25 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
80d5d44a0b Add inference api key frontend integration 2025-12-04 23:26:32 +01:00
2d0acc60f2 Fix double dash in generated keys 2025-12-04 23:25:51 +01:00
a1b6f0c1b0 Remove JSON file archiving from migration process 2025-12-04 23:02:06 +01:00
991ce3c678 Remove unnecessary canviewlogs permission 2025-12-04 22:18:29 +01:00
d9c666a245 Update deprication warnings 2025-12-04 21:23:22 +01:00
52 changed files with 1378 additions and 348 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,25 +50,19 @@ 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++
} }
log.Printf("Successfully migrated %d/%d instances to SQLite", migrated, len(files)) log.Printf("Successfully migrated %d/%d instances to SQLite", migrated, len(files))
// Archive old JSON files
if migrated > 0 {
archiveDir := filepath.Join(instancesDir, "json_archive")
if err := os.MkdirAll(archiveDir, 0755); err == nil {
for _, file := range files {
newPath := filepath.Join(archiveDir, filepath.Base(file))
if err := os.Rename(file, newPath); err != nil {
log.Printf("Failed to archive %s: %v", file, err)
}
}
log.Printf("Archived old JSON files to %s", archiveDir)
}
}
return nil return nil
} }

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,8 +28,6 @@ type APIKey struct {
type KeyPermission struct { type KeyPermission struct {
KeyID int KeyID int
InstanceID int InstanceID int
CanInfer bool
CanViewLogs 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

@@ -93,7 +93,6 @@ type InstancesConfig struct {
// Port range for instances (e.g., 8000,9000) // Port range for instances (e.g., 8000,9000)
PortRange [2]int `yaml:"port_range" json:"port_range"` PortRange [2]int `yaml:"port_range" json:"port_range"`
// Instance config directory override (relative to data_dir if not absolute) // Instance config directory override (relative to data_dir if not absolute)
InstancesDir string `yaml:"configs_dir" json:"configs_dir"` InstancesDir string `yaml:"configs_dir" json:"configs_dir"`
@@ -248,9 +247,18 @@ func LoadConfig(configPath string) (AppConfig, error) {
// 3. Override with environment variables // 3. Override with environment variables
loadEnvVars(&cfg) loadEnvVars(&cfg)
// Log warning if deprecated inference keys are present
if len(cfg.Auth.InferenceKeys) > 0 {
log.Println("⚠️ Config-based inference keys are no longer supported and will be ignored.")
log.Println(" Please create inference keys in web UI or via management API.")
}
// Set default directories if not specified // Set default directories if not specified
if cfg.Instances.InstancesDir == "" { if cfg.Instances.InstancesDir == "" {
cfg.Instances.InstancesDir = filepath.Join(cfg.DataDir, "instances") cfg.Instances.InstancesDir = filepath.Join(cfg.DataDir, "instances")
} else {
// Log deprecation warning if using custom instances dir
log.Println("⚠️ Instances directory is deprecated and will be removed in future versions. Instances are persisted in the database.")
} }
if cfg.Instances.LogsDir == "" { if cfg.Instances.LogsDir == "" {
cfg.Instances.LogsDir = filepath.Join(cfg.DataDir, "logs") cfg.Instances.LogsDir = filepath.Join(cfg.DataDir, "logs")

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, can_view_logs) INSERT INTO key_permissions (key_id, instance_id)
VALUES (?, ?, ?, ?) VALUES (?, ?)
` `
_, err := tx.ExecContext(ctx, query, perm.KeyID, perm.InstanceID, perm.CanInfer, perm.CanViewLogs) _, 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,8 +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,
can_view_logs 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, can_view_logs 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, &perm.CanViewLogs) 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,19 +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"`
CanViewLogs bool `json:"can_view_logs"`
}
// 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.
@@ -33,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"`
@@ -47,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"`
@@ -57,8 +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"`
CanViewLogs bool `json:"can_view_logs"`
} }
// CreateKey godoc // CreateKey godoc
@@ -93,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() {
@@ -114,16 +103,16 @@ 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
} }
} }
} }
// Generate plain-text key // Generate plain-text key
plainTextKey, err := auth.GenerateKey("llamactl-") plainTextKey, err := auth.GenerateKey("llamactl")
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "key_generation_failed", "Failed to generate API key") writeError(w, http.StatusInternalServerError, "key_generation_failed", "Failed to generate API key")
return return
@@ -144,19 +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,
CanViewLogs: perm.CanViewLogs,
}) })
} }
@@ -174,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,
@@ -213,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,
@@ -263,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,
@@ -362,8 +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,
CanViewLogs: perm.CanViewLogs,
}) })
} }

