20 Commits

Author SHA1 Message Date
c776785f30 Merge pull request #103 from lordmathis/docs/api-keys
docs: Improve API key management documentation
2025-12-08 19:23:39 +01:00
1cfbd42eda Update swagger docs 2025-12-08 19:16:02 +01:00
8fee27054d Update docs for API key management 2025-12-08 19:15:42 +01:00
fd33837026 Merge pull request #101 from lordmathis/feat/api-key-mgmt
feat: Add inference api key management
2025-12-08 18:49:49 +01:00
3c4ebf7403 Addsimple python LLM test client 2025-12-08 18:44:28 +01:00
b7a0f7e3d8 Unhide migrated directory 2025-12-08 18:08:22 +01:00
d5b68a900f Add .migrated directory for migrated json files 2025-12-08 18:06:15 +01:00
00cd8c8877 Update shadcn componments 2025-12-07 18:50:52 +01:00
4b1b12a7a8 Fix lint errors 2025-12-07 18:28:01 +01:00
0ce9016488 Fix some lint issues 2025-12-07 17:40:09 +01:00
1acbcafe1c Add DialogDescription to SettingsDialog 2025-12-07 17:26:38 +01:00
00a502a268 Implement LocalStorageMock for testing 2025-12-07 17:16:40 +01:00
54fe0f7421 Fix eslint issues 2025-12-07 16:16:13 +01:00
cd1bd64889 Refactor CreateApiKeyDialog to use instance IDs 2025-12-06 22:20:39 +01:00
0fee7abc7c Simplify create key request format 2025-12-06 22:20:05 +01:00
02193bd309 Add instance ID to JSON output 2025-12-06 21:28:17 +01:00
0217f7cc4e Fix instance creation to retrieve and set the auto-generated ID 2025-12-06 20:58:17 +01:00
fa311c46ac Improve server shutdown process 2025-12-06 19:52:40 +01:00
99927160c2 Remove 'can_infer' field 2025-12-06 18:07:01 +01:00
c37c1b8161 Remove 'enabled' field from API key model and related database operations 2025-12-06 17:59:11 +01:00
42 changed files with 616 additions and 456 deletions

1
.vscode/launch.json vendored
View File

@@ -14,6 +14,7 @@
"GO_ENV": "development", "GO_ENV": "development",
"LLAMACTL_CONFIG_PATH": "${workspaceFolder}/llamactl.dev.yaml" "LLAMACTL_CONFIG_PATH": "${workspaceFolder}/llamactl.dev.yaml"
}, },
"console": "integratedTerminal",
} }
] ]
} }

View File

@@ -183,7 +183,7 @@ data_dir: ~/.local/share/llamactl # Main data directory (database, instances, l
instances: instances:
port_range: [8000, 9000] # Port range for instances port_range: [8000, 9000] # Port range for instances
configs_dir: ~/.local/share/llamactl/instances # Instance configs directory (platform dependent) configs_dir: ~/.local/share/llamactl/instances # Instance configs directory (platform dependent) [deprecated]
logs_dir: ~/.local/share/llamactl/logs # Logs directory (platform dependent) logs_dir: ~/.local/share/llamactl/logs # Logs directory (platform dependent)
auto_create_dirs: true # Auto-create data/config/logs dirs if missing auto_create_dirs: true # Auto-create data/config/logs dirs if missing
max_instances: -1 # Max instances (-1 = unlimited) max_instances: -1 # Max instances (-1 = unlimited)
@@ -203,8 +203,7 @@ database:
connection_max_lifetime: 5m # Connection max lifetime connection_max_lifetime: 5m # Connection max lifetime
auth: auth:
require_inference_auth: true # Require auth for inference endpoints require_inference_auth: true # Require auth for inference endpoints, API keys are created in web UI
inference_keys: [] # Keys for inference endpoints
require_management_auth: true # Require auth for management endpoints require_management_auth: true # Require auth for management endpoints
management_keys: [] # Keys for management endpoints management_keys: [] # Keys for management endpoints
``` ```

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"llamactl/pkg/config" "llamactl/pkg/config"
"llamactl/pkg/database" "llamactl/pkg/database"
@@ -11,6 +12,7 @@ import (
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
"time"
) )
// version is set at build time using -ldflags "-X main.version=1.0.0" // version is set at build time using -ldflags "-X main.version=1.0.0"
@@ -116,14 +118,23 @@ func main() {
<-stop <-stop
fmt.Println("Shutting down server...") fmt.Println("Shutting down server...")
if err := server.Close(); err != nil { // Create shutdown context with timeout
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
// Shutdown HTTP server gracefully
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("Error shutting down server: %v\n", err) log.Printf("Error shutting down server: %v\n", err)
} else { } else {
fmt.Println("Server shut down gracefully.") fmt.Println("Server shut down gracefully.")
} }
// Wait for all instances to stop // Stop all instances and cleanup
instanceManager.Shutdown() instanceManager.Shutdown()
if err := db.Close(); err != nil {
log.Printf("Error closing database: %v\n", err)
}
fmt.Println("Exiting llamactl.") fmt.Println("Exiting llamactl.")
} }

View File