View File

@@ -36,18 +36,12 @@ func NewAPIAuthMiddleware(authCfg config.AuthConfig, authStore database.AuthStor
managementKeys[key] = true managementKeys[key] = true
} }
// If len(authCfg.InferenceKeys) > 0, log warning
if len(authCfg.InferenceKeys) > 0 {
log.Println("⚠️ Config-based inference keys are no longer supported and will be ignored.")
log.Println(" Please create inference keys in web UI or via management API.")
}
// Handle legacy auto-generation for management keys if none provided and auth is required // Handle legacy auto-generation for management keys if none provided and auth is required
var generated bool = false var generated bool = false
const banner = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" const banner = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if authCfg.RequireManagementAuth && len(authCfg.ManagementKeys) == 0 { if authCfg.RequireManagementAuth && len(authCfg.ManagementKeys) == 0 {
key, err := auth.GenerateKey("llamactl-mgmt-") key, err := auth.GenerateKey("llamactl-mgmt")
if err != nil { if err != nil {
log.Printf("Warning: Failed to generate management key: %v", err) log.Printf("Warning: Failed to generate management key: %v", err)
// Fallback to PID-based key for safety // Fallback to PID-based key for safety

View File

@@ -78,7 +78,7 @@ func SetupRouter(handler *Handler) *chi.Mux {
r.Get("/", handler.ListNodes()) // List all nodes r.Get("/", handler.ListNodes()) // List all nodes
r.Route("/{name}", func(r chi.Router) { r.Route("/{name}", func(r chi.Router) {
r.Get("/", handler.GetNode()) r.Get("/", handler.GetNode()) // Get node details
}) })
}) })

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()

307
webui/package-lock.json generated
View File

@@ -9,13 +9,15 @@
"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-slot": "^1.2.3", "@radix-ui/react-radio-group": "^1.3.8",
"@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",
"date-fns": "^4.1.0",
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@@ -29,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",
@@ -160,7 +161,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -510,7 +510,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -554,7 +553,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -1251,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",
@@ -1286,6 +1284,50 @@
} }
} }
}, },
"node_modules/@radix-ui/react-collection": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz",
"integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-slot": "1.2.3"
},
"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-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",
@@ -1317,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",
@@ -1352,13 +1394,46 @@
} }
} }
}, },
"node_modules/@radix-ui/react-dismissable-layer": { "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.1.10", "version": "1.2.3",
"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-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/primitive": "1.1.2", "@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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz",
"integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==",
"license": "MIT",
"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-dismissable-layer": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz",
"integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==",
"license": "MIT",
"dependencies": {
"@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",
@@ -1380,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": "*",
@@ -1438,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": "*",
@@ -1485,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",
@@ -1531,7 +1629,7 @@
} }
} }
}, },
"node_modules/@radix-ui/react-slot": { "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
"version": "1.2.3", "version": "1.2.3",
"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.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
@@ -1549,6 +1647,87 @@
} }
} }
}, },
"node_modules/@radix-ui/react-radio-group": {
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz",
"integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-presence": "1.1.5",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-roving-focus": "1.1.11",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "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": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz",
"integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-collection": "1.1.7",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-direction": "1.1.1",
"@radix-ui/react-id": "1.1.1",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-callback-ref": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.2.2"
},
"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-slot": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz",
"integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==",
"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-use-callback-ref": { "node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz",
@@ -2352,7 +2531,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
@@ -2417,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",
@@ -2447,7 +2616,6 @@
"integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@@ -2458,7 +2626,6 @@
"integrity": "sha512-tBFxBp9Nfyy5rsmefN+WXc1JeW/j2BpBHFdLZbEVfs9wn3E3NRFxwV0pJg8M1qQAexFpvz73hJXFofV0ZAu92A==", "integrity": "sha512-tBFxBp9Nfyy5rsmefN+WXc1JeW/j2BpBHFdLZbEVfs9wn3E3NRFxwV0pJg8M1qQAexFpvz73hJXFofV0ZAu92A==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"csstype": "^3.0.2" "csstype": "^3.0.2"
} }
@@ -2469,7 +2636,6 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
@@ -2520,7 +2686,6 @@
"integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==", "integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.48.0", "@typescript-eslint/scope-manager": "8.48.0",
"@typescript-eslint/types": "8.48.0", "@typescript-eslint/types": "8.48.0",
@@ -2869,7 +3034,6 @@
"integrity": "sha512-F9jI5rSstNknPlTlPN2gcc4gpbaagowuRzw/OJzl368dvPun668Q182S8Q8P9PITgGCl5LAKXpzuue106eM4wA==", "integrity": "sha512-F9jI5rSstNknPlTlPN2gcc4gpbaagowuRzw/OJzl368dvPun668Q182S8Q8P9PITgGCl5LAKXpzuue106eM4wA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vitest/utils": "4.0.8", "@vitest/utils": "4.0.8",
"fflate": "^0.8.2", "fflate": "^0.8.2",
@@ -2906,7 +3070,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -2957,6 +3120,7 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -3228,7 +3392,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"caniuse-lite": "^1.0.30001726", "caniuse-lite": "^1.0.30001726",
"electron-to-chromium": "^1.5.173", "electron-to-chromium": "^1.5.173",
@@ -3540,6 +3703,16 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -3651,7 +3824,8 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/dunder-proto": { "node_modules/dunder-proto": {
"version": "1.0.1", "version": "1.0.1",
@@ -3955,7 +4129,6 @@
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==", "integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1", "@eslint-community/regexpp": "^4.12.1",
@@ -5181,7 +5354,6 @@
"integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==", "integrity": "sha512-454TI39PeRDW1LgpyLPyURtB4Zx1tklSr6+OFOipsxGUH1WMTvk6C65JQdrj455+DP2uJ1+veBEHTGFKWVLFoA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@acemir/cssom": "^0.9.23", "@acemir/cssom": "^0.9.23",
"@asamuzakjp/dom-selector": "^6.7.4", "@asamuzakjp/dom-selector": "^6.7.4",
@@ -5592,6 +5764,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"lz-string": "bin/bin.js" "lz-string": "bin/bin.js"
} }
@@ -5977,7 +6150,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -6039,6 +6211,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"ansi-regex": "^5.0.1", "ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0", "ansi-styles": "^5.0.0",
@@ -6054,6 +6227,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=10" "node": ">=10"
}, },
@@ -6095,7 +6269,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -6105,7 +6278,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
@@ -6118,7 +6290,8 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.18.0", "version": "0.18.0",
@@ -7072,7 +7245,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -7220,7 +7392,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -7296,7 +7467,6 @@
"integrity": "sha512-urzu3NCEV0Qa0Y2PwvBtRgmNtxhj5t5ULw7cuKhIHh3OrkKTLlut0lnBOv9qe5OvbkMH2g38G7KPDCTpIytBVg==", "integrity": "sha512-urzu3NCEV0Qa0Y2PwvBtRgmNtxhj5t5ULw7cuKhIHh3OrkKTLlut0lnBOv9qe5OvbkMH2g38G7KPDCTpIytBVg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vitest/expect": "4.0.8", "@vitest/expect": "4.0.8",
"@vitest/mocker": "4.0.8", "@vitest/mocker": "4.0.8",
@@ -7625,7 +7795,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz",
"integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }

View File

@@ -18,13 +18,15 @@
"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-slot": "^1.2.3", "@radix-ui/react-radio-group": "^1.3.8",
"@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",
"date-fns": "^4.1.0",
"lucide-react": "^0.555.0", "lucide-react": "^0.555.0",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@@ -38,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,6 +4,7 @@ import InstanceList from "@/components/InstanceList";
import InstanceDialog from "@/components/InstanceDialog"; import InstanceDialog from "@/components/InstanceDialog";
import LoginDialog from "@/components/LoginDialog"; import LoginDialog from "@/components/LoginDialog";
import SystemInfoDialog from "./components/SystemInfoDialog"; import SystemInfoDialog from "./components/SystemInfoDialog";
import SettingsDialog from "./components/settings/SettingsDialog";
import { type CreateInstanceOptions, type Instance } from "@/types/instance"; import { type CreateInstanceOptions, type Instance } from "@/types/instance";
import { useInstances } from "@/contexts/InstancesContext"; import { useInstances } from "@/contexts/InstancesContext";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
@@ -14,6 +15,7 @@ function App() {
const { isAuthenticated, isLoading: authLoading } = useAuth(); const { isAuthenticated, isLoading: authLoading } = useAuth();
const [isInstanceModalOpen, setIsInstanceModalOpen] = useState(false); const [isInstanceModalOpen, setIsInstanceModalOpen] = useState(false);
const [isSystemInfoModalOpen, setIsSystemInfoModalOpen] = useState(false); const [isSystemInfoModalOpen, setIsSystemInfoModalOpen] = useState(false);
const [isSettingsModalOpen, setIsSettingsModalOpen] = useState(false);
const [editingInstance, setEditingInstance] = useState<Instance | undefined>( const [editingInstance, setEditingInstance] = useState<Instance | undefined>(
undefined undefined
); );
@@ -41,6 +43,10 @@ function App() {
setIsSystemInfoModalOpen(true); setIsSystemInfoModalOpen(true);
}; };
const handleShowSettings = () => {
setIsSettingsModalOpen(true);
};
// Show loading spinner while checking auth // Show loading spinner while checking auth
if (authLoading) { if (authLoading) {
return ( return (
@@ -70,7 +76,11 @@ function App() {
return ( return (
<ThemeProvider> <ThemeProvider>
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<Header onCreateInstance={handleCreateInstance} onShowSystemInfo={handleShowSystemInfo} /> <Header
onCreateInstance={handleCreateInstance}
onShowSystemInfo={handleShowSystemInfo}
onShowSettings={handleShowSettings}
/>
<main className="container mx-auto max-w-4xl px-4 py-8"> <main className="container mx-auto max-w-4xl px-4 py-8">
<InstanceList editInstance={handleEditInstance} /> <InstanceList editInstance={handleEditInstance} />
</main> </main>
@@ -87,6 +97,11 @@ function App() {
onOpenChange={setIsSystemInfoModalOpen} onOpenChange={setIsSystemInfoModalOpen}
/> />
<SettingsDialog
open={isSettingsModalOpen}
onOpenChange={setIsSettingsModalOpen}
/>
<Toaster /> <Toaster />
</div> </div>
</ThemeProvider> </ThemeProvider>

View File

@@ -75,8 +75,8 @@ function renderApp() {
describe('App Component - Critical Business Logic Only', () => { describe('App Component - Critical Business Logic Only', () => {
const mockInstances: Instance[] = [ const mockInstances: Instance[] = [
{ name: 'test-instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model1.gguf' } } }, { id: 1, name: 'test-instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model1.gguf' } } },
{ name: 'test-instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model2.gguf' } } } { id: 2, name: 'test-instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model2.gguf' } } }
] ]
beforeEach(() => { beforeEach(() => {
@@ -109,6 +109,7 @@ describe('App Component - Critical Business Logic Only', () => {
it('creates new instance with correct API call and updates UI', async () => { it('creates new instance with correct API call and updates UI', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const newInstance: Instance = { const newInstance: Instance = {
id: 3,
name: 'new-test-instance', name: 'new-test-instance',
status: 'stopped', status: 'stopped',
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'new-model.gguf' } } options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'new-model.gguf' } }
@@ -151,6 +152,7 @@ describe('App Component - Critical Business Logic Only', () => {
it('updates existing instance with correct API call', async () => { it('updates existing instance with correct API call', async () => {
const user = userEvent.setup() const user = userEvent.setup()
const updatedInstance: Instance = { const updatedInstance: Instance = {
id: 1,
name: 'test-instance-1', name: 'test-instance-1',
status: 'stopped', status: 'stopped',
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'updated-model.gguf' } } options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'updated-model.gguf' } }

View File

@@ -1,14 +1,15 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { HelpCircle, LogOut, Moon, Sun } from "lucide-react"; import { HelpCircle, LogOut, Moon, Settings, Sun } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext"; import { useAuth } from "@/contexts/AuthContext";
import { useTheme } from "@/contexts/ThemeContext"; import { useTheme } from "@/contexts/ThemeContext";
interface HeaderProps { interface HeaderProps {
onCreateInstance: () => void; onCreateInstance: () => void;
onShowSystemInfo: () => void; onShowSystemInfo: () => void;
onShowSettings: () => void;
} }
function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) { function Header({ onCreateInstance, onShowSystemInfo, onShowSettings }: HeaderProps) {
const { logout } = useAuth(); const { logout } = useAuth();
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
@@ -41,6 +42,16 @@ function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) {
{theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />} {theme === 'light' ? <Moon className="h-4 w-4" /> : <Sun className="h-4 w-4" />}
</Button> </Button>
<Button
variant="outline"
size="icon"
onClick={onShowSettings}
data-testid="settings-button"
title="Settings"
>
<Settings className="h-4 w-4" />
</Button>
<Button <Button
variant="outline" variant="outline"
size="icon" size="icon"

View File

@@ -21,12 +21,14 @@ describe('InstanceCard - Instance Actions and State', () => {
const mockEditInstance = vi.fn() const mockEditInstance = vi.fn()
const stoppedInstance: Instance = { const stoppedInstance: Instance = {
id: 1,
name: 'test-instance', name: 'test-instance',
status: 'stopped', status: 'stopped',
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'test-model.gguf' } } options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'test-model.gguf' } }
} }
const runningInstance: Instance = { const runningInstance: Instance = {
id: 2,
name: 'running-instance', name: 'running-instance',
status: 'running', status: 'running',
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'running-model.gguf' } } options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'running-model.gguf' } }
@@ -342,6 +344,7 @@ afterEach(() => {
describe('Error Edge Cases', () => { describe('Error Edge Cases', () => {
it('handles instance with minimal data', () => { it('handles instance with minimal data', () => {
const minimalInstance: Instance = { const minimalInstance: Instance = {
id: 3,
name: 'minimal', name: 'minimal',
status: 'stopped', status: 'stopped',
options: {} options: {}
@@ -364,6 +367,7 @@ afterEach(() => {
it('handles instance with undefined options', () => { it('handles instance with undefined options', () => {
const instanceWithoutOptions: Instance = { const instanceWithoutOptions: Instance = {
id: 4,
name: 'no-options', name: 'no-options',
status: 'running', status: 'running',
options: undefined options: undefined

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
@@ -59,9 +58,9 @@ describe('InstanceList - State Management and UI Logic', () => {
const mockEditInstance = vi.fn() const mockEditInstance = vi.fn()
const mockInstances: Instance[] = [ const mockInstances: Instance[] = [
{ name: 'instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model1.gguf' } } }, { id: 1, name: 'instance-1', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model1.gguf' } } },
{ name: 'instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model2.gguf' } } }, { id: 1, name: 'instance-2', status: 'running', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model2.gguf' } } },
{ name: 'instance-3', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model3.gguf' } } } { id: 1, name: 'instance-3', status: 'stopped', options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: 'model3.gguf' } } }
] ]
const DUMMY_API_KEY = 'test-api-key-123' const DUMMY_API_KEY = 'test-api-key-123'

View File

@@ -153,6 +153,7 @@ afterEach(() => {
describe('Edit Mode', () => { describe('Edit Mode', () => {
const mockInstance: Instance = { const mockInstance: Instance = {
id: 1,
name: 'existing-instance', name: 'existing-instance',
status: 'stopped', status: 'stopped',
options: { options: {

View File

@@ -0,0 +1,238 @@
import { useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Checkbox } from "@/components/ui/checkbox";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Loader2 } from "lucide-react";
import { apiKeysApi } from "@/lib/api";
import { PermissionMode, type CreateKeyRequest } from "@/types/apiKey";
import { useInstances } from "@/contexts/InstancesContext";
import { format } from "date-fns";
interface CreateApiKeyDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onKeyCreated: (plainTextKey: string) => void;
}
function CreateApiKeyDialog({ open, onOpenChange, onKeyCreated }: CreateApiKeyDialogProps) {
const { instances } = useInstances();
const [name, setName] = useState("");
const [permissionMode, setPermissionMode] = useState<PermissionMode>(PermissionMode.AllowAll);
const [expiresAt, setExpiresAt] = useState<string>("");
const [instancePermissions, setInstancePermissions] = useState<Record<number, boolean>>({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const formatDisplayDate = (dateString: string) => {
if (!dateString) return null;
try {
const date = new Date(dateString);
return format(date, "d MMMM yyyy");
} catch {
return null;
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
// Validation
if (!name.trim()) {
setError("Name is required");
return;
}
if (name.length > 100) {
setError("Name must be 100 characters or less");
return;
}
if (permissionMode === PermissionMode.PerInstance) {
const hasAnyPermission = Object.values(instancePermissions).some(v => v);
if (!hasAnyPermission) {
setError("At least one instance permission is required for per-instance mode");
return;
}
}
// Build request
const instanceIds: number[] = [];
if (permissionMode === PermissionMode.PerInstance) {
Object.entries(instancePermissions).forEach(([instanceId, hasPermission]) => {
if (hasPermission) {
instanceIds.push(parseInt(instanceId));
}
});
}
const request: CreateKeyRequest = {
name: name.trim(),
permission_mode: permissionMode,
instance_ids: instanceIds,
};
// Add expiration if provided
if (expiresAt) {
const expirationDate = new Date(expiresAt);
const now = new Date();
if (expirationDate <= now) {
setError("Expiration date must be in the future");
return;
}
request.expires_at = Math.floor(expirationDate.getTime() / 1000);
}
setLoading(true);
try {
const response = await apiKeysApi.create(request);
onKeyCreated(response.key);
// Reset form
setName("");
setPermissionMode(PermissionMode.AllowAll);
setExpiresAt("");
setInstancePermissions({});
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create API key");
} finally {
setLoading(false);
}
};
const handleInstancePermissionChange = (instanceId: number, checked: boolean) => {
setInstancePermissions(prev => ({
...prev,
[instanceId]: checked,
}));
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Create API Key</DialogTitle>
</DialogHeader>
<form onSubmit={(e) => void handleSubmit(e)} className="space-y-4">
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My API Key"
maxLength={100}
disabled={loading}
/>
</div>
<div className="space-y-3">
<Label>Permission Mode</Label>
<RadioGroup
value={permissionMode}
onValueChange={(value) => setPermissionMode(value as PermissionMode)}
disabled={loading}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value={PermissionMode.AllowAll} id="allow-all" />
<Label htmlFor="allow-all" className="font-normal cursor-pointer">
Full Access
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value={PermissionMode.PerInstance} id="per-instance" />
<Label htmlFor="per-instance" className="font-normal cursor-pointer">
Per-Instance Access
</Label>
</div>
</RadioGroup>
{permissionMode === PermissionMode.AllowAll && (
<p className="text-sm text-muted-foreground">
This key will have access to all instances
</p>
)}
{permissionMode === PermissionMode.PerInstance && (
<div className="space-y-2 border rounded-lg p-4">
<Label className="text-sm font-semibold">Instance Permissions</Label>
{instances.length === 0 ? (
<p className="text-sm text-muted-foreground">No instances available</p>
) : (
<div className="space-y-2">
{instances.map((instance, index) => {
const isChecked = !!instancePermissions[instance.id];
return (
<div
key={`${instance.name}-${index}`}
className="flex items-center space-x-2"
>
<Checkbox
id={`instance-${instance.id}`}
checked={isChecked}
onCheckedChange={(checked) => {
handleInstancePermissionChange(instance.id, checked as boolean);
}}
disabled={loading}
/>
<Label
htmlFor={`instance-${instance.id}`}
className="font-normal cursor-pointer flex-1"
>
{instance.name}
</Label>
</div>
);
})}
</div>
)}
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="expires-at">Expiration Date (Optional)</Label>
<Input
id="expires-at"
type="date"
value={expiresAt}
onChange={(e) => setExpiresAt(e.target.value)}
disabled={loading}
/>
{expiresAt && formatDisplayDate(expiresAt) && (
<p className="text-sm text-muted-foreground">
Expires on {formatDisplayDate(expiresAt)}
</p>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
export default CreateApiKeyDialog;

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

@@ -0,0 +1,270 @@
import { useEffect, useState, Fragment } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Trash2, Copy, Check, X, ChevronDown, ChevronRight } from "lucide-react";
import { apiKeysApi } from "@/lib/api";
import { type ApiKey, type KeyPermissionResponse, PermissionMode } from "@/types/apiKey";
import CreateApiKeyDialog from "@/components/apikeys/CreateApiKeyDialog";
import { format, formatDistanceToNow } from "date-fns";
function ApiKeysSection() {
const [keys, setKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [expandedRowId, setExpandedRowId] = useState<number | null>(null);
const [newKeyPlainText, setNewKeyPlainText] = useState<string | null>(null);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [copiedKey, setCopiedKey] = useState(false);
const [permissions, setPermissions] = useState<Record<number, KeyPermissionResponse[]>>({});
const [loadingPermissions, setLoadingPermissions] = useState<Record<number, boolean>>({});
useEffect(() => {
void fetchKeys();
}, []);
const fetchKeys = async () => {
setLoading(true);
setError(null);
try {
const data = await apiKeysApi.list();
setKeys(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load API keys");
} finally {
setLoading(false);
}
};
const fetchPermissions = async (keyId: number) => {
if (permissions[keyId]) return;
setLoadingPermissions({ ...loadingPermissions, [keyId]: true });
try {
const data = await apiKeysApi.getPermissions(keyId);
setPermissions({ ...permissions, [keyId]: data });
} catch (err) {
console.error("Failed to load permissions:", err);
} finally {
setLoadingPermissions({ ...loadingPermissions, [keyId]: false });
}
};
const handleKeyCreated = (plainTextKey: string) => {
setNewKeyPlainText(plainTextKey);
void fetchKeys();
setCreateDialogOpen(false);
};
const dismissSuccessBanner = () => {
setNewKeyPlainText(null);
};
const handleCopyKey = async () => {
if (newKeyPlainText) {
await navigator.clipboard.writeText(newKeyPlainText);
setCopiedKey(true);
setTimeout(() => setCopiedKey(false), 2000);
}
};
const handleDeleteKey = async (id: number, name: string) => {
if (!confirm(`Are you sure you want to delete the key '${name}'?\n\nThis action cannot be undone.`)) {
return;
}
try {
await apiKeysApi.delete(id);
void fetchKeys();
} catch (err) {
alert(err instanceof Error ? err.message : "Failed to delete API key");
}
};
const handleRowClick = (key: ApiKey) => {
if (expandedRowId === key.id) {
setExpandedRowId(null);
} else {
setExpandedRowId(key.id);
if (key.permission_mode === PermissionMode.PerInstance) {
void fetchPermissions(key.id);
}
}
};
const formatDate = (timestamp: number) => {
return format(new Date(timestamp * 1000), "MMM d, yyyy");
};
const formatLastUsed = (timestamp: number | null) => {
if (!timestamp) return "Never";
return formatDistanceToNow(new Date(timestamp * 1000), { addSuffix: true });
};
const isExpired = (expiresAt: number | null) => {
if (!expiresAt) return false;
return expiresAt * 1000 < Date.now();
};
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">API Keys</h3>
<Button onClick={() => setCreateDialogOpen(true)}>Create API Key</Button>
</div>
{newKeyPlainText && (
<Alert className="bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-900">
<AlertDescription className="space-y-3">
<div className="flex items-start justify-between">
<div>
<p className="font-semibold text-green-900 dark:text-green-100">API key created successfully</p>
<p className="text-sm text-green-800 dark:text-green-200 mt-1">
Make sure to copy this key now. You won't be able to see it again!
</p>
</div>
<Button
variant="ghost"
size="icon"
onClick={dismissSuccessBanner}
className="h-6 w-6"
>
<X className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<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}
</code>
<Button onClick={() => void handleCopyKey()} variant="outline" size="sm">
{copiedKey ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
</AlertDescription>
</Alert>
)}
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{loading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-16 bg-muted animate-pulse rounded" />
))}
</div>
) : keys.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
No API keys yet. Create your first key to get started.
</div>
) : (
<div className="border rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-muted">
<tr>
<th className="text-left p-3 font-semibold text-sm">Name</th>
<th className="text-left p-3 font-semibold text-sm">Permissions</th>
<th className="text-left p-3 font-semibold text-sm">Created</th>
<th className="text-left p-3 font-semibold text-sm">Expires</th>
<th className="text-left p-3 font-semibold text-sm">Last Accessed</th>
<th className="text-left p-3 font-semibold text-sm">Actions</th>
</tr>
</thead>
<tbody>
{keys.map((key) => (
<Fragment key={key.id}>
<tr
className="border-t hover:bg-muted/50 cursor-pointer"
onClick={() => handleRowClick(key)}
>
<td className="p-3">
<div className="flex items-center gap-2">
{expandedRowId === key.id ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
{key.name}
</div>
</td>
<td className="p-3">
{key.permission_mode === PermissionMode.AllowAll ? (
<Badge variant="default">Full Access</Badge>
) : (
<Badge variant="secondary">Limited Access</Badge>
)}
</td>
<td className="p-3 text-sm text-muted-foreground">{formatDate(key.created_at)}</td>
<td className="p-3">
{key.expires_at ? (
isExpired(key.expires_at) ? (
<Badge variant="destructive">Expired</Badge>
) : (
<span className="text-sm text-muted-foreground">{formatDate(key.expires_at)}</span>
)
) : (
<span className="text-sm text-muted-foreground">Never</span>
)}
</td>
<td className="p-3 text-sm text-muted-foreground">{formatLastUsed(key.last_used_at)}</td>
<td className="p-3">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
void handleDeleteKey(key.id, key.name);
}}
title="Delete key"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</td>
</tr>
{expandedRowId === key.id && (
<tr key={`${key.id}-expanded`} className="border-t bg-muted/30">
<td colSpan={6} className="p-4">
{key.permission_mode === PermissionMode.AllowAll ? (
<p className="text-sm text-muted-foreground">
This key has full access to all instances
</p>
) : loadingPermissions[key.id] ? (
<p className="text-sm text-muted-foreground">Loading permissions...</p>
) : permissions[key.id] ? (
<div className="space-y-2">
<p className="text-sm font-semibold">Allowed Instances:</p>
<ul className="text-sm space-y-1">
{permissions[key.id].map((perm) => (
<li key={perm.instance_id} className="flex items-center gap-2">
<Check className="h-3 w-3 text-green-600" />
{perm.instance_name}
</li>
))}
</ul>
</div>
) : (
<p className="text-sm text-muted-foreground">No permissions data</p>
)}
</td>
</tr>
)}
</Fragment>
))}
</tbody>
</table>
</div>
)}
<CreateApiKeyDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
onKeyCreated={handleKeyCreated}
/>
</div>
);
}
export default ApiKeysSection;

View File

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

View File

@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"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: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
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 }

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

@@ -0,0 +1,43 @@
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"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
)}
{...props}
>
<RadioGroupPrimitive.Indicator
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.Item>
)
}
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
@@ -123,8 +122,8 @@ function renderWithProvider(children: ReactNode) {
describe("InstancesContext", () => { describe("InstancesContext", () => {
const mockInstances: Instance[] = [ const mockInstances: Instance[] = [
{ name: "instance1", status: "running", options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "model1.gguf" } } }, { id: 1, name: "instance1", status: "running", options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "model1.gguf" } } },
{ name: "instance2", status: "stopped", options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "model2.gguf" } } }, { id: 2, name: "instance2", status: "stopped", options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "model2.gguf" } } },
]; ];
beforeEach(() => { beforeEach(() => {
@@ -181,6 +180,7 @@ describe("InstancesContext", () => {
describe("Create Instance", () => { describe("Create Instance", () => {
it("creates instance and adds it to state", async () => { it("creates instance and adds it to state", async () => {
const newInstance: Instance = { const newInstance: Instance = {
id: 3,
name: "new-instance", name: "new-instance",
status: "stopped", status: "stopped",
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "test.gguf" } }, options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "test.gguf" } },
@@ -238,6 +238,7 @@ describe("InstancesContext", () => {
describe("Update Instance", () => { describe("Update Instance", () => {
it("updates instance and maintains it in state", async () => { it("updates instance and maintains it in state", async () => {
const updatedInstance: Instance = { const updatedInstance: Instance = {
id: 1,
name: "instance1", name: "instance1",
status: "running", status: "running",
options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "updated.gguf" } }, options: { backend_type: BackendType.LLAMA_CPP, backend_options: { model: "updated.gguf" } },
@@ -408,6 +409,7 @@ describe("InstancesContext", () => {
it("maintains consistent state during multiple operations", async () => { it("maintains consistent state during multiple operations", async () => {
// Test that operations don't interfere with each other // Test that operations don't interfere with each other
const newInstance: Instance = { const newInstance: Instance = {
id: 3,
name: "new-instance", name: "new-instance",
status: "stopped", status: "stopped",
options: {}, options: {},

View File

@@ -1,5 +1,6 @@
import type { CreateInstanceOptions, Instance } from "@/types/instance"; import type { CreateInstanceOptions, Instance } from "@/types/instance";
import type { AppConfig } from "@/types/config"; import type { AppConfig } from "@/types/config";
import type { ApiKey, CreateKeyRequest, CreateKeyResponse, KeyPermissionResponse } from "@/types/apiKey";
import { handleApiError } from "./errorUtils"; import { handleApiError } from "./errorUtils";
// Adding baseURI as a prefix to support being served behind a subpath // Adding baseURI as a prefix to support being served behind a subpath
@@ -178,3 +179,29 @@ export const instancesApi = {
// GET /instances/{name}/proxy/health // GET /instances/{name}/proxy/health
getHealth: (name: string) => apiCall<Record<string, unknown>>(`/instances/${encodeURIComponent(name)}/proxy/health`), getHealth: (name: string) => apiCall<Record<string, unknown>>(`/instances/${encodeURIComponent(name)}/proxy/health`),
}; };
// API Keys API functions
export const apiKeysApi = {
// GET /auth/keys
list: () => apiCall<ApiKey[]>("/auth/keys"),
// GET /auth/keys/{id}
get: (id: number) => apiCall<ApiKey>(`/auth/keys/${id}`),
// POST /auth/keys
create: (request: CreateKeyRequest) =>
apiCall<CreateKeyResponse>("/auth/keys", {
method: "POST",
body: JSON.stringify(request),
}),
// DELETE /auth/keys/{id}
delete: (id: number) =>
apiCall<void>(`/auth/keys/${id}`, {
method: "DELETE",
}),
// GET /auth/keys/{id}/permissions
getPermissions: (id: number) =>
apiCall<KeyPermissionResponse[]>(`/auth/keys/${id}/permissions`),
};

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()
}) })

31
webui/src/types/apiKey.ts Normal file
View File

@@ -0,0 +1,31 @@
export enum PermissionMode {
AllowAll = "allow_all",
PerInstance = "per_instance"
}
export interface ApiKey {
id: number
name: string
user_id: string
permission_mode: PermissionMode
expires_at: number | null
created_at: number
updated_at: number
last_used_at: number | null
}
export interface CreateKeyRequest {
name: string
permission_mode: PermissionMode
expires_at?: number
instance_ids: number[]
}
export interface CreateKeyResponse extends ApiKey {
key: string
}
export interface KeyPermissionResponse {
instance_id: number
instance_name: string
}

View File

@@ -24,6 +24,7 @@ export interface HealthStatus {
} }
export interface Instance { export interface Instance {
id: number;
name: string; name: string;
status: InstanceStatus; status: InstanceStatus;
options?: CreateInstanceOptions; options?: CreateInstanceOptions;

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"]
} }