@@ -13,6 +13,7 @@ import (
// migrateFromJSON migrates instances from JSON files to SQLite database // migrateFromJSON migrates instances from JSON files to SQLite database
// This is a one-time migration that runs on first startup with existing JSON files. // This is a one-time migration that runs on first startup with existing JSON files.
// Migrated files are moved to a migrated subdirectory to avoid re-importing.
func migrateFromJSON(cfg *config.AppConfig, db database.InstanceStore) error { func migrateFromJSON(cfg *config.AppConfig, db database.InstanceStore) error {
instancesDir := cfg.Instances.InstancesDir instancesDir := cfg.Instances.InstancesDir
if instancesDir == "" { if instancesDir == "" {
@@ -24,16 +25,6 @@ func migrateFromJSON(cfg *config.AppConfig, db database.InstanceStore) error {
return nil // No instances directory, nothing to migrate return nil // No instances directory, nothing to migrate
} }
// Check if database is empty (no instances)
existing, err := db.LoadAll()
if err != nil {
return fmt.Errorf("failed to check existing instances: %w", err)
}
if len(existing) > 0 {
return nil // Database already has instances, skip migration
}
// Find all JSON files // Find all JSON files
files, err := filepath.Glob(filepath.Join(instancesDir, "*.json")) files, err := filepath.Glob(filepath.Join(instancesDir, "*.json"))
if err != nil { if err != nil {
@@ -46,6 +37,12 @@ func migrateFromJSON(cfg *config.AppConfig, db database.InstanceStore) error {
log.Printf("Migrating %d instances from JSON to SQLite...", len(files)) log.Printf("Migrating %d instances from JSON to SQLite...", len(files))
// Create migrated directory
migratedDir := filepath.Join(instancesDir, "migrated")
if err := os.MkdirAll(migratedDir, 0755); err != nil {
return fmt.Errorf("failed to create migrated directory: %w", err)
}
// Migrate each JSON file // Migrate each JSON file
var migrated int var migrated int
for _, file := range files { for _, file := range files {
@@ -53,6 +50,14 @@ func migrateFromJSON(cfg *config.AppConfig, db database.InstanceStore) error {
log.Printf("Failed to migrate %s: %v", file, err) log.Printf("Failed to migrate %s: %v", file, err)
continue continue
} }
// Move the file to the migrated directory
destPath := filepath.Join(migratedDir, filepath.Base(file))
if err := os.Rename(file, destPath); err != nil {
log.Printf("Warning: Failed to move %s to migrated directory: %v", file, err)
// Don't fail the migration if we can't move the file
}
migrated++ migrated++
} }

View File

@@ -74,7 +74,6 @@ database:
auth: auth:
require_inference_auth: true # Require auth for inference endpoints require_inference_auth: true # Require auth for inference endpoints
inference_keys: [] # Keys for inference endpoints
require_management_auth: true # Require auth for management endpoints require_management_auth: true # Require auth for management endpoints
management_keys: [] # Keys for management endpoints management_keys: [] # Keys for management endpoints
@@ -266,17 +265,33 @@ database:
### Authentication Configuration ### Authentication Configuration
llamactl supports two types of authentication:
- **Management API Keys**: For accessing the web UI and management API (creating/managing instances). These can be configured in the config file or via environment variables.
- **Inference API Keys**: For accessing the OpenAI-compatible inference endpoints. These are managed via the web UI (Settings → API Keys) and stored in the database.
```yaml ```yaml
auth: auth:
require_inference_auth: true # Require API key for OpenAI endpoints (default: true) require_inference_auth: true # Require API key for OpenAI endpoints (default: true)
inference_keys: [] # List of valid inference API keys
require_management_auth: true # Require API key for management endpoints (default: true) require_management_auth: true # Require API key for management endpoints (default: true)
management_keys: [] # List of valid management API keys management_keys: [] # List of valid management API keys
``` ```
**Managing Inference API Keys:**
Inference API keys are managed through the web UI or management API and stored in the database. To create and manage inference keys:
1. Open the web UI and log in with a management API key
2. Navigate to **Settings → API Keys**
3. Click **Create API Key**
4. Configure the key:
- **Name**: A descriptive name for the key
- **Expiration**: Optional expiration date
- **Permissions**: Grant access to all instances or specific instances only
5. Copy the generated key - it won't be shown again
**Environment Variables:** **Environment Variables:**
- `LLAMACTL_REQUIRE_INFERENCE_AUTH` - Require auth for OpenAI endpoints (true/false) - `LLAMACTL_REQUIRE_INFERENCE_AUTH` - Require auth for OpenAI endpoints (true/false)
- `LLAMACTL_INFERENCE_KEYS` - Comma-separated inference API keys
- `LLAMACTL_REQUIRE_MANAGEMENT_AUTH` - Require auth for management endpoints (true/false) - `LLAMACTL_REQUIRE_MANAGEMENT_AUTH` - Require auth for management endpoints (true/false)
- `LLAMACTL_MANAGEMENT_KEYS` - Comma-separated management API keys - `LLAMACTL_MANAGEMENT_KEYS` - Comma-separated management API keys

View File

@@ -2063,20 +2063,19 @@ const docTemplate = `{
"server.CreateKeyRequest": { "server.CreateKeyRequest": {
"type": "object", "type": "object",
"properties": { "properties": {
"expiresAt": { "expires_at": {
"type": "integer", "type": "integer"
"format": "int64"
}, },
"instancePermissions": { "instance_ids": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/server.InstancePermission" "type": "integer"
} }
}, },
"name": { "name": {
"type": "string" "type": "string"
}, },
"permissionMode": { "permission_mode": {
"$ref": "#/definitions/auth.PermissionMode" "$ref": "#/definitions/auth.PermissionMode"
} }
} }
@@ -2087,9 +2086,6 @@ const docTemplate = `{
"created_at": { "created_at": {
"type": "integer" "type": "integer"
}, },
"enabled": {
"type": "boolean"
},
"expires_at": { "expires_at": {
"type": "integer" "type": "integer"
}, },
@@ -2116,29 +2112,9 @@ const docTemplate = `{
} }
} }
}, },
"server.InstancePermission": {
"type": "object",
"properties": {
"can_infer": {
"type": "boolean"
},
"can_view_logs": {
"type": "boolean"
},
"instance_id": {
"type": "integer"
}
}
},
"server.KeyPermissionResponse": { "server.KeyPermissionResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
"can_infer": {
"type": "boolean"
},
"can_view_logs": {
"type": "boolean"
},
"instance_id": { "instance_id": {
"type": "integer" "type": "integer"
}, },
@@ -2153,9 +2129,6 @@ const docTemplate = `{
"created_at": { "created_at": {
"type": "integer" "type": "integer"
}, },
"enabled": {
"type": "boolean"
},
"expires_at": { "expires_at": {
"type": "integer" "type": "integer"
}, },

View File

@@ -17,10 +17,10 @@ Before you start, let's clarify a few key terms:
Llamactl uses two types of API keys: Llamactl uses two types of API keys:
- **Management API Key**: Used to authenticate with the Llamactl management API (creating, starting, stopping instances). - **Management API Key**: Used to authenticate with the Llamactl management API and web UI. If not configured, one is auto-generated at startup and printed to the terminal.
- **Inference API Key**: Used to authenticate requests to the OpenAI-compatible endpoints (`/v1/chat/completions`, `/v1/completions`, etc.). - **Inference API Key**: Used to authenticate requests to the OpenAI-compatible endpoints (`/v1/chat/completions`, `/v1/completions`, etc.). These are created and managed via the web UI.
By default, authentication is required. If you don't configure these keys in your configuration file, llamactl will auto-generate them and print them to the terminal on startup. You can also configure custom keys or disable authentication entirely in the [Configuration](configuration.md) guide. By default, authentication is required for both management and inference endpoints. You can configure custom management keys or disable authentication in the [Configuration](configuration.md) guide.
## Start Llamactl ## Start Llamactl
@@ -38,24 +38,17 @@ llamactl
sk-management-... sk-management-...
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⚠️ INFERENCE AUTHENTICATION REQUIRED
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🔑 Generated Inference API Key:
sk-inference-...
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⚠️ IMPORTANT ⚠️ IMPORTANT
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
• These keys are auto-generated and will change on restart • This key is auto-generated and will change on restart
• For production, add explicit keys to your configuration • For production, add explicit management_keys to your configuration
• Copy these keys before they disappear from the terminal • Copy this key before it disappears from the terminal
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Llamactl server listening on 0.0.0.0:8080 Llamactl server listening on 0.0.0.0:8080
``` ```
Copy the **Management** and **Inference** API Keys from the terminal - you'll need them to access the web UI and make inference requests. Copy the **Management API Key** from the terminal - you'll need it to access the web UI.
By default, Llamactl will start on `http://localhost:8080`. By default, Llamactl will start on `http://localhost:8080`.
@@ -82,7 +75,7 @@ You should see the Llamactl web interface.
- **Additional Options**: Backend-specific parameters - **Additional Options**: Backend-specific parameters
!!! tip "Auto-Assignment" !!! tip "Auto-Assignment"
Llamactl automatically assigns ports from the configured port range (default: 8000-9000) and generates API keys if authentication is enabled. You typically don't need to manually specify these values. Llamactl automatically assigns ports from the configured port range (default: 8000-9000) and manages API keys if authentication is enabled. You typically don't need to manually specify these values.
!!! note "Remote Node Deployment" !!! note "Remote Node Deployment"
If you have configured remote nodes in your configuration file, you can select which node to deploy the instance to. This allows you to distribute instances across multiple machines. See the [Configuration](configuration.md#remote-node-configuration) guide for details on setting up remote nodes. If you have configured remote nodes in your configuration file, you can select which node to deploy the instance to. This allows you to distribute instances across multiple machines. See the [Configuration](configuration.md#remote-node-configuration) guide for details on setting up remote nodes.
@@ -98,6 +91,24 @@ Once created, you can:
- **View logs** by clicking the logs button - **View logs** by clicking the logs button
- **Stop** the instance when needed - **Stop** the instance when needed
## Create an Inference API Key
To make inference requests to your instances, you'll need an inference API key:
1. In the web UI, click the **Settings** icon (gear icon in the top-right)
2. Navigate to the **API Keys** tab
3. Click **Create API Key**
4. Configure your key:
- **Name**: Give it a descriptive name (e.g., "Production Key", "Development Key")
- **Expiration**: Optionally set an expiration date for the key
- **Permissions**: Choose whether the key can access all instances or only specific ones
5. Click **Create**
6. **Copy the generated key** - it will only be shown once!
The key will look like: `llamactl-...`
You can create multiple inference keys with different permissions for different use cases (e.g., one for development, one for production, or keys limited to specific instances).
## Example Configurations ## Example Configurations
Here are basic example configurations for each backend: Here are basic example configurations for each backend:
@@ -246,7 +257,7 @@ print(response.choices[0].message.content)
``` ```
!!! note "API Key" !!! note "API Key"
If you disabled authentication in your config, you can use any value for `api_key` (e.g., `"not-needed"`). Otherwise, use the inference API key shown in the terminal output on startup. If you disabled authentication in your config, you can use any value for `api_key` (e.g., `"not-needed"`). Otherwise, use the inference API key you created via the web UI (Settings → API Keys).
### List Available Models ### List Available Models

View File

@@ -2056,20 +2056,19 @@
"server.CreateKeyRequest": { "server.CreateKeyRequest": {
"type": "object", "type": "object",
"properties": { "properties": {
"expiresAt": { "expires_at": {
"type": "integer", "type": "integer"
"format": "int64"
}, },
"instancePermissions": { "instance_ids": {
"type": "array", "type": "array",
"items": { "items": {
"$ref": "#/definitions/server.InstancePermission" "type": "integer"
} }
}, },
"name": { "name": {
"type": "string" "type": "string"
}, },
"permissionMode": { "permission_mode": {
"$ref": "#/definitions/auth.PermissionMode" "$ref": "#/definitions/auth.PermissionMode"
} }
} }
@@ -2080,9 +2079,6 @@
"created_at": { "created_at": {
"type": "integer" "type": "integer"
}, },
"enabled": {
"type": "boolean"
},
"expires_at": { "expires_at": {
"type": "integer" "type": "integer"
}, },
@@ -2109,29 +2105,9 @@
} }
} }
}, },
"server.InstancePermission": {
"type": "object",
"properties": {
"can_infer": {
"type": "boolean"
},
"can_view_logs": {
"type": "boolean"
},
"instance_id": {
"type": "integer"
}
}
},
"server.KeyPermissionResponse": { "server.KeyPermissionResponse": {
"type": "object", "type": "object",
"properties": { "properties": {
"can_infer": {
"type": "boolean"
},
"can_view_logs": {
"type": "boolean"
},
"instance_id": { "instance_id": {
"type": "integer" "type": "integer"
}, },
@@ -2146,9 +2122,6 @@
"created_at": { "created_at": {
"type": "integer" "type": "integer"
}, },
"enabled": {
"type": "boolean"
},
"expires_at": { "expires_at": {
"type": "integer" "type": "integer"
}, },

View File

@@ -232,24 +232,21 @@ definitions:
type: object type: object
server.CreateKeyRequest: server.CreateKeyRequest:
properties: properties:
expiresAt: expires_at:
format: int64
type: integer type: integer
instancePermissions: instance_ids:
items: items:
$ref: '#/definitions/server.InstancePermission' type: integer
type: array type: array
name: name:
type: string type: string
permissionMode: permission_mode:
$ref: '#/definitions/auth.PermissionMode' $ref: '#/definitions/auth.PermissionMode'
type: object type: object
server.CreateKeyResponse: server.CreateKeyResponse:
properties: properties:
created_at: created_at:
type: integer type: integer
enabled:
type: boolean
expires_at: expires_at:
type: integer type: integer
id: id:
@@ -267,21 +264,8 @@ definitions:
user_id: user_id:
type: string type: string
type: object type: object
server.InstancePermission:
properties:
can_infer:
type: boolean
can_view_logs:
type: boolean
instance_id:
type: integer
type: object
server.KeyPermissionResponse: server.KeyPermissionResponse:
properties: properties:
can_infer:
type: boolean
can_view_logs:
type: boolean
instance_id: instance_id:
type: integer type: integer
instance_name: instance_name:
@@ -291,8 +275,6 @@ definitions:
properties: properties:
created_at: created_at:
type: integer type: integer
enabled:
type: boolean
expires_at: expires_at:
type: integer type: integer
id: id:

View File

@@ -115,15 +115,15 @@ vllm serve microsoft/DialoGPT-medium --port 8081
require_inference_auth: false require_inference_auth: false
``` ```
2. **Configure API keys:** 2. **Configure management API keys:**
```yaml ```yaml
auth: auth:
management_keys: management_keys:
- "your-management-key" - "your-management-key"
inference_keys:
- "your-inference-key"
``` ```
For inference API keys, create them via the web UI (Settings → API Keys) after logging in with your management key.
3. **Use correct Authorization header:** 3. **Use correct Authorization header:**
```bash ```bash
curl -H "Authorization: Bearer your-api-key" \ curl -H "Authorization: Bearer your-api-key" \

View File

@@ -20,7 +20,6 @@ type APIKey struct {
UserID string UserID string
PermissionMode PermissionMode PermissionMode PermissionMode
ExpiresAt *int64 ExpiresAt *int64
Enabled bool
CreatedAt int64 CreatedAt int64
UpdatedAt int64 UpdatedAt int64
LastUsedAt *int64 LastUsedAt *int64
@@ -29,7 +28,6 @@ type APIKey struct {
type KeyPermission struct { type KeyPermission struct {
KeyID int KeyID int
InstanceID int InstanceID int
CanInfer bool
} }
// GenerateKey generates a cryptographically secure API key with the given prefix // GenerateKey generates a cryptographically secure API key with the given prefix

View File

@@ -18,8 +18,8 @@ func (db *sqliteDB) CreateKey(ctx context.Context, key *auth.APIKey, permissions
// Insert the API key // Insert the API key
query := ` query := `
INSERT INTO api_keys (key_hash, name, user_id, permission_mode, expires_at, enabled, created_at, updated_at) INSERT INTO api_keys (key_hash, name, user_id, permission_mode, expires_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
` `
var expiresAt sql.NullInt64 var expiresAt sql.NullInt64
@@ -29,7 +29,7 @@ func (db *sqliteDB) CreateKey(ctx context.Context, key *auth.APIKey, permissions
result, err := tx.ExecContext(ctx, query, result, err := tx.ExecContext(ctx, query,
key.KeyHash, key.Name, key.UserID, key.PermissionMode, key.KeyHash, key.Name, key.UserID, key.PermissionMode,
expiresAt, key.Enabled, key.CreatedAt, key.UpdatedAt, expiresAt, key.CreatedAt, key.UpdatedAt,
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to insert API key: %w", err) return fmt.Errorf("failed to insert API key: %w", err)
@@ -45,10 +45,10 @@ func (db *sqliteDB) CreateKey(ctx context.Context, key *auth.APIKey, permissions
if key.PermissionMode == auth.PermissionModePerInstance { if key.PermissionMode == auth.PermissionModePerInstance {
for _, perm := range permissions { for _, perm := range permissions {
query := ` query := `
INSERT INTO key_permissions (key_id, instance_id, can_infer) INSERT INTO key_permissions (key_id, instance_id)
VALUES (?, ?, ?) VALUES (?, ?)
` `
_, err := tx.ExecContext(ctx, query, key.ID, perm.InstanceID, perm.CanInfer) _, err := tx.ExecContext(ctx, query, key.ID, perm.InstanceID)
if err != nil { if err != nil {
return fmt.Errorf("failed to insert permission for instance %d: %w", perm.InstanceID, err) return fmt.Errorf("failed to insert permission for instance %d: %w", perm.InstanceID, err)
} }
@@ -61,7 +61,7 @@ func (db *sqliteDB) CreateKey(ctx context.Context, key *auth.APIKey, permissions
// GetKeyByID retrieves an API key by ID // GetKeyByID retrieves an API key by ID
func (db *sqliteDB) GetKeyByID(ctx context.Context, id int) (*auth.APIKey, error) { func (db *sqliteDB) GetKeyByID(ctx context.Context, id int) (*auth.APIKey, error) {
query := ` query := `
SELECT id, key_hash, name, user_id, permission_mode, expires_at, enabled, created_at, updated_at, last_used_at SELECT id, key_hash, name, user_id, permission_mode, expires_at, created_at, updated_at, last_used_at
FROM api_keys FROM api_keys
WHERE id = ? WHERE id = ?
` `
@@ -72,7 +72,7 @@ func (db *sqliteDB) GetKeyByID(ctx context.Context, id int) (*auth.APIKey, error
err := db.QueryRowContext(ctx, query, id).Scan( err := db.QueryRowContext(ctx, query, id).Scan(
&key.ID, &key.KeyHash, &key.Name, &key.UserID, &key.PermissionMode, &key.ID, &key.KeyHash, &key.Name, &key.UserID, &key.PermissionMode,
&expiresAt, &key.Enabled, &key.CreatedAt, &key.UpdatedAt, &lastUsedAt, &expiresAt, &key.CreatedAt, &key.UpdatedAt, &lastUsedAt,
) )
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
@@ -94,7 +94,7 @@ func (db *sqliteDB) GetKeyByID(ctx context.Context, id int) (*auth.APIKey, error
// GetUserKeys retrieves all API keys for a user // GetUserKeys retrieves all API keys for a user
func (db *sqliteDB) GetUserKeys(ctx context.Context, userID string) ([]*auth.APIKey, error) { func (db *sqliteDB) GetUserKeys(ctx context.Context, userID string) ([]*auth.APIKey, error) {
query := ` query := `
SELECT id, key_hash, name, user_id, permission_mode, expires_at, enabled, created_at, updated_at, last_used_at SELECT id, key_hash, name, user_id, permission_mode, expires_at, created_at, updated_at, last_used_at
FROM api_keys FROM api_keys
WHERE user_id = ? WHERE user_id = ?
ORDER BY created_at DESC ORDER BY created_at DESC
@@ -114,7 +114,7 @@ func (db *sqliteDB) GetUserKeys(ctx context.Context, userID string) ([]*auth.API
err := rows.Scan( err := rows.Scan(
&key.ID, &key.KeyHash, &key.Name, &key.UserID, &key.PermissionMode, &key.ID, &key.KeyHash, &key.Name, &key.UserID, &key.PermissionMode,
&expiresAt, &key.Enabled, &key.CreatedAt, &key.UpdatedAt, &lastUsedAt, &expiresAt, &key.CreatedAt, &key.UpdatedAt, &lastUsedAt,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to scan API key: %w", err) return nil, fmt.Errorf("failed to scan API key: %w", err)
@@ -133,12 +133,12 @@ func (db *sqliteDB) GetUserKeys(ctx context.Context, userID string) ([]*auth.API
return keys, nil return keys, nil
} }
// GetActiveKeys retrieves all enabled, non-expired API keys // GetActiveKeys retrieves all non-expired API keys
func (db *sqliteDB) GetActiveKeys(ctx context.Context) ([]*auth.APIKey, error) { func (db *sqliteDB) GetActiveKeys(ctx context.Context) ([]*auth.APIKey, error) {
query := ` query := `
SELECT id, key_hash, name, user_id, permission_mode, expires_at, enabled, created_at, updated_at, last_used_at SELECT id, key_hash, name, user_id, permission_mode, expires_at, created_at, updated_at, last_used_at
FROM api_keys FROM api_keys
WHERE enabled = 1 AND (expires_at IS NULL OR expires_at > ?) WHERE expires_at IS NULL OR expires_at > ?
ORDER BY created_at DESC ORDER BY created_at DESC
` `
@@ -157,7 +157,7 @@ func (db *sqliteDB) GetActiveKeys(ctx context.Context) ([]*auth.APIKey, error) {
err := rows.Scan( err := rows.Scan(
&key.ID, &key.KeyHash, &key.Name, &key.UserID, &key.PermissionMode, &key.ID, &key.KeyHash, &key.Name, &key.UserID, &key.PermissionMode,
&expiresAt, &key.Enabled, &key.CreatedAt, &key.UpdatedAt, &lastUsedAt, &expiresAt, &key.CreatedAt, &key.UpdatedAt, &lastUsedAt,
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to scan API key: %w", err) return nil, fmt.Errorf("failed to scan API key: %w", err)

View File

@@ -107,6 +107,12 @@ func Open(config *Config) (*sqliteDB, error) {
func (db *sqliteDB) Close() error { func (db *sqliteDB) Close() error {
if db.DB != nil { if db.DB != nil {
log.Println("Closing database connection") log.Println("Closing database connection")
// Checkpoint WAL to merge changes back to main database file
if _, err := db.DB.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
log.Printf("Warning: Failed to checkpoint WAL: %v", err)
}
return db.DB.Close() return db.DB.Close()
} }
return nil return nil

View File

@@ -45,7 +45,7 @@ func (db *sqliteDB) Create(ctx context.Context, inst *instance.Instance) error {
) VALUES (?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?)
` `
_, err = db.DB.ExecContext(ctx, query, result, err := db.DB.ExecContext(ctx, query,
row.Name, row.Status, row.CreatedAt, row.UpdatedAt, row.OptionsJSON, row.OwnerUserID, row.Name, row.Status, row.CreatedAt, row.UpdatedAt, row.OptionsJSON, row.OwnerUserID,
) )
@@ -53,6 +53,14 @@ func (db *sqliteDB) Create(ctx context.Context, inst *instance.Instance) error {
return fmt.Errorf("failed to insert instance: %w", err) return fmt.Errorf("failed to insert instance: %w", err)
} }
// Get the auto-generated ID and set it on the instance
id, err := result.LastInsertId()
if err != nil {
return fmt.Errorf("failed to get last insert ID: %w", err)
}
inst.ID = int(id)
return nil return nil
} }
@@ -263,6 +271,7 @@ func (db *sqliteDB) rowToInstance(row *instanceRow) (*instance.Instance, error)
// Build complete instance JSON with all fields // Build complete instance JSON with all fields
instanceJSON, err := json.Marshal(map[string]any{ instanceJSON, err := json.Marshal(map[string]any{
"id": row.ID,
"name": row.Name, "name": row.Name,
"created": row.CreatedAt, "created": row.CreatedAt,
"status": row.Status, "status": row.Status,

View File

@@ -36,7 +36,6 @@ CREATE TABLE IF NOT EXISTS api_keys (
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
permission_mode TEXT NOT NULL CHECK(permission_mode IN ('allow_all', 'per_instance')) DEFAULT 'per_instance', permission_mode TEXT NOT NULL CHECK(permission_mode IN ('allow_all', 'per_instance')) DEFAULT 'per_instance',
expires_at INTEGER NULL, expires_at INTEGER NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL, updated_at INTEGER NOT NULL,
last_used_at INTEGER NULL last_used_at INTEGER NULL
@@ -48,7 +47,6 @@ CREATE TABLE IF NOT EXISTS api_keys (
CREATE TABLE IF NOT EXISTS key_permissions ( CREATE TABLE IF NOT EXISTS key_permissions (
key_id INTEGER NOT NULL, key_id INTEGER NOT NULL,
instance_id INTEGER NOT NULL, instance_id INTEGER NOT NULL,
can_infer INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY (key_id, instance_id), PRIMARY KEY (key_id, instance_id),
FOREIGN KEY (key_id) REFERENCES api_keys (id) ON DELETE CASCADE, FOREIGN KEY (key_id) REFERENCES api_keys (id) ON DELETE CASCADE,
FOREIGN KEY (instance_id) REFERENCES instances (id) ON DELETE CASCADE FOREIGN KEY (instance_id) REFERENCES instances (id) ON DELETE CASCADE

View File

@@ -10,7 +10,7 @@ import (
// GetPermissions retrieves all permissions for a key // GetPermissions retrieves all permissions for a key
func (db *sqliteDB) GetPermissions(ctx context.Context, keyID int) ([]auth.KeyPermission, error) { func (db *sqliteDB) GetPermissions(ctx context.Context, keyID int) ([]auth.KeyPermission, error) {
query := ` query := `
SELECT key_id, instance_id, can_infer SELECT key_id, instance_id
FROM key_permissions FROM key_permissions
WHERE key_id = ? WHERE key_id = ?
ORDER BY instance_id ORDER BY instance_id
@@ -25,7 +25,7 @@ func (db *sqliteDB) GetPermissions(ctx context.Context, keyID int) ([]auth.KeyPe
var permissions []auth.KeyPermission var permissions []auth.KeyPermission
for rows.Next() { for rows.Next() {
var perm auth.KeyPermission var perm auth.KeyPermission
err := rows.Scan(&perm.KeyID, &perm.InstanceID, &perm.CanInfer) err := rows.Scan(&perm.KeyID, &perm.InstanceID)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to scan key permission: %w", err) return nil, fmt.Errorf("failed to scan key permission: %w", err)
} }
@@ -38,13 +38,13 @@ func (db *sqliteDB) GetPermissions(ctx context.Context, keyID int) ([]auth.KeyPe
// HasPermission checks if key has inference permission for instance // HasPermission checks if key has inference permission for instance
func (db *sqliteDB) HasPermission(ctx context.Context, keyID, instanceID int) (bool, error) { func (db *sqliteDB) HasPermission(ctx context.Context, keyID, instanceID int) (bool, error) {
query := ` query := `
SELECT can_infer SELECT 1
FROM key_permissions FROM key_permissions
WHERE key_id = ? AND instance_id = ? WHERE key_id = ? AND instance_id = ?
` `
var canInfer bool var exists int
err := db.QueryRowContext(ctx, query, keyID, instanceID).Scan(&canInfer) err := db.QueryRowContext(ctx, query, keyID, instanceID).Scan(&exists)
if err != nil { if err != nil {
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
// No permission record found, deny access // No permission record found, deny access
@@ -53,5 +53,5 @@ func (db *sqliteDB) HasPermission(ctx context.Context, keyID, instanceID int) (b
return false, fmt.Errorf("failed to check key permission: %w", err) return false, fmt.Errorf("failed to check key permission: %w", err)
} }
return canInfer, nil return true, nil
} }

View File

@@ -114,11 +114,6 @@ func (im *instanceManager) Shutdown() {
} }
wg.Wait() wg.Wait()
fmt.Println("All instances stopped.") fmt.Println("All instances stopped.")
// 4. Close database connection
if err := im.db.Close(); err != nil {
log.Printf("Error closing database: %v\n", err)
}
}) })
} }
@@ -181,6 +176,7 @@ func (im *instanceManager) loadInstance(persistedInst *instance.Instance) error
inst := instance.New(name, im.globalConfig, options, statusCallback) inst := instance.New(name, im.globalConfig, options, statusCallback)
// Restore persisted fields that NewInstance doesn't set // Restore persisted fields that NewInstance doesn't set
inst.ID = persistedInst.ID
inst.Created = persistedInst.Created inst.Created = persistedInst.Created
inst.SetStatus(persistedInst.GetStatus()) inst.SetStatus(persistedInst.GetStatus())

View File

@@ -37,7 +37,6 @@ func (im *instanceManager) ListInstances() ([]*instance.Instance, error) {
if node := im.getNodeForInstance(inst); node != nil { if node := im.getNodeForInstance(inst); node != nil {
remoteInst, err := im.remote.getInstance(ctx, node, inst.Name) remoteInst, err := im.remote.getInstance(ctx, node, inst.Name)
if err != nil { if err != nil {
// Log error but continue with stale data
// Don't fail the entire list operation due to one remote failure // Don't fail the entire list operation due to one remote failure
continue continue
} }

View File

@@ -11,18 +11,12 @@ import (
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
) )
// InstancePermission defines the permissions for an API key on a specific instance.
type InstancePermission struct {
InstanceID int `json:"instance_id"`
CanInfer bool `json:"can_infer"`
}
// CreateKeyRequest represents the request body for creating a new API key. // CreateKeyRequest represents the request body for creating a new API key.
type CreateKeyRequest struct { type CreateKeyRequest struct {
Name string Name string `json:"name"`
PermissionMode auth.PermissionMode PermissionMode auth.PermissionMode `json:"permission_mode"`
ExpiresAt *int64 ExpiresAt *int64 `json:"expires_at,omitempty"`
InstancePermissions []InstancePermission InstanceIDs []int `json:"instance_ids,omitempty"`
} }
// CreateKeyResponse represents the response returned when creating a new API key. // CreateKeyResponse represents the response returned when creating a new API key.
@@ -32,7 +26,6 @@ type CreateKeyResponse struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
PermissionMode auth.PermissionMode `json:"permission_mode"` PermissionMode auth.PermissionMode `json:"permission_mode"`
ExpiresAt *int64 `json:"expires_at"` ExpiresAt *int64 `json:"expires_at"`
Enabled bool `json:"enabled"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
LastUsedAt *int64 `json:"last_used_at"` LastUsedAt *int64 `json:"last_used_at"`
@@ -46,7 +39,6 @@ type KeyResponse struct {
UserID string `json:"user_id"` UserID string `json:"user_id"`
PermissionMode auth.PermissionMode `json:"permission_mode"` PermissionMode auth.PermissionMode `json:"permission_mode"`
ExpiresAt *int64 `json:"expires_at"` ExpiresAt *int64 `json:"expires_at"`
Enabled bool `json:"enabled"`
CreatedAt int64 `json:"created_at"` CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"` UpdatedAt int64 `json:"updated_at"`
LastUsedAt *int64 `json:"last_used_at"` LastUsedAt *int64 `json:"last_used_at"`
@@ -56,7 +48,6 @@ type KeyResponse struct {
type KeyPermissionResponse struct { type KeyPermissionResponse struct {
InstanceID int `json:"instance_id"` InstanceID int `json:"instance_id"`
InstanceName string `json:"instance_name"` InstanceName string `json:"instance_name"`
CanInfer bool `json:"can_infer"`
} }
// CreateKey godoc // CreateKey godoc
@@ -91,8 +82,8 @@ func (h *Handler) CreateKey() http.HandlerFunc {
writeError(w, http.StatusBadRequest, "invalid_permission_mode", "Permission mode must be 'allow_all' or 'per_instance'") writeError(w, http.StatusBadRequest, "invalid_permission_mode", "Permission mode must be 'allow_all' or 'per_instance'")
return return
} }
if req.PermissionMode == auth.PermissionModePerInstance && len(req.InstancePermissions) == 0 { if req.PermissionMode == auth.PermissionModePerInstance && len(req.InstanceIDs) == 0 {
writeError(w, http.StatusBadRequest, "missing_permissions", "Instance permissions required when permission mode is 'per_instance'") writeError(w, http.StatusBadRequest, "missing_permissions", "Instance IDs required when permission mode is 'per_instance'")
return return
} }
if req.ExpiresAt != nil && *req.ExpiresAt <= time.Now().Unix() { if req.ExpiresAt != nil && *req.ExpiresAt <= time.Now().Unix() {
@@ -112,9 +103,9 @@ func (h *Handler) CreateKey() http.HandlerFunc {
instanceIDMap[inst.ID] = true instanceIDMap[inst.ID] = true
} }
for _, perm := range req.InstancePermissions { for _, instanceID := range req.InstanceIDs {
if !instanceIDMap[perm.InstanceID] { if !instanceIDMap[instanceID] {
writeError(w, http.StatusBadRequest, "invalid_instance_id", fmt.Sprintf("Instance ID %d does not exist", perm.InstanceID)) writeError(w, http.StatusBadRequest, "invalid_instance_id", fmt.Sprintf("Instance ID %d does not exist", instanceID))
return return
} }
} }
@@ -142,18 +133,16 @@ func (h *Handler) CreateKey() http.HandlerFunc {
UserID: "system", UserID: "system",
PermissionMode: req.PermissionMode, PermissionMode: req.PermissionMode,
ExpiresAt: req.ExpiresAt, ExpiresAt: req.ExpiresAt,
Enabled: true,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
// Convert InstancePermissions to KeyPermissions // Convert InstanceIDs to KeyPermissions
var keyPermissions []auth.KeyPermission var keyPermissions []auth.KeyPermission
for _, perm := range req.InstancePermissions { for _, instanceID := range req.InstanceIDs {
keyPermissions = append(keyPermissions, auth.KeyPermission{ keyPermissions = append(keyPermissions, auth.KeyPermission{
KeyID: 0, // Will be set by database after key creation KeyID: 0, // Will be set by database after key creation
InstanceID: perm.InstanceID, InstanceID: instanceID,
CanInfer: perm.CanInfer,
}) })
} }
@@ -171,7 +160,6 @@ func (h *Handler) CreateKey() http.HandlerFunc {
UserID: apiKey.UserID, UserID: apiKey.UserID,
PermissionMode: apiKey.PermissionMode, PermissionMode: apiKey.PermissionMode,
ExpiresAt: apiKey.ExpiresAt, ExpiresAt: apiKey.ExpiresAt,
Enabled: apiKey.Enabled,
CreatedAt: apiKey.CreatedAt, CreatedAt: apiKey.CreatedAt,
UpdatedAt: apiKey.UpdatedAt, UpdatedAt: apiKey.UpdatedAt,
LastUsedAt: apiKey.LastUsedAt, LastUsedAt: apiKey.LastUsedAt,
@@ -210,7 +198,6 @@ func (h *Handler) ListKeys() http.HandlerFunc {
UserID: key.UserID, UserID: key.UserID,
PermissionMode: key.PermissionMode, PermissionMode: key.PermissionMode,
ExpiresAt: key.ExpiresAt, ExpiresAt: key.ExpiresAt,
Enabled: key.Enabled,
CreatedAt: key.CreatedAt, CreatedAt: key.CreatedAt,
UpdatedAt: key.UpdatedAt, UpdatedAt: key.UpdatedAt,
LastUsedAt: key.LastUsedAt, LastUsedAt: key.LastUsedAt,
@@ -260,7 +247,6 @@ func (h *Handler) GetKey() http.HandlerFunc {
UserID: key.UserID, UserID: key.UserID,
PermissionMode: key.PermissionMode, PermissionMode: key.PermissionMode,
ExpiresAt: key.ExpiresAt, ExpiresAt: key.ExpiresAt,
Enabled: key.Enabled,
CreatedAt: key.CreatedAt, CreatedAt: key.CreatedAt,
UpdatedAt: key.UpdatedAt, UpdatedAt: key.UpdatedAt,
LastUsedAt: key.LastUsedAt, LastUsedAt: key.LastUsedAt,
@@ -359,7 +345,6 @@ func (h *Handler) GetKeyPermissions() http.HandlerFunc {
response = append(response, KeyPermissionResponse{ response = append(response, KeyPermissionResponse{
InstanceID: perm.InstanceID, InstanceID: perm.InstanceID,
InstanceName: instanceNameMap[perm.InstanceID], InstanceName: instanceNameMap[perm.InstanceID],
CanInfer: perm.CanInfer,
}) })
} }

136
test_client.py Normal file
View File

@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""
Simple Python script to interact with local LLM server's OpenAI-compatible API
"""
import requests
import json
import sys
# Local LLM server configuration
BASE_URL = "http://localhost:8080"
API_KEY = None
MODEL_NAME = None
def get_models():
"""Fetch available models from /v1/models endpoint"""
headers = {}
if API_KEY:
headers["Authorization"] = f"Bearer {API_KEY}"
try:
response = requests.get(f"{BASE_URL}/v1/models", headers=headers, timeout=10)
response.raise_for_status()
return response.json()["data"]
except Exception as e:
print(f"Error fetching models: {e}")
return []
def send_message(message):
"""
Send a message to local LLM server API
Args:
message (str): The message to send
Returns:
str: The AI response or error message
"""
headers = {
"Content-Type": "application/json",
}
if API_KEY:
headers["Authorization"] = f"Bearer {API_KEY}"
data = {
"model": MODEL_NAME,
"messages": [
{
"role": "user",
"content": message
}
],
"temperature": 0.7,
"max_tokens": 1000,
"stream": False,
}
response = requests.post(f"{BASE_URL}/v1/chat/completions", headers=headers, json=data, timeout=60)
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"]
def interactive_mode():
"""Run in interactive mode for continuous conversation"""
global BASE_URL, API_KEY, MODEL_NAME
# Get base URL
url_input = input(f"Base URL [{BASE_URL}]: ").strip()
if url_input:
BASE_URL = url_input
# Get API key (optional)
key_input = input("API key (optional): ").strip()
if key_input:
API_KEY = key_input
# Fetch and select model
models = get_models()
if not models:
print("No models available. Exiting.")
return
print("\nAvailable models:")
for i, m in enumerate(models, 1):
print(f"{i}. {m['id']}")
while True:
try:
selection = int(input("\nSelect model: "))
if 1 <= selection <= len(models):
MODEL_NAME = models[selection - 1]["id"]
break
print(f"Please enter a number between 1 and {len(models)}")
except ValueError:
print("Please enter a valid number")
print(f"\nUsing model: {MODEL_NAME}")
print("Type 'quit' or 'exit' to stop")
print("-" * 40)
while True:
try:
user_input = input("\nYou: ").strip()
if user_input.lower() in ['quit', 'exit', 'q']:
print("Goodbye!")
break
if not user_input:
continue
print("AI: ", end="", flush=True)
response = send_message(user_input)
print(response)
except KeyboardInterrupt:
print("\nGoodbye!")
break
except EOFError:
print("\nGoodbye!")
break
def main():
"""Main function"""
if len(sys.argv) > 1:
# Single message mode
message = " ".join(sys.argv[1:])
response = send_message(message)
print(response)
else:
# Interactive mode
interactive_mode()
if __name__ == "__main__":
main()

197
webui/package-lock.json generated
View File

@@ -9,11 +9,11 @@
"version": "0.0.0", "version": "0.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -31,7 +31,6 @@
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/eslint__js": "^9.14.0",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.4", "@types/react": "^19.2.4",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -1250,21 +1249,21 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@radix-ui/primitive": { "node_modules/@radix-ui/primitive": {
"version": "1.1.2", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@radix-ui/react-checkbox": { "node_modules/@radix-ui/react-checkbox": {
"version": "1.3.2", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz",
"integrity": "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==", "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/primitive": "1.1.2", "@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2", "@radix-ui/react-context": "1.1.2",
"@radix-ui/react-presence": "1.1.4", "@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-previous": "1.1.1",
@@ -1311,6 +1310,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-compose-refs": { "node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
@@ -1342,20 +1359,20 @@
} }
}, },
"node_modules/@radix-ui/react-dialog": { "node_modules/@radix-ui/react-dialog": {
"version": "1.1.14", "version": "1.1.15",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz",
"integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==", "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/primitive": "1.1.2", "@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2", "@radix-ui/react-context": "1.1.2",
"@radix-ui/react-dismissable-layer": "1.1.10", "@radix-ui/react-dismissable-layer": "1.1.11",
"@radix-ui/react-focus-guards": "1.1.2", "@radix-ui/react-focus-guards": "1.1.3",
"@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-focus-scope": "1.1.7",
"@radix-ui/react-id": "1.1.1", "@radix-ui/react-id": "1.1.1",
"@radix-ui/react-portal": "1.1.9", "@radix-ui/react-portal": "1.1.9",
"@radix-ui/react-presence": "1.1.4", "@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3", "@radix-ui/react-slot": "1.2.3",
"@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-controllable-state": "1.2.2",
@@ -1377,6 +1394,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": { "node_modules/@radix-ui/react-direction": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
@@ -1393,12 +1428,12 @@
} }
}, },
"node_modules/@radix-ui/react-dismissable-layer": { "node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.10", "version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==", "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/primitive": "1.1.2", "@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-callback-ref": "1.1.1",
@@ -1420,9 +1455,9 @@
} }
}, },
"node_modules/@radix-ui/react-focus-guards": { "node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.2", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz",
"integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==", "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==",
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -1478,12 +1513,35 @@
} }
}, },
"node_modules/@radix-ui/react-label": { "node_modules/@radix-ui/react-label": {
"version": "2.1.7", "version": "2.1.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz",
"integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-primitive": "2.1.3" "@radix-ui/react-primitive": "2.1.4"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-label/node_modules/@radix-ui/react-primitive": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz",
"integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.2.4"
}, },
"peerDependencies": { "peerDependencies": {
"@types/react": "*", "@types/react": "*",
@@ -1525,9 +1583,9 @@
} }
}, },
"node_modules/@radix-ui/react-presence": { "node_modules/@radix-ui/react-presence": {
"version": "1.1.4", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-compose-refs": "1.1.2",
@@ -1571,6 +1629,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group": { "node_modules/@radix-ui/react-radio-group": {
"version": "1.3.8", "version": "1.3.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
@@ -1603,36 +1679,6 @@
} }
} }
}, },
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-presence": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz",
"integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-use-layout-effect": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": { "node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.11", "version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
@@ -1664,16 +1710,10 @@
} }
} }
}, },
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/primitive": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz",
"integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==",
"license": "MIT"
},
"node_modules/@radix-ui/react-slot": { "node_modules/@radix-ui/react-slot": {
"version": "1.2.3", "version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/react-compose-refs": "1.1.2" "@radix-ui/react-compose-refs": "1.1.2"
@@ -2557,17 +2597,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/eslint__js": {
"version": "9.14.0",
"resolved": "https://registry.npmjs.org/@types/eslint__js/-/eslint__js-9.14.0.tgz",
"integrity": "sha512-s0jepCjOJWB/GKcuba4jISaVpBudw3ClXJ3fUK4tugChUMQsp6kSwuA8Dcx6wFd/JsJqcY8n4rEpa5RTHs5ypA==",
"deprecated": "This is a stub types definition. @eslint/js provides its own type definitions, so you do not need this installed.",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint/js": "*"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",

View File

@@ -18,11 +18,11 @@
"lint:fix": "eslint . --ext .ts,.tsx --fix" "lint:fix": "eslint . --ext .ts,.tsx --fix"
}, },
"dependencies": { "dependencies": {
"@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/vite": "^4.1.11", "@tailwindcss/vite": "^4.1.11",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -40,7 +40,6 @@
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1", "@testing-library/user-event": "^14.6.1",
"@types/eslint__js": "^9.14.0",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.4", "@types/react": "^19.2.4",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",

View File

@@ -4,8 +4,7 @@ import userEvent from '@testing-library/user-event'
import InstanceList from '@/components/InstanceList' import InstanceList from '@/components/InstanceList'
import { InstancesProvider } from '@/contexts/InstancesContext' import { InstancesProvider } from '@/contexts/InstancesContext'
import { instancesApi } from '@/lib/api' import { instancesApi } from '@/lib/api'
import type { Instance } from '@/types/instance' import { BackendType, type Instance } from '@/types/instance'
import { BackendType } from '@/types/instance'
import { AuthProvider } from '@/contexts/AuthContext' import { AuthProvider } from '@/contexts/AuthContext'
// Mock the API // Mock the API

View File

@@ -8,9 +8,9 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { apiKeysApi } from "@/lib/api"; import { apiKeysApi } from "@/lib/api";
import { CreateKeyRequest, PermissionMode, InstancePermission } from "@/types/apiKey"; import { PermissionMode, type CreateKeyRequest } from "@/types/apiKey";
import { useInstances } from "@/contexts/InstancesContext"; import { useInstances } from "@/contexts/InstancesContext";
import { format, addDays } from "date-fns"; import { format } from "date-fns";
interface CreateApiKeyDialogProps { interface CreateApiKeyDialogProps {
open: boolean; open: boolean;
@@ -61,22 +61,19 @@ function CreateApiKeyDialog({ open, onOpenChange, onKeyCreated }: CreateApiKeyDi
} }
// Build request // Build request
const permissions: InstancePermission[] = []; const instanceIds: number[] = [];
if (permissionMode === PermissionMode.PerInstance) { if (permissionMode === PermissionMode.PerInstance) {
Object.entries(instancePermissions).forEach(([instanceId, canInfer]) => { Object.entries(instancePermissions).forEach(([instanceId, hasPermission]) => {
if (canInfer) { if (hasPermission) {
permissions.push({ instanceIds.push(parseInt(instanceId));
InstanceID: parseInt(instanceId),
CanInfer: true,
});
} }
}); });
} }
const request: CreateKeyRequest = { const request: CreateKeyRequest = {
Name: name.trim(), name: name.trim(),
PermissionMode: permissionMode, permission_mode: permissionMode,
InstancePermissions: permissions, instance_ids: instanceIds,
}; };
// Add expiration if provided // Add expiration if provided
@@ -87,7 +84,7 @@ function CreateApiKeyDialog({ open, onOpenChange, onKeyCreated }: CreateApiKeyDi
setError("Expiration date must be in the future"); setError("Expiration date must be in the future");
return; return;
} }
request.ExpiresAt = Math.floor(expirationDate.getTime() / 1000); request.expires_at = Math.floor(expirationDate.getTime() / 1000);
} }
setLoading(true); setLoading(true);
@@ -107,10 +104,10 @@ function CreateApiKeyDialog({ open, onOpenChange, onKeyCreated }: CreateApiKeyDi
}; };
const handleInstancePermissionChange = (instanceId: number, checked: boolean) => { const handleInstancePermissionChange = (instanceId: number, checked: boolean) => {
setInstancePermissions({ setInstancePermissions(prev => ({
...instancePermissions, ...prev,
[instanceId]: checked, [instanceId]: checked,
}); }));
}; };
return ( return (
@@ -119,7 +116,7 @@ function CreateApiKeyDialog({ open, onOpenChange, onKeyCreated }: CreateApiKeyDi
<DialogHeader> <DialogHeader>
<DialogTitle>Create API Key</DialogTitle> <DialogTitle>Create API Key</DialogTitle>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={(e) => void handleSubmit(e)} className="space-y-4">
{error && ( {error && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertDescription>{error}</AlertDescription> <AlertDescription>{error}</AlertDescription>
@@ -172,14 +169,19 @@ function CreateApiKeyDialog({ open, onOpenChange, onKeyCreated }: CreateApiKeyDi
<p className="text-sm text-muted-foreground">No instances available</p> <p className="text-sm text-muted-foreground">No instances available</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{instances.map((instance) => ( {instances.map((instance, index) => {
<div key={instance.id} className="flex items-center space-x-2"> const isChecked = !!instancePermissions[instance.id];
return (
<div
key={`${instance.name}-${index}`}
className="flex items-center space-x-2"
>
<Checkbox <Checkbox
id={`instance-${instance.id}`} id={`instance-${instance.id}`}
checked={instancePermissions[instance.id] || false} checked={isChecked}
onCheckedChange={(checked) => onCheckedChange={(checked) => {
handleInstancePermissionChange(instance.id, checked as boolean) handleInstancePermissionChange(instance.id, checked as boolean);
} }}
disabled={loading} disabled={loading}
/> />
<Label <Label
@@ -188,9 +190,9 @@ function CreateApiKeyDialog({ open, onOpenChange, onKeyCreated }: CreateApiKeyDi
> >
{instance.name} {instance.name}
</Label> </Label>
<span className="text-sm text-muted-foreground">Can Infer</span>
</div> </div>
))} );
})}
</div> </div>
)} )}
</div> </div>

View File

@@ -59,7 +59,7 @@ const KeyValueInput: React.FC<KeyValueInputProps> = ({
// Reset to single empty row if value is explicitly undefined/null // Reset to single empty row if value is explicitly undefined/null
setPairs([{ key: '', value: '' }]) setPairs([{ key: '', value: '' }])
} }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]) }, [value])
// Update parent component when pairs change // Update parent component when pairs change

View File

@@ -5,7 +5,7 @@ import NumberInput from '@/components/form/NumberInput'
interface AutoRestartConfigurationProps { interface AutoRestartConfigurationProps {
formData: CreateInstanceOptions formData: CreateInstanceOptions
onChange: (key: keyof CreateInstanceOptions, value: any) => void onChange: <K extends keyof CreateInstanceOptions>(key: K, value: CreateInstanceOptions[K]) => void
} }
const AutoRestartConfiguration: React.FC<AutoRestartConfigurationProps> = ({ const AutoRestartConfiguration: React.FC<AutoRestartConfigurationProps> = ({

View File

@@ -3,9 +3,11 @@ import type { CreateInstanceOptions } from '@/types/instance'
import { getBasicBackendFields, getAdvancedBackendFields } from '@/lib/zodFormUtils' import { getBasicBackendFields, getAdvancedBackendFields } from '@/lib/zodFormUtils'
import BackendFormField from '@/components/BackendFormField' import BackendFormField from '@/components/BackendFormField'
type BackendFieldValue = string | number | boolean | string[] | Record<string, string> | undefined
interface BackendConfigurationProps { interface BackendConfigurationProps {
formData: CreateInstanceOptions formData: CreateInstanceOptions
onBackendFieldChange: (key: string, value: any) => void onBackendFieldChange: (key: string, value: BackendFieldValue) => void
showAdvanced?: boolean showAdvanced?: boolean
} }
@@ -26,7 +28,7 @@ const BackendConfiguration: React.FC<BackendConfigurationProps> = ({
<BackendFormField <BackendFormField
key={fieldKey} key={fieldKey}
fieldKey={fieldKey} fieldKey={fieldKey}
value={(formData.backend_options as any)?.[fieldKey]} value={(formData.backend_options as Record<string, BackendFieldValue> | undefined)?.[fieldKey]}
onChange={onBackendFieldChange} onChange={onBackendFieldChange}
/> />
))} ))}
@@ -41,7 +43,7 @@ const BackendConfiguration: React.FC<BackendConfigurationProps> = ({
<BackendFormField <BackendFormField
key={fieldKey} key={fieldKey}
fieldKey={fieldKey} fieldKey={fieldKey}
value={(formData.backend_options as any)?.[fieldKey]} value={(formData.backend_options as Record<string, BackendFieldValue> | undefined)?.[fieldKey]}
onChange={onBackendFieldChange} onChange={onBackendFieldChange}
/> />
))} ))}
@@ -53,7 +55,7 @@ const BackendConfiguration: React.FC<BackendConfigurationProps> = ({
<BackendFormField <BackendFormField
key="extra_args" key="extra_args"
fieldKey="extra_args" fieldKey="extra_args"
value={(formData.backend_options as any)?.extra_args} value={(formData.backend_options as Record<string, BackendFieldValue> | undefined)?.extra_args}
onChange={onBackendFieldChange} onChange={onBackendFieldChange}
/> />
</div> </div>

View File

@@ -1,10 +1,10 @@
import { useEffect, useState } from "react"; import { useEffect, useState, Fragment } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Trash2, Copy, Check, X, ChevronDown, ChevronRight } from "lucide-react"; import { Trash2, Copy, Check, X, ChevronDown, ChevronRight } from "lucide-react";
import { apiKeysApi } from "@/lib/api"; import { apiKeysApi } from "@/lib/api";
import { ApiKey, KeyPermissionResponse, PermissionMode } from "@/types/apiKey"; import { type ApiKey, type KeyPermissionResponse, PermissionMode } from "@/types/apiKey";
import CreateApiKeyDialog from "@/components/apikeys/CreateApiKeyDialog"; import CreateApiKeyDialog from "@/components/apikeys/CreateApiKeyDialog";
import { format, formatDistanceToNow } from "date-fns"; import { format, formatDistanceToNow } from "date-fns";
@@ -20,7 +20,7 @@ function ApiKeysSection() {
const [loadingPermissions, setLoadingPermissions] = useState<Record<number, boolean>>({}); const [loadingPermissions, setLoadingPermissions] = useState<Record<number, boolean>>({});
useEffect(() => { useEffect(() => {
fetchKeys(); void fetchKeys();
}, []); }, []);
const fetchKeys = async () => { const fetchKeys = async () => {
@@ -52,7 +52,7 @@ function ApiKeysSection() {
const handleKeyCreated = (plainTextKey: string) => { const handleKeyCreated = (plainTextKey: string) => {
setNewKeyPlainText(plainTextKey); setNewKeyPlainText(plainTextKey);
fetchKeys(); void fetchKeys();
setCreateDialogOpen(false); setCreateDialogOpen(false);
}; };
@@ -75,7 +75,7 @@ function ApiKeysSection() {
try { try {
await apiKeysApi.delete(id); await apiKeysApi.delete(id);
fetchKeys(); void fetchKeys();
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : "Failed to delete API key"); alert(err instanceof Error ? err.message : "Failed to delete API key");
} }
@@ -87,7 +87,7 @@ function ApiKeysSection() {
} else { } else {
setExpandedRowId(key.id); setExpandedRowId(key.id);
if (key.permission_mode === PermissionMode.PerInstance) { if (key.permission_mode === PermissionMode.PerInstance) {
fetchPermissions(key.id); void fetchPermissions(key.id);
} }
} }
}; };
@@ -136,7 +136,7 @@ function ApiKeysSection() {
<code className="flex-1 p-3 bg-white dark:bg-gray-900 border border-green-300 dark:border-green-800 rounded font-mono text-sm break-all"> <code className="flex-1 p-3 bg-white dark:bg-gray-900 border border-green-300 dark:border-green-800 rounded font-mono text-sm break-all">
{newKeyPlainText} {newKeyPlainText}
</code> </code>
<Button onClick={handleCopyKey} variant="outline" size="sm"> <Button onClick={() => void handleCopyKey()} variant="outline" size="sm">
{copiedKey ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />} {copiedKey ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button> </Button>
</div> </div>
@@ -175,9 +175,8 @@ function ApiKeysSection() {
</thead> </thead>
<tbody> <tbody>
{keys.map((key) => ( {keys.map((key) => (
<> <Fragment key={key.id}>
<tr <tr
key={key.id}
className="border-t hover:bg-muted/50 cursor-pointer" className="border-t hover:bg-muted/50 cursor-pointer"
onClick={() => handleRowClick(key)} onClick={() => handleRowClick(key)}
> >
@@ -217,7 +216,7 @@ function ApiKeysSection() {
size="icon" size="icon"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDeleteKey(key.id, key.name); void handleDeleteKey(key.id, key.name);
}} }}
title="Delete key" title="Delete key"
> >
@@ -236,29 +235,15 @@ function ApiKeysSection() {
<p className="text-sm text-muted-foreground">Loading permissions...</p> <p className="text-sm text-muted-foreground">Loading permissions...</p>
) : permissions[key.id] ? ( ) : permissions[key.id] ? (
<div className="space-y-2"> <div className="space-y-2">
<p className="text-sm font-semibold">Instance Permissions:</p> <p className="text-sm font-semibold">Allowed Instances:</p>
<table className="w-full text-sm"> <ul className="text-sm space-y-1">
<thead>
<tr className="border-b">
<th className="text-left py-2">Instance Name</th>
<th className="text-left py-2">Can Infer</th>
</tr>
</thead>
<tbody>
{permissions[key.id].map((perm) => ( {permissions[key.id].map((perm) => (
<tr key={perm.instance_id} className="border-b"> <li key={perm.instance_id} className="flex items-center gap-2">
<td className="py-2">{perm.instance_name}</td> <Check className="h-3 w-3 text-green-600" />
<td className="py-2"> {perm.instance_name}
{perm.can_infer ? ( </li>
<Check className="h-4 w-4 text-green-600" />
) : (
<X className="h-4 w-4 text-red-600" />
)}
</td>
</tr>
))} ))}
</tbody> </ul>
</table>
</div> </div>
) : ( ) : (
<p className="text-sm text-muted-foreground">No permissions data</p> <p className="text-sm text-muted-foreground">No permissions data</p>
@@ -266,7 +251,7 @@ function ApiKeysSection() {
</td> </td>
</tr> </tr>
)} )}
</> </Fragment>
))} ))}
</tbody> </tbody>
</table> </table>

View File

@@ -1,4 +1,4 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import ApiKeysSection from "./ApiKeysSection"; import ApiKeysSection from "./ApiKeysSection";
interface SettingsDialogProps { interface SettingsDialogProps {
@@ -12,6 +12,9 @@ function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-y-auto"> <DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle>Settings</DialogTitle> <DialogTitle>Settings</DialogTitle>
<DialogDescription>
Manage your application settings and API keys.
</DialogDescription>
</DialogHeader> </DialogHeader>
<ApiKeysSection /> <ApiKeysSection />
</DialogContent> </DialogContent>

View File

@@ -4,13 +4,13 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const alertVariants = cva( const alertVariants = cva(
"relative w-full rounded-lg border p-4", "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{ {
variants: { variants: {
variant: { variant: {
default: "bg-background text-foreground", default: "bg-card text-card-foreground",
destructive: destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
}, },
}, },
defaultVariants: { defaultVariants: {
@@ -19,41 +19,48 @@ const alertVariants = cva(
} }
) )
const Alert = React.forwardRef< function Alert({
HTMLDivElement, className,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants> variant,
>(({ className, variant, ...props }, ref) => ( ...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div <div
ref={ref} data-slot="alert"
role="alert" role="alert"
className={cn(alertVariants({ variant }), className)} className={cn(alertVariants({ variant }), className)}
{...props} {...props}
/> />
)) )
Alert.displayName = "Alert" }
const AlertTitle = React.forwardRef< function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
HTMLParagraphElement, return (
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div <div
ref={ref} data-slot="alert-title"
className={cn("text-sm [&_p]:leading-relaxed", className)} className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props} {...props}
/> />
)) )
AlertDescription.displayName = "AlertDescription" }
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription } export { Alert, AlertTitle, AlertDescription }

View File

@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", "inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{ {
variants: { variants: {
variant: { variant: {

View File

@@ -9,14 +9,13 @@ const buttonVariants = cva(
{ {
variants: { variants: {
variant: { variant: {
default: default: "bg-primary text-primary-foreground hover:bg-primary/90",
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive: destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
@@ -26,6 +25,8 @@ const buttonVariants = cva(
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4", lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9", icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
}, },
}, },
defaultVariants: { defaultVariants: {

View File

@@ -20,7 +20,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
<div <div
data-slot="card-header" data-slot="card-header"
className={cn( className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className className
)} )}
{...props} {...props}

View File

@@ -19,7 +19,7 @@ function Checkbox({
> >
<CheckboxPrimitive.Indicator <CheckboxPrimitive.Indicator
data-slot="checkbox-indicator" data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none" className="grid place-content-center text-current transition-none"
> >
<CheckIcon className="size-3.5" /> <CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator> </CheckboxPrimitive.Indicator>

View File

@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className className

View File

@@ -1,42 +1,43 @@
import * as React from "react" import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react" import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
const RadioGroup = React.forwardRef< function RadioGroup({
React.ElementRef<typeof RadioGroupPrimitive.Root>, className,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root> ...props
>(({ className, ...props }, ref) => { }: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return ( return (
<RadioGroupPrimitive.Root <RadioGroupPrimitive.Root
className={cn("grid gap-2", className)} data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props} {...props}
ref={ref}
/> />
) )
}) }
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName
const RadioGroupItem = React.forwardRef< function RadioGroupItem({
React.ElementRef<typeof RadioGroupPrimitive.Item>, className,
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item> ...props
>(({ className, ...props }, ref) => { }: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return ( return (
<RadioGroupPrimitive.Item <RadioGroupPrimitive.Item
ref={ref} data-slot="radio-group-item"
className={cn( className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", "border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className className
)} )}
{...props} {...props}
> >
<RadioGroupPrimitive.Indicator className="flex items-center justify-center"> <RadioGroupPrimitive.Indicator
<Circle className="h-2.5 w-2.5 fill-current text-current" /> data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator> </RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item> </RadioGroupPrimitive.Item>
) )
}) }
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName
export { RadioGroup, RadioGroupItem } export { RadioGroup, RadioGroupItem }

View File

@@ -3,8 +3,7 @@ import { render, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { InstancesProvider, useInstances } from "@/contexts/InstancesContext"; import { InstancesProvider, useInstances } from "@/contexts/InstancesContext";
import { instancesApi } from "@/lib/api"; import { instancesApi } from "@/lib/api";
import type { Instance } from "@/types/instance"; import { BackendType, type Instance } from "@/types/instance";
import { BackendType } from "@/types/instance";
import { AuthProvider } from "../AuthContext"; import { AuthProvider } from "../AuthContext";
// Mock the API module // Mock the API module
@@ -71,37 +70,37 @@ function TestComponent() {
{/* Action buttons for testing with specific instances */} {/* Action buttons for testing with specific instances */}
<button <button
onClick={() => createInstance("new-instance", { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "test.gguf" } })} onClick={() => void createInstance("new-instance", { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "test.gguf" } })}
data-testid="create-instance" data-testid="create-instance"
> >
Create Instance Create Instance
</button> </button>
<button <button
onClick={() => updateInstance("instance1", { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "updated.gguf" } })} onClick={() => void updateInstance("instance1", { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "updated.gguf" } })}
data-testid="update-instance" data-testid="update-instance"
> >
Update Instance Update Instance
</button> </button>
<button <button
onClick={() => startInstance("instance2")} onClick={() => void startInstance("instance2")}
data-testid="start-instance" data-testid="start-instance"
> >
Start Instance2 Start Instance2
</button> </button>
<button <button
onClick={() => stopInstance("instance1")} onClick={() => void stopInstance("instance1")}
data-testid="stop-instance" data-testid="stop-instance"
> >
Stop Instance1 Stop Instance1
</button> </button>
<button <button
onClick={() => restartInstance("instance1")} onClick={() => void restartInstance("instance1")}
data-testid="restart-instance" data-testid="restart-instance"
> >
Restart Instance1 Restart Instance1
</button> </button>
<button <button
onClick={() => deleteInstance("instance2")} onClick={() => void deleteInstance("instance2")}
data-testid="delete-instance" data-testid="delete-instance"
> >
Delete Instance2 Delete Instance2

View File

@@ -156,12 +156,15 @@ class HealthService {
this.callbacks.set(instanceName, new Set()) this.callbacks.set(instanceName, new Set())
} }
this.callbacks.get(instanceName)!.add(callback) const callbacks = this.callbacks.get(instanceName)
if (callbacks) {
callbacks.add(callback)
// Start health checking if this is the first subscriber // Start health checking if this is the first subscriber
if (this.callbacks.get(instanceName)!.size === 1) { if (callbacks.size === 1) {
this.startHealthCheck(instanceName) this.startHealthCheck(instanceName)
} }
}
// Return unsubscribe function // Return unsubscribe function
return () => { return () => {
@@ -214,7 +217,8 @@ class HealthService {
} }
// Start new interval with appropriate timing // Start new interval with appropriate timing
const interval = setInterval(async () => { const interval = setInterval(() => {
void (async () => {
try { try {
const health = await this.performHealthCheck(instanceName) const health = await this.performHealthCheck(instanceName)
this.notifyCallbacks(instanceName, health) this.notifyCallbacks(instanceName, health)
@@ -230,6 +234,7 @@ class HealthService {
console.error(`Health check failed for ${instanceName}:`, error) console.error(`Health check failed for ${instanceName}:`, error)
// Continue polling even on error // Continue polling even on error
} }
})()
}, pollInterval) }, pollInterval)
this.intervals.set(instanceName, interval) this.intervals.set(instanceName, interval)

View File

@@ -6,7 +6,10 @@ import './index.css'
import { AuthProvider } from './contexts/AuthContext' import { AuthProvider } from './contexts/AuthContext'
import { ConfigProvider } from './contexts/ConfigContext' import { ConfigProvider } from './contexts/ConfigContext'
ReactDOM.createRoot(document.getElementById('root')!).render( const rootElement = document.getElementById('root')
if (!rootElement) throw new Error('Failed to find the root element')
ReactDOM.createRoot(rootElement).render(
<React.StrictMode> <React.StrictMode>
<AuthProvider> <AuthProvider>
<ConfigProvider> <ConfigProvider>

View File

@@ -1,10 +1,44 @@
import '@testing-library/jest-dom' import '@testing-library/jest-dom'
import { afterEach, vi } from 'vitest' import { afterEach, beforeEach } from 'vitest'
// Mock fetch globally since your app uses fetch // Create a working localStorage implementation for tests
global.fetch = vi.fn() // This ensures localStorage works in both CLI and VSCode test runner
class LocalStorageMock implements Storage {
private store: Map<string, string> = new Map()
// Clean up after each test get length(): number {
afterEach(() => { return this.store.size
vi.clearAllMocks() }
clear(): void {
this.store.clear()
}
getItem(key: string): string | null {
return this.store.get(key) ?? null
}
key(index: number): string | null {
return Array.from(this.store.keys())[index] ?? null
}
removeItem(key: string): void {
this.store.delete(key)
}
setItem(key: string, value: string): void {
this.store.set(key, value)
}
}
// Replace global localStorage
global.localStorage = new LocalStorageMock()
// Clean up before each test
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
localStorage.clear()
}) })

View File

@@ -9,22 +9,16 @@ export interface ApiKey {
user_id: string user_id: string
permission_mode: PermissionMode permission_mode: PermissionMode
expires_at: number | null expires_at: number | null
enabled: boolean
created_at: number created_at: number
updated_at: number updated_at: number
last_used_at: number | null last_used_at: number | null
} }
export interface CreateKeyRequest { export interface CreateKeyRequest {
Name: string name: string
PermissionMode: PermissionMode permission_mode: PermissionMode
ExpiresAt?: number expires_at?: number
InstancePermissions: InstancePermission[] instance_ids: number[]
}
export interface InstancePermission {
InstanceID: number
CanInfer: boolean
} }
export interface CreateKeyResponse extends ApiKey { export interface CreateKeyResponse extends ApiKey {
@@ -34,5 +28,4 @@ export interface CreateKeyResponse extends ApiKey {
export interface KeyPermissionResponse { export interface KeyPermissionResponse {
instance_id: number instance_id: number
instance_name: string instance_name: string
can_infer: boolean
} }

View File

@@ -4,7 +4,8 @@
"skipLibCheck": true, "skipLibCheck": true,
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowSyntheticDefaultImports": true "allowSyntheticDefaultImports": true,
"types": ["node"]
}, },
"include": ["vite.config.ts"] "include": ["vite.config.ts"]
} }