mirror of
https://github.com/lordmathis/llamactl.git
synced 2025-11-06 17:14:28 +00:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae1bf8561f | |||
| ad117ef6c6 | |||
| 169432260a | |||
| f94a150b07 | |||
| c038cabaf6 | |||
| 89f90697ef | |||
| 8e8056f071 | |||
| 4d06bc487a | |||
| bedec089ef | |||
| b3540d5b3e | |||
| 72ba008d1e | |||
| 0aa5def9ec | |||
| 79364eca95 | |||
| e1889a65ac |
59
.github/workflows/release.yaml
vendored
59
.github/workflows/release.yaml
vendored
@@ -108,63 +108,9 @@ jobs:
|
||||
*.zip
|
||||
retention-days: 1
|
||||
|
||||
generate-changelog:
|
||||
name: Generate Changelog
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changelog: ${{ steps.changelog.outputs.changelog }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate changelog
|
||||
id: changelog
|
||||
run: |
|
||||
# Get the previous tag
|
||||
PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -v "^${{ github.ref_name }}$" | head -n1)
|
||||
|
||||
if [ -z "$PREVIOUS_TAG" ]; then
|
||||
echo "No previous tag found, generating changelog from first commit"
|
||||
PREVIOUS_TAG=$(git rev-list --max-parents=0 HEAD)
|
||||
fi
|
||||
|
||||
echo "Generating changelog from $PREVIOUS_TAG to ${{ github.ref_name }}"
|
||||
|
||||
# Generate changelog
|
||||
CHANGELOG=$(cat << 'EOL'
|
||||
## What's Changed
|
||||
|
||||
EOL
|
||||
)
|
||||
|
||||
# Get commits between tags
|
||||
COMMITS=$(git log --pretty=format:"* %s (%h)" "$PREVIOUS_TAG..${{ github.ref_name }}" --no-merges)
|
||||
|
||||
if [ -z "$COMMITS" ]; then
|
||||
CHANGELOG="${CHANGELOG}* No changes since previous release"
|
||||
else
|
||||
CHANGELOG="${CHANGELOG}${COMMITS}"
|
||||
fi
|
||||
|
||||
# Add full changelog link if we have a previous tag and it's not a commit hash
|
||||
if [[ "$PREVIOUS_TAG" =~ ^v[0-9] ]]; then
|
||||
CHANGELOG="${CHANGELOG}
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREVIOUS_TAG}...${{ github.ref_name }}"
|
||||
fi
|
||||
|
||||
# Save changelog to output (handle multiline)
|
||||
{
|
||||
echo 'changelog<<EOF'
|
||||
echo "$CHANGELOG"
|
||||
echo EOF
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
needs: [build, generate-changelog]
|
||||
needs: [build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
@@ -184,8 +130,9 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
name: Release ${{ github.ref_name }}
|
||||
body: ${{ needs.generate-changelog.outputs.changelog }}
|
||||
tag_name: ${{ github.ref_name }}
|
||||
files: release-assets/*
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref_name, '-') }}
|
||||
env:
|
||||
|
||||
44
README.md
44
README.md
@@ -54,12 +54,14 @@ go build -o llamactl ./cmd/server
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
llamactl can be configured via configuration files or environment variables. Configuration is loaded in the following order of precedence:
|
||||
|
||||
1. Hardcoded defaults
|
||||
2. Configuration file
|
||||
3. Environment variables
|
||||
|
||||
|
||||
### Configuration Files
|
||||
|
||||
Configuration files are searched in the following locations:
|
||||
@@ -76,19 +78,35 @@ Configuration files are searched in the following locations:
|
||||
|
||||
You can specify the path to config file with `LLAMACTL_CONFIG_PATH` environment variable
|
||||
|
||||
## API Key Authentication
|
||||
|
||||
llamactl now supports API Key authentication for both management and inference (OpenAI-compatible) endpoints. The are separate keys for management and inference APIs. Management keys grant full access; inference keys grant access to OpenAI-compatible endpoints
|
||||
|
||||
**How to Use:**
|
||||
- Pass your API key in requests using one of:
|
||||
- `Authorization: Bearer <key>` header
|
||||
- `X-API-Key: <key>` header
|
||||
- `api_key=<key>` query parameter
|
||||
|
||||
**Auto-generated keys**: If no keys are set and authentication is required, a key will be generated and printed to the terminal at startup. For production, set your own keys in config or environment variables.
|
||||
|
||||
### Configuration Options
|
||||
|
||||
#### Server Configuration
|
||||
|
||||
```yaml
|
||||
server:
|
||||
host: "" # Server host to bind to (default: "")
|
||||
port: 8080 # Server port to bind to (default: 8080)
|
||||
host: "0.0.0.0" # Server host to bind to (default: "0.0.0.0")
|
||||
port: 8080 # Server port to bind to (default: 8080)
|
||||
allowed_origins: ["*"] # CORS allowed origins (default: ["*"])
|
||||
enable_swagger: false # Enable Swagger UI (default: false)
|
||||
```
|
||||
|
||||
**Environment Variables:**
|
||||
- `LLAMACTL_HOST` - Server host
|
||||
- `LLAMACTL_PORT` - Server port
|
||||
- `LLAMACTL_ALLOWED_ORIGINS` - Comma-separated CORS origins
|
||||
- `LLAMACTL_ENABLE_SWAGGER` - Enable Swagger UI (true/false)
|
||||
|
||||
#### Instance Configuration
|
||||
|
||||
@@ -112,6 +130,22 @@ instances:
|
||||
- `LLAMACTL_DEFAULT_MAX_RESTARTS` - Default maximum restarts
|
||||
- `LLAMACTL_DEFAULT_RESTART_DELAY` - Default restart delay in seconds
|
||||
|
||||
#### Auth Configuration
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
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)
|
||||
management_keys: [] # List of valid management API keys
|
||||
```
|
||||
|
||||
**Environment Variables:**
|
||||
- `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_MANAGEMENT_KEYS` - Comma-separated management API keys
|
||||
|
||||
### Example Configuration
|
||||
|
||||
```yaml
|
||||
@@ -127,6 +161,12 @@ instances:
|
||||
default_auto_restart: true
|
||||
default_max_restarts: 5
|
||||
default_restart_delay: 10
|
||||
|
||||
auth:
|
||||
require_inference_auth: true
|
||||
inference_keys: ["sk-inference-abc123"]
|
||||
require_management_auth: true
|
||||
management_keys: ["sk-management-xyz456"]
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
82
docs/docs.go
82
docs/docs.go
@@ -21,6 +21,11 @@ const docTemplate = `{
|
||||
"paths": {
|
||||
"/instances": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns a list of all instances managed by the server",
|
||||
"tags": [
|
||||
"instances"
|
||||
@@ -47,6 +52,11 @@ const docTemplate = `{
|
||||
},
|
||||
"/instances/{name}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns the details of a specific instance by name",
|
||||
"tags": [
|
||||
"instances"
|
||||
@@ -83,6 +93,11 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Updates the configuration of a specific instance by name",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
@@ -131,6 +146,11 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Creates a new instance with the provided configuration options",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
@@ -179,6 +199,11 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Stops and removes a specific instance by name",
|
||||
"tags": [
|
||||
"instances"
|
||||
@@ -214,6 +239,11 @@ const docTemplate = `{
|
||||
},
|
||||
"/instances/{name}/logs": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns the logs from a specific instance by name with optional line limit",
|
||||
"tags": [
|
||||
"instances"
|
||||
@@ -258,6 +288,11 @@ const docTemplate = `{
|
||||
},
|
||||
"/instances/{name}/proxy": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Forwards HTTP requests to the llama-server instance running on a specific port",
|
||||
"tags": [
|
||||
"instances"
|
||||
@@ -297,6 +332,11 @@ const docTemplate = `{
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Forwards HTTP requests to the llama-server instance running on a specific port",
|
||||
"tags": [
|
||||
"instances"
|
||||
@@ -338,6 +378,11 @@ const docTemplate = `{
|
||||
},
|
||||
"/instances/{name}/restart": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Restarts a specific instance by name",
|
||||
"tags": [
|
||||
"instances"
|
||||
@@ -376,6 +421,11 @@ const docTemplate = `{
|
||||
},
|
||||
"/instances/{name}/start": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Starts a specific instance by name",
|
||||
"tags": [
|
||||
"instances"
|
||||
@@ -414,6 +464,11 @@ const docTemplate = `{
|
||||
},
|
||||
"/instances/{name}/stop": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Stops a specific instance by name",
|
||||
"tags": [
|
||||
"instances"
|
||||
@@ -452,6 +507,11 @@ const docTemplate = `{
|
||||
},
|
||||
"/server/devices": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns a list of available devices for the llama server",
|
||||
"tags": [
|
||||
"server"
|
||||
@@ -475,6 +535,11 @@ const docTemplate = `{
|
||||
},
|
||||
"/server/help": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns the help text for the llama server command",
|
||||
"tags": [
|
||||
"server"
|
||||
@@ -498,6 +563,11 @@ const docTemplate = `{
|
||||
},
|
||||
"/server/version": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns the version of the llama server command",
|
||||
"tags": [
|
||||
"server"
|
||||
@@ -521,7 +591,12 @@ const docTemplate = `{
|
||||
},
|
||||
"/v1/": {
|
||||
"post": {
|
||||
"description": "Handles all POST requests to /v1/*, routing to the appropriate instance based on the request body",
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Handles all POST requests to /v1/*, routing to the appropriate instance based on the request body. Requires API key authentication via the ` + "`" + `Authorization` + "`" + ` header.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -550,6 +625,11 @@ const docTemplate = `{
|
||||
},
|
||||
"/v1/models": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns a list of instances in a format compatible with OpenAI API",
|
||||
"tags": [
|
||||
"openai"
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
"paths": {
|
||||
"/instances": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns a list of all instances managed by the server",
|
||||
"tags": [
|
||||
"instances"
|
||||
@@ -40,6 +45,11 @@
|
||||
},
|
||||
"/instances/{name}": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns the details of a specific instance by name",
|
||||
"tags": [
|
||||
"instances"
|
||||
@@ -76,6 +86,11 @@
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Updates the configuration of a specific instance by name",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
@@ -124,6 +139,11 @@
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Creates a new instance with the provided configuration options",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
@@ -172,6 +192,11 @@
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Stops and removes a specific instance by name",
|
||||
"tags": [
|
||||
"instances"
|
||||
@@ -207,6 +232,11 @@
|
||||
},
|
||||
"/instances/{name}/logs": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns the logs from a specific instance by name with optional line limit",
|
||||
"tags": [
|
||||
"instances"
|
||||
@@ -251,6 +281,11 @@
|
||||
},
|
||||
"/instances/{name}/proxy": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Forwards HTTP requests to the llama-server instance running on a specific port",
|
||||
"tags": [
|
||||
"instances"
|
||||
@@ -290,6 +325,11 @@
|
||||
}
|
||||
},
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Forwards HTTP requests to the llama-server instance running on a specific port",
|
||||
"tags": [
|
||||
"instances"
|
||||
@@ -331,6 +371,11 @@
|
||||
},
|
||||
"/instances/{name}/restart": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Restarts a specific instance by name",
|
||||
"tags": [
|
||||
"instances"
|
||||
@@ -369,6 +414,11 @@
|
||||
},
|
||||
"/instances/{name}/start": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Starts a specific instance by name",
|
||||
"tags": [
|
||||
"instances"
|
||||
@@ -407,6 +457,11 @@
|
||||
},
|
||||
"/instances/{name}/stop": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Stops a specific instance by name",
|
||||
"tags": [
|
||||
"instances"
|
||||
@@ -445,6 +500,11 @@
|
||||
},
|
||||
"/server/devices": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns a list of available devices for the llama server",
|
||||
"tags": [
|
||||
"server"
|
||||
@@ -468,6 +528,11 @@
|
||||
},
|
||||
"/server/help": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns the help text for the llama server command",
|
||||
"tags": [
|
||||
"server"
|
||||
@@ -491,6 +556,11 @@
|
||||
},
|
||||
"/server/version": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns the version of the llama server command",
|
||||
"tags": [
|
||||
"server"
|
||||
@@ -514,7 +584,12 @@
|
||||
},
|
||||
"/v1/": {
|
||||
"post": {
|
||||
"description": "Handles all POST requests to /v1/*, routing to the appropriate instance based on the request body",
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Handles all POST requests to /v1/*, routing to the appropriate instance based on the request body. Requires API key authentication via the `Authorization` header.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
@@ -543,6 +618,11 @@
|
||||
},
|
||||
"/v1/models": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Returns a list of instances in a format compatible with OpenAI API",
|
||||
"tags": [
|
||||
"openai"
|
||||
|
||||
@@ -399,6 +399,8 @@ paths:
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
type: string
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: List all instances
|
||||
tags:
|
||||
- instances
|
||||
@@ -422,6 +424,8 @@ paths:
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
type: string
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Delete an instance
|
||||
tags:
|
||||
- instances
|
||||
@@ -446,6 +450,8 @@ paths:
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
type: string
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Get details of a specific instance
|
||||
tags:
|
||||
- instances
|
||||
@@ -478,6 +484,8 @@ paths:
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
type: string
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Create and start a new instance
|
||||
tags:
|
||||
- instances
|
||||
@@ -510,6 +518,8 @@ paths:
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
type: string
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Update an instance's configuration
|
||||
tags:
|
||||
- instances
|
||||
@@ -540,6 +550,8 @@ paths:
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
type: string
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Get logs from a specific instance
|
||||
tags:
|
||||
- instances
|
||||
@@ -568,6 +580,8 @@ paths:
|
||||
description: Instance is not running
|
||||
schema:
|
||||
type: string
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Proxy requests to a specific instance
|
||||
tags:
|
||||
- instances
|
||||
@@ -595,6 +609,8 @@ paths:
|
||||
description: Instance is not running
|
||||
schema:
|
||||
type: string
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Proxy requests to a specific instance
|
||||
tags:
|
||||
- instances
|
||||
@@ -620,6 +636,8 @@ paths:
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
type: string
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Restart a running instance
|
||||
tags:
|
||||
- instances
|
||||
@@ -645,6 +663,8 @@ paths:
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
type: string
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Start a stopped instance
|
||||
tags:
|
||||
- instances
|
||||
@@ -670,6 +690,8 @@ paths:
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
type: string
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Stop a running instance
|
||||
tags:
|
||||
- instances
|
||||
@@ -685,6 +707,8 @@ paths:
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
type: string
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: List available devices for llama server
|
||||
tags:
|
||||
- server
|
||||
@@ -700,6 +724,8 @@ paths:
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
type: string
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Get help for llama server
|
||||
tags:
|
||||
- server
|
||||
@@ -715,6 +741,8 @@ paths:
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
type: string
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Get version of llama server
|
||||
tags:
|
||||
- server
|
||||
@@ -723,7 +751,8 @@ paths:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Handles all POST requests to /v1/*, routing to the appropriate
|
||||
instance based on the request body
|
||||
instance based on the request body. Requires API key authentication via the
|
||||
`Authorization` header.
|
||||
responses:
|
||||
"200":
|
||||
description: OpenAI response
|
||||
@@ -735,6 +764,8 @@ paths:
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
type: string
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: OpenAI-compatible proxy endpoint
|
||||
tags:
|
||||
- openai
|
||||
@@ -751,6 +782,8 @@ paths:
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
type: string
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: List instances in OpenAI-compatible format
|
||||
tags:
|
||||
- openai
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
type Config struct {
|
||||
Server ServerConfig `yaml:"server"`
|
||||
Instances InstancesConfig `yaml:"instances"`
|
||||
Auth AuthConfig `yaml:"auth"`
|
||||
}
|
||||
|
||||
// ServerConfig contains HTTP server configuration
|
||||
@@ -26,6 +27,9 @@ type ServerConfig struct {
|
||||
|
||||
// Allowed origins for CORS (e.g., "http://localhost:3000")
|
||||
AllowedOrigins []string `yaml:"allowed_origins"`
|
||||
|
||||
// Enable Swagger UI for API documentation
|
||||
EnableSwagger bool `yaml:"enable_swagger"`
|
||||
}
|
||||
|
||||
// InstancesConfig contains instance management configuration
|
||||
@@ -52,6 +56,22 @@ type InstancesConfig struct {
|
||||
DefaultRestartDelay int `yaml:"default_restart_delay"`
|
||||
}
|
||||
|
||||
// AuthConfig contains authentication settings
|
||||
type AuthConfig struct {
|
||||
|
||||
// Require authentication for OpenAI compatible inference endpoints
|
||||
RequireInferenceAuth bool `yaml:"require_inference_auth"`
|
||||
|
||||
// List of keys for OpenAI compatible inference endpoints
|
||||
InferenceKeys []string `yaml:"inference_keys"`
|
||||
|
||||
// Require authentication for management endpoints
|
||||
RequireManagementAuth bool `yaml:"require_management_auth"`
|
||||
|
||||
// List of keys for management endpoints
|
||||
ManagementKeys []string `yaml:"management_keys"`
|
||||
}
|
||||
|
||||
// LoadConfig loads configuration with the following precedence:
|
||||
// 1. Hardcoded defaults
|
||||
// 2. Config file
|
||||
@@ -63,6 +83,7 @@ func LoadConfig(configPath string) (Config, error) {
|
||||
Host: "0.0.0.0",
|
||||
Port: 8080,
|
||||
AllowedOrigins: []string{"*"}, // Default to allow all origins
|
||||
EnableSwagger: false,
|
||||
},
|
||||
Instances: InstancesConfig{
|
||||
PortRange: [2]int{8000, 9000},
|
||||
@@ -73,6 +94,12 @@ func LoadConfig(configPath string) (Config, error) {
|
||||
DefaultMaxRestarts: 3,
|
||||
DefaultRestartDelay: 5,
|
||||
},
|
||||
Auth: AuthConfig{
|
||||
RequireInferenceAuth: true,
|
||||
InferenceKeys: []string{},
|
||||
RequireManagementAuth: true,
|
||||
ManagementKeys: []string{},
|
||||
},
|
||||
}
|
||||
|
||||
// 2. Load from config file
|
||||
@@ -121,6 +148,14 @@ func loadEnvVars(cfg *Config) {
|
||||
cfg.Server.Port = p
|
||||
}
|
||||
}
|
||||
if allowedOrigins := os.Getenv("LLAMACTL_ALLOWED_ORIGINS"); allowedOrigins != "" {
|
||||
cfg.Server.AllowedOrigins = strings.Split(allowedOrigins, ",")
|
||||
}
|
||||
if enableSwagger := os.Getenv("LLAMACTL_ENABLE_SWAGGER"); enableSwagger != "" {
|
||||
if b, err := strconv.ParseBool(enableSwagger); err == nil {
|
||||
cfg.Server.EnableSwagger = b
|
||||
}
|
||||
}
|
||||
|
||||
// Instance config
|
||||
if portRange := os.Getenv("LLAMACTL_INSTANCE_PORT_RANGE"); portRange != "" {
|
||||
@@ -154,6 +189,23 @@ func loadEnvVars(cfg *Config) {
|
||||
cfg.Instances.DefaultRestartDelay = seconds
|
||||
}
|
||||
}
|
||||
// Auth config
|
||||
if requireInferenceAuth := os.Getenv("LLAMACTL_REQUIRE_INFERENCE_AUTH"); requireInferenceAuth != "" {
|
||||
if b, err := strconv.ParseBool(requireInferenceAuth); err == nil {
|
||||
cfg.Auth.RequireInferenceAuth = b
|
||||
}
|
||||
}
|
||||
if inferenceKeys := os.Getenv("LLAMACTL_INFERENCE_KEYS"); inferenceKeys != "" {
|
||||
cfg.Auth.InferenceKeys = strings.Split(inferenceKeys, ",")
|
||||
}
|
||||
if requireManagementAuth := os.Getenv("LLAMACTL_REQUIRE_MANAGEMENT_AUTH"); requireManagementAuth != "" {
|
||||
if b, err := strconv.ParseBool(requireManagementAuth); err == nil {
|
||||
cfg.Auth.RequireManagementAuth = b
|
||||
}
|
||||
}
|
||||
if managementKeys := os.Getenv("LLAMACTL_MANAGEMENT_KEYS"); managementKeys != "" {
|
||||
cfg.Auth.ManagementKeys = strings.Split(managementKeys, ",")
|
||||
}
|
||||
}
|
||||
|
||||
// ParsePortRange parses port range from string formats like "8000-9000" or "8000,9000"
|
||||
|
||||
@@ -29,6 +29,7 @@ func NewHandler(im InstanceManager, config Config) *Handler {
|
||||
// @Summary Get help for llama server
|
||||
// @Description Returns the help text for the llama server command
|
||||
// @Tags server
|
||||
// @Security ApiKeyAuth
|
||||
// @Produces text/plain
|
||||
// @Success 200 {string} string "Help text"
|
||||
// @Failure 500 {string} string "Internal Server Error"
|
||||
@@ -50,6 +51,7 @@ func (h *Handler) HelpHandler() http.HandlerFunc {
|
||||
// @Summary Get version of llama server
|
||||
// @Description Returns the version of the llama server command
|
||||
// @Tags server
|
||||
// @Security ApiKeyAuth
|
||||
// @Produces text/plain
|
||||
// @Success 200 {string} string "Version information"
|
||||
// @Failure 500 {string} string "Internal Server Error"
|
||||
@@ -71,6 +73,7 @@ func (h *Handler) VersionHandler() http.HandlerFunc {
|
||||
// @Summary List available devices for llama server
|
||||
// @Description Returns a list of available devices for the llama server
|
||||
// @Tags server
|
||||
// @Security ApiKeyAuth
|
||||
// @Produces text/plain
|
||||
// @Success 200 {string} string "List of devices"
|
||||
// @Failure 500 {string} string "Internal Server Error"
|
||||
@@ -92,6 +95,7 @@ func (h *Handler) ListDevicesHandler() http.HandlerFunc {
|
||||
// @Summary List all instances
|
||||
// @Description Returns a list of all instances managed by the server
|
||||
// @Tags instances
|
||||
// @Security ApiKeyAuth
|
||||
// @Produces json
|
||||
// @Success 200 {array} Instance "List of instances"
|
||||
// @Failure 500 {string} string "Internal Server Error"
|
||||
@@ -116,6 +120,7 @@ func (h *Handler) ListInstances() http.HandlerFunc {
|
||||
// @Summary Create and start a new instance
|
||||
// @Description Creates a new instance with the provided configuration options
|
||||
// @Tags instances
|
||||
// @Security ApiKeyAuth
|
||||
// @Accept json
|
||||
// @Produces json
|
||||
// @Param name path string true "Instance Name"
|
||||
@@ -157,6 +162,7 @@ func (h *Handler) CreateInstance() http.HandlerFunc {
|
||||
// @Summary Get details of a specific instance
|
||||
// @Description Returns the details of a specific instance by name
|
||||
// @Tags instances
|
||||
// @Security ApiKeyAuth
|
||||
// @Produces json
|
||||
// @Param name path string true "Instance Name"
|
||||
// @Success 200 {object} Instance "Instance details"
|
||||
@@ -189,6 +195,7 @@ func (h *Handler) GetInstance() http.HandlerFunc {
|
||||
// @Summary Update an instance's configuration
|
||||
// @Description Updates the configuration of a specific instance by name
|
||||
// @Tags instances
|
||||
// @Security ApiKeyAuth
|
||||
// @Accept json
|
||||
// @Produces json
|
||||
// @Param name path string true "Instance Name"
|
||||
@@ -229,6 +236,7 @@ func (h *Handler) UpdateInstance() http.HandlerFunc {
|
||||
// @Summary Start a stopped instance
|
||||
// @Description Starts a specific instance by name
|
||||
// @Tags instances
|
||||
// @Security ApiKeyAuth
|
||||
// @Produces json
|
||||
// @Param name path string true "Instance Name"
|
||||
// @Success 200 {object} Instance "Started instance details"
|
||||
@@ -261,6 +269,7 @@ func (h *Handler) StartInstance() http.HandlerFunc {
|
||||
// @Summary Stop a running instance
|
||||
// @Description Stops a specific instance by name
|
||||
// @Tags instances
|
||||
// @Security ApiKeyAuth
|
||||
// @Produces json
|
||||
// @Param name path string true "Instance Name"
|
||||
// @Success 200 {object} Instance "Stopped instance details"
|
||||
@@ -293,6 +302,7 @@ func (h *Handler) StopInstance() http.HandlerFunc {
|
||||
// @Summary Restart a running instance
|
||||
// @Description Restarts a specific instance by name
|
||||
// @Tags instances
|
||||
// @Security ApiKeyAuth
|
||||
// @Produces json
|
||||
// @Param name path string true "Instance Name"
|
||||
// @Success 200 {object} Instance "Restarted instance details"
|
||||
@@ -325,6 +335,7 @@ func (h *Handler) RestartInstance() http.HandlerFunc {
|
||||
// @Summary Delete an instance
|
||||
// @Description Stops and removes a specific instance by name
|
||||
// @Tags instances
|
||||
// @Security ApiKeyAuth
|
||||
// @Param name path string true "Instance Name"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 400 {string} string "Invalid name format"
|
||||
@@ -351,6 +362,7 @@ func (h *Handler) DeleteInstance() http.HandlerFunc {
|
||||
// @Summary Get logs from a specific instance
|
||||
// @Description Returns the logs from a specific instance by name with optional line limit
|
||||
// @Tags instances
|
||||
// @Security ApiKeyAuth
|
||||
// @Param name path string true "Instance Name"
|
||||
// @Param lines query string false "Number of lines to retrieve (default: all lines)"
|
||||
// @Produces text/plain
|
||||
@@ -398,6 +410,7 @@ func (h *Handler) GetInstanceLogs() http.HandlerFunc {
|
||||
// @Summary Proxy requests to a specific instance
|
||||
// @Description Forwards HTTP requests to the llama-server instance running on a specific port
|
||||
// @Tags instances
|
||||
// @Security ApiKeyAuth
|
||||
// @Param name path string true "Instance Name"
|
||||
// @Success 200 "Request successfully proxied to instance"
|
||||
// @Failure 400 {string} string "Invalid name format"
|
||||
@@ -462,6 +475,7 @@ func (h *Handler) ProxyToInstance() http.HandlerFunc {
|
||||
// @Summary List instances in OpenAI-compatible format
|
||||
// @Description Returns a list of instances in a format compatible with OpenAI API
|
||||
// @Tags openai
|
||||
// @Security ApiKeyAuth
|
||||
// @Produces json
|
||||
// @Success 200 {object} OpenAIListInstancesResponse "List of OpenAI-compatible instances"
|
||||
// @Failure 500 {string} string "Internal Server Error"
|
||||
@@ -499,8 +513,9 @@ func (h *Handler) OpenAIListInstances() http.HandlerFunc {
|
||||
|
||||
// OpenAIProxy godoc
|
||||
// @Summary OpenAI-compatible proxy endpoint
|
||||
// @Description Handles all POST requests to /v1/*, routing to the appropriate instance based on the request body
|
||||
// @Description Handles all POST requests to /v1/*, routing to the appropriate instance based on the request body. Requires API key authentication via the `Authorization` header.
|
||||
// @Tags openai
|
||||
// @Security ApiKeyAuth
|
||||
// @Accept json
|
||||
// @Produces json
|
||||
// @Success 200 "OpenAI response"
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
@@ -191,19 +192,35 @@ func (i *Instance) GetProxy() (*httputil.ReverseProxy, error) {
|
||||
i.mu.Lock()
|
||||
defer i.mu.Unlock()
|
||||
|
||||
if i.proxy == nil {
|
||||
if i.options == nil {
|
||||
return nil, fmt.Errorf("instance %s has no options set", i.Name)
|
||||
}
|
||||
|
||||
targetURL, err := url.Parse(fmt.Sprintf("http://%s:%d", i.options.Host, i.options.Port))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse target URL for instance %s: %w", i.Name, err)
|
||||
}
|
||||
|
||||
i.proxy = httputil.NewSingleHostReverseProxy(targetURL)
|
||||
if i.proxy != nil {
|
||||
return i.proxy, nil
|
||||
}
|
||||
|
||||
if i.options == nil {
|
||||
return nil, fmt.Errorf("instance %s has no options set", i.Name)
|
||||
}
|
||||
|
||||
targetURL, err := url.Parse(fmt.Sprintf("http://%s:%d", i.options.Host, i.options.Port))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse target URL for instance %s: %w", i.Name, err)
|
||||
}
|
||||
|
||||
proxy := httputil.NewSingleHostReverseProxy(targetURL)
|
||||
|
||||
proxy.ModifyResponse = func(resp *http.Response) error {
|
||||
// Remove CORS headers from llama-server response to avoid conflicts
|
||||
// llamactl will add its own CORS headers
|
||||
resp.Header.Del("Access-Control-Allow-Origin")
|
||||
resp.Header.Del("Access-Control-Allow-Methods")
|
||||
resp.Header.Del("Access-Control-Allow-Headers")
|
||||
resp.Header.Del("Access-Control-Allow-Credentials")
|
||||
resp.Header.Del("Access-Control-Max-Age")
|
||||
resp.Header.Del("Access-Control-Expose-Headers")
|
||||
return nil
|
||||
}
|
||||
|
||||
i.proxy = proxy
|
||||
|
||||
return i.proxy, nil
|
||||
}
|
||||
|
||||
|
||||
188
pkg/middleware.go
Normal file
188
pkg/middleware.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package llamactl
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type KeyType int
|
||||
|
||||
const (
|
||||
KeyTypeInference KeyType = iota
|
||||
KeyTypeManagement
|
||||
)
|
||||
|
||||
type APIAuthMiddleware struct {
|
||||
requireInferenceAuth bool
|
||||
inferenceKeys map[string]bool
|
||||
requireManagementAuth bool
|
||||
managementKeys map[string]bool
|
||||
}
|
||||
|
||||
// NewAPIAuthMiddleware creates a new APIAuthMiddleware with the given configuration
|
||||
func NewAPIAuthMiddleware(config AuthConfig) *APIAuthMiddleware {
|
||||
|
||||
var generated bool = false
|
||||
|
||||
inferenceAPIKeys := make(map[string]bool)
|
||||
managementAPIKeys := make(map[string]bool)
|
||||
|
||||
const banner = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
|
||||
if config.RequireManagementAuth && len(config.ManagementKeys) == 0 {
|
||||
key := generateAPIKey(KeyTypeManagement)
|
||||
managementAPIKeys[key] = true
|
||||
generated = true
|
||||
fmt.Printf("%s\n⚠️ MANAGEMENT AUTHENTICATION REQUIRED\n%s\n", banner, banner)
|
||||
fmt.Printf("🔑 Generated Management API Key:\n\n %s\n\n", key)
|
||||
}
|
||||
for _, key := range config.ManagementKeys {
|
||||
managementAPIKeys[key] = true
|
||||
}
|
||||
|
||||
if config.RequireInferenceAuth && len(config.InferenceKeys) == 0 {
|
||||
key := generateAPIKey(KeyTypeInference)
|
||||
inferenceAPIKeys[key] = true
|
||||
generated = true
|
||||
fmt.Printf("%s\n⚠️ INFERENCE AUTHENTICATION REQUIRED\n%s\n", banner, banner)
|
||||
fmt.Printf("🔑 Generated Inference API Key:\n\n %s\n\n", key)
|
||||
}
|
||||
for _, key := range config.InferenceKeys {
|
||||
inferenceAPIKeys[key] = true
|
||||
}
|
||||
|
||||
if generated {
|
||||
fmt.Printf("%s\n⚠️ IMPORTANT\n%s\n", banner, banner)
|
||||
fmt.Println("• These keys are auto-generated and will change on restart")
|
||||
fmt.Println("• For production, add explicit keys to your configuration")
|
||||
fmt.Println("• Copy these keys before they disappear from the terminal")
|
||||
fmt.Println(banner)
|
||||
}
|
||||
|
||||
return &APIAuthMiddleware{
|
||||
requireInferenceAuth: config.RequireInferenceAuth,
|
||||
inferenceKeys: inferenceAPIKeys,
|
||||
requireManagementAuth: config.RequireManagementAuth,
|
||||
managementKeys: managementAPIKeys,
|
||||
}
|
||||
}
|
||||
|
||||
// generateAPIKey creates a cryptographically secure API key
|
||||
func generateAPIKey(keyType KeyType) string {
|
||||
// Generate 32 random bytes (256 bits)
|
||||
randomBytes := make([]byte, 32)
|
||||
|
||||
var prefix string
|
||||
|
||||
switch keyType {
|
||||
case KeyTypeInference:
|
||||
prefix = "sk-inference"
|
||||
case KeyTypeManagement:
|
||||
prefix = "sk-management"
|
||||
default:
|
||||
prefix = "sk-unknown"
|
||||
}
|
||||
|
||||
if _, err := rand.Read(randomBytes); err != nil {
|
||||
log.Printf("Warning: Failed to generate secure random key, using fallback")
|
||||
// Fallback to a less secure method if crypto/rand fails
|
||||
return fmt.Sprintf("%s-fallback-%d", prefix, os.Getpid())
|
||||
}
|
||||
|
||||
// Convert to hex and add prefix
|
||||
return fmt.Sprintf("%s-%s", prefix, hex.EncodeToString(randomBytes))
|
||||
}
|
||||
|
||||
// AuthMiddleware returns a middleware that checks API keys for the given key type
|
||||
func (a *APIAuthMiddleware) AuthMiddleware(keyType KeyType) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "OPTIONS" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
apiKey := a.extractAPIKey(r)
|
||||
if apiKey == "" {
|
||||
a.unauthorized(w, "Missing API key")
|
||||
return
|
||||
}
|
||||
|
||||
var isValid bool
|
||||
switch keyType {
|
||||
case KeyTypeInference:
|
||||
// Management keys also work for OpenAI endpoints (higher privilege)
|
||||
isValid = a.isValidKey(apiKey, KeyTypeInference) || a.isValidKey(apiKey, KeyTypeManagement)
|
||||
case KeyTypeManagement:
|
||||
isValid = a.isValidKey(apiKey, KeyTypeManagement)
|
||||
default:
|
||||
isValid = false
|
||||
}
|
||||
|
||||
if !isValid {
|
||||
a.unauthorized(w, "Invalid API key")
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// extractAPIKey extracts the API key from the request
|
||||
func (a *APIAuthMiddleware) extractAPIKey(r *http.Request) string {
|
||||
// Check Authorization header: "Bearer sk-..."
|
||||
if auth := r.Header.Get("Authorization"); auth != "" {
|
||||
if after, ok := strings.CutPrefix(auth, "Bearer "); ok {
|
||||
return after
|
||||
}
|
||||
}
|
||||
|
||||
// Check X-API-Key header
|
||||
if apiKey := r.Header.Get("X-API-Key"); apiKey != "" {
|
||||
return apiKey
|
||||
}
|
||||
|
||||
// Check query parameter
|
||||
if apiKey := r.URL.Query().Get("api_key"); apiKey != "" {
|
||||
return apiKey
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// isValidKey checks if the provided API key is valid for the given key type
|
||||
func (a *APIAuthMiddleware) isValidKey(providedKey string, keyType KeyType) bool {
|
||||
var validKeys map[string]bool
|
||||
|
||||
switch keyType {
|
||||
case KeyTypeInference:
|
||||
validKeys = a.inferenceKeys
|
||||
case KeyTypeManagement:
|
||||
validKeys = a.managementKeys
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
||||
for validKey := range validKeys {
|
||||
if len(providedKey) == len(validKey) &&
|
||||
subtle.ConstantTimeCompare([]byte(providedKey), []byte(validKey)) == 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// unauthorized sends an unauthorized response
|
||||
func (a *APIAuthMiddleware) unauthorized(w http.ResponseWriter, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
response := fmt.Sprintf(`{"error": {"message": "%s", "type": "authentication_error"}}`, message)
|
||||
w.Write([]byte(response))
|
||||
}
|
||||
354
pkg/middleware_test.go
Normal file
354
pkg/middleware_test.go
Normal file
@@ -0,0 +1,354 @@
|
||||
package llamactl_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
llamactl "llamactl/pkg"
|
||||
)
|
||||
|
||||
func TestAuthMiddleware(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
keyType llamactl.KeyType
|
||||
inferenceKeys []string
|
||||
managementKeys []string
|
||||
requestKey string
|
||||
method string
|
||||
expectedStatus int
|
||||
}{
|
||||
// Valid key tests
|
||||
{
|
||||
name: "valid inference key for inference",
|
||||
keyType: llamactl.KeyTypeInference,
|
||||
inferenceKeys: []string{"sk-inference-valid123"},
|
||||
requestKey: "sk-inference-valid123",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "valid management key for inference", // Management keys work for inference
|
||||
keyType: llamactl.KeyTypeInference,
|
||||
managementKeys: []string{"sk-management-admin123"},
|
||||
requestKey: "sk-management-admin123",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "valid management key for management",
|
||||
keyType: llamactl.KeyTypeManagement,
|
||||
managementKeys: []string{"sk-management-admin123"},
|
||||
requestKey: "sk-management-admin123",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
|
||||
// Invalid key tests
|
||||
{
|
||||
name: "inference key for management should fail",
|
||||
keyType: llamactl.KeyTypeManagement,
|
||||
inferenceKeys: []string{"sk-inference-user123"},
|
||||
requestKey: "sk-inference-user123",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "invalid inference key",
|
||||
keyType: llamactl.KeyTypeInference,
|
||||
inferenceKeys: []string{"sk-inference-valid123"},
|
||||
requestKey: "sk-inference-invalid",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "missing inference key",
|
||||
keyType: llamactl.KeyTypeInference,
|
||||
inferenceKeys: []string{"sk-inference-valid123"},
|
||||
requestKey: "",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "invalid management key",
|
||||
keyType: llamactl.KeyTypeManagement,
|
||||
managementKeys: []string{"sk-management-valid123"},
|
||||
requestKey: "sk-management-invalid",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
},
|
||||
{
|
||||
name: "missing management key",
|
||||
keyType: llamactl.KeyTypeManagement,
|
||||
managementKeys: []string{"sk-management-valid123"},
|
||||
requestKey: "",
|
||||
method: "GET",
|
||||
expectedStatus: http.StatusUnauthorized,
|
||||
},
|
||||
|
||||
// OPTIONS requests should always pass
|
||||
{
|
||||
name: "OPTIONS request bypasses inference auth",
|
||||
keyType: llamactl.KeyTypeInference,
|
||||
inferenceKeys: []string{"sk-inference-valid123"},
|
||||
requestKey: "",
|
||||
method: "OPTIONS",
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "OPTIONS request bypasses management auth",
|
||||
keyType: llamactl.KeyTypeManagement,
|
||||
managementKeys: []string{"sk-management-valid123"},
|
||||
requestKey: "",
|
||||
method: "OPTIONS",
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
|
||||
// Cross-key-type validation
|
||||
{
|
||||
name: "management key works for inference endpoint",
|
||||
keyType: llamactl.KeyTypeInference,
|
||||
inferenceKeys: []string{},
|
||||
managementKeys: []string{"sk-management-admin"},
|
||||
requestKey: "sk-management-admin",
|
||||
method: "POST",
|
||||
expectedStatus: http.StatusOK,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := llamactl.AuthConfig{
|
||||
InferenceKeys: tt.inferenceKeys,
|
||||
ManagementKeys: tt.managementKeys,
|
||||
}
|
||||
middleware := llamactl.NewAPIAuthMiddleware(config)
|
||||
|
||||
// Create test request
|
||||
req := httptest.NewRequest(tt.method, "/test", nil)
|
||||
if tt.requestKey != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tt.requestKey)
|
||||
}
|
||||
|
||||
// Create test handler using the appropriate middleware
|
||||
var handler http.Handler
|
||||
if tt.keyType == llamactl.KeyTypeInference {
|
||||
handler = middleware.AuthMiddleware(llamactl.KeyTypeInference)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
} else {
|
||||
handler = middleware.AuthMiddleware(llamactl.KeyTypeManagement)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
}
|
||||
|
||||
// Execute request
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, req)
|
||||
|
||||
if recorder.Code != tt.expectedStatus {
|
||||
t.Errorf("AuthMiddleware() status = %v, expected %v", recorder.Code, tt.expectedStatus)
|
||||
}
|
||||
|
||||
// Check that unauthorized responses have proper format
|
||||
if recorder.Code == http.StatusUnauthorized {
|
||||
contentType := recorder.Header().Get("Content-Type")
|
||||
if contentType != "application/json" {
|
||||
t.Errorf("Unauthorized response Content-Type = %v, expected application/json", contentType)
|
||||
}
|
||||
|
||||
body := recorder.Body.String()
|
||||
if !strings.Contains(body, `"type": "authentication_error"`) {
|
||||
t.Errorf("Unauthorized response missing proper error type: %v", body)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateAPIKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
keyType llamactl.KeyType
|
||||
}{
|
||||
{"inference key generation", llamactl.KeyTypeInference},
|
||||
{"management key generation", llamactl.KeyTypeManagement},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Test auto-generation by creating config that will trigger it
|
||||
var config llamactl.AuthConfig
|
||||
if tt.keyType == llamactl.KeyTypeInference {
|
||||
config.RequireInferenceAuth = true
|
||||
config.InferenceKeys = []string{} // Empty to trigger generation
|
||||
} else {
|
||||
config.RequireManagementAuth = true
|
||||
config.ManagementKeys = []string{} // Empty to trigger generation
|
||||
}
|
||||
|
||||
// Create middleware - this should trigger key generation
|
||||
middleware := llamactl.NewAPIAuthMiddleware(config)
|
||||
|
||||
// Test that auth is required (meaning a key was generated)
|
||||
req := httptest.NewRequest("GET", "/", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
var handler http.Handler
|
||||
if tt.keyType == llamactl.KeyTypeInference {
|
||||
handler = middleware.AuthMiddleware(llamactl.KeyTypeInference)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
} else {
|
||||
handler = middleware.AuthMiddleware(llamactl.KeyTypeManagement)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
}
|
||||
|
||||
handler.ServeHTTP(recorder, req)
|
||||
|
||||
// Should be unauthorized without a key (proving that a key was generated and auth is working)
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Expected unauthorized without key, got status %v", recorder.Code)
|
||||
}
|
||||
|
||||
// Test uniqueness by creating another middleware instance
|
||||
middleware2 := llamactl.NewAPIAuthMiddleware(config)
|
||||
|
||||
req2 := httptest.NewRequest("GET", "/", nil)
|
||||
recorder2 := httptest.NewRecorder()
|
||||
|
||||
if tt.keyType == llamactl.KeyTypeInference {
|
||||
handler2 := middleware2.AuthMiddleware(llamactl.KeyTypeInference)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
handler2.ServeHTTP(recorder2, req2)
|
||||
} else {
|
||||
handler2 := middleware2.AuthMiddleware(llamactl.KeyTypeManagement)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
handler2.ServeHTTP(recorder2, req2)
|
||||
}
|
||||
|
||||
// Both should require auth (proving keys were generated for both instances)
|
||||
if recorder2.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Expected unauthorized for second middleware without key, got status %v", recorder2.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoGeneration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
requireInference bool
|
||||
requireManagement bool
|
||||
providedInference []string
|
||||
providedManagement []string
|
||||
shouldGenerateInf bool // Whether inference key should be generated
|
||||
shouldGenerateMgmt bool // Whether management key should be generated
|
||||
}{
|
||||
{
|
||||
name: "inference auth required, keys provided - no generation",
|
||||
requireInference: true,
|
||||
requireManagement: false,
|
||||
providedInference: []string{"sk-inference-provided"},
|
||||
providedManagement: []string{},
|
||||
shouldGenerateInf: false,
|
||||
shouldGenerateMgmt: false,
|
||||
},
|
||||
{
|
||||
name: "inference auth required, no keys - should auto-generate",
|
||||
requireInference: true,
|
||||
requireManagement: false,
|
||||
providedInference: []string{},
|
||||
providedManagement: []string{},
|
||||
shouldGenerateInf: true,
|
||||
shouldGenerateMgmt: false,
|
||||
},
|
||||
{
|
||||
name: "management auth required, keys provided - no generation",
|
||||
requireInference: false,
|
||||
requireManagement: true,
|
||||
providedInference: []string{},
|
||||
providedManagement: []string{"sk-management-provided"},
|
||||
shouldGenerateInf: false,
|
||||
shouldGenerateMgmt: false,
|
||||
},
|
||||
{
|
||||
name: "management auth required, no keys - should auto-generate",
|
||||
requireInference: false,
|
||||
requireManagement: true,
|
||||
providedInference: []string{},
|
||||
providedManagement: []string{},
|
||||
shouldGenerateInf: false,
|
||||
shouldGenerateMgmt: true,
|
||||
},
|
||||
{
|
||||
name: "both required, both provided - no generation",
|
||||
requireInference: true,
|
||||
requireManagement: true,
|
||||
providedInference: []string{"sk-inference-provided"},
|
||||
providedManagement: []string{"sk-management-provided"},
|
||||
shouldGenerateInf: false,
|
||||
shouldGenerateMgmt: false,
|
||||
},
|
||||
{
|
||||
name: "both required, none provided - should auto-generate both",
|
||||
requireInference: true,
|
||||
requireManagement: true,
|
||||
providedInference: []string{},
|
||||
providedManagement: []string{},
|
||||
shouldGenerateInf: true,
|
||||
shouldGenerateMgmt: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
config := llamactl.AuthConfig{
|
||||
RequireInferenceAuth: tt.requireInference,
|
||||
RequireManagementAuth: tt.requireManagement,
|
||||
InferenceKeys: tt.providedInference,
|
||||
ManagementKeys: tt.providedManagement,
|
||||
}
|
||||
|
||||
middleware := llamactl.NewAPIAuthMiddleware(config)
|
||||
|
||||
// Test inference behavior if inference auth is required
|
||||
if tt.requireInference {
|
||||
req := httptest.NewRequest("GET", "/v1/models", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := middleware.AuthMiddleware(llamactl.KeyTypeInference)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, req)
|
||||
|
||||
// Should always be unauthorized without a key (since middleware assumes auth is required)
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Expected unauthorized for inference without key, got status %v", recorder.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Test management behavior if management auth is required
|
||||
if tt.requireManagement {
|
||||
req := httptest.NewRequest("GET", "/api/v1/instances", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
handler := middleware.AuthMiddleware(llamactl.KeyTypeManagement)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
handler.ServeHTTP(recorder, req)
|
||||
|
||||
// Should always be unauthorized without a key (since middleware assumes auth is required)
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Errorf("Expected unauthorized for management without key, got status %v", recorder.Code)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -26,12 +26,22 @@ func SetupRouter(handler *Handler) *chi.Mux {
|
||||
MaxAge: 300,
|
||||
}))
|
||||
|
||||
r.Get("/swagger/*", httpSwagger.Handler(
|
||||
httpSwagger.URL("/swagger/doc.json"),
|
||||
))
|
||||
// Add API authentication middleware
|
||||
authMiddleware := NewAPIAuthMiddleware(handler.config.Auth)
|
||||
|
||||
if handler.config.Server.EnableSwagger {
|
||||
r.Get("/swagger/*", httpSwagger.Handler(
|
||||
httpSwagger.URL("/swagger/doc.json"),
|
||||
))
|
||||
}
|
||||
|
||||
// Define routes
|
||||
r.Route("/api/v1", func(r chi.Router) {
|
||||
|
||||
if authMiddleware != nil && handler.config.Auth.RequireManagementAuth {
|
||||
r.Use(authMiddleware.AuthMiddleware(KeyTypeManagement))
|
||||
}
|
||||
|
||||
r.Route("/server", func(r chi.Router) {
|
||||
r.Get("/help", handler.HelpHandler())
|
||||
r.Get("/version", handler.VersionHandler())
|
||||
@@ -61,17 +71,25 @@ func SetupRouter(handler *Handler) *chi.Mux {
|
||||
})
|
||||
})
|
||||
|
||||
r.Get(("/v1/models"), handler.OpenAIListInstances()) // List instances in OpenAI-compatible format
|
||||
r.Route(("/v1"), func(r chi.Router) {
|
||||
|
||||
// OpenAI-compatible proxy endpoint
|
||||
// Handles all POST requests to /v1/*, including:
|
||||
// - /v1/completions
|
||||
// - /v1/chat/completions
|
||||
// - /v1/embeddings
|
||||
// - /v1/rerank
|
||||
// - /v1/reranking
|
||||
// The instance/model to use is determined by the request body.
|
||||
r.Post("/v1/*", handler.OpenAIProxy())
|
||||
if authMiddleware != nil && handler.config.Auth.RequireInferenceAuth {
|
||||
r.Use(authMiddleware.AuthMiddleware(KeyTypeInference))
|
||||
}
|
||||
|
||||
r.Get(("/models"), handler.OpenAIListInstances()) // List instances in OpenAI-compatible format
|
||||
|
||||
// OpenAI-compatible proxy endpoint
|
||||
// Handles all POST requests to /v1/*, including:
|
||||
// - /v1/completions
|
||||
// - /v1/chat/completions
|
||||
// - /v1/embeddings
|
||||
// - /v1/rerank
|
||||
// - /v1/reranking
|
||||
// The instance/model to use is determined by the request body.
|
||||
r.Post("/*", handler.OpenAIProxy())
|
||||
|
||||
})
|
||||
|
||||
// Serve WebUI files
|
||||
if err := webui.SetupWebUI(r); err != nil {
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { useState } from "react";
|
||||
import Header from "@/components/Header";
|
||||
import InstanceList from "@/components/InstanceList";
|
||||
import InstanceModal from "@/components/InstanceModal";
|
||||
import InstanceDialog from "@/components/InstanceDialog";
|
||||
import LoginDialog from "@/components/LoginDialog";
|
||||
import SystemInfoDialog from "./components/SystemInfoDialog";
|
||||
import { type CreateInstanceOptions, type Instance } from "@/types/instance";
|
||||
import { useInstances } from "@/contexts/InstancesContext";
|
||||
import SystemInfoModal from "./components/SystemInfoModal";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
function App() {
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const [isInstanceModalOpen, setIsInstanceModalOpen] = useState(false);
|
||||
const [isSystemInfoModalOpen, setIsSystemInfoModalOpen] = useState(false);
|
||||
const [editingInstance, setEditingInstance] = useState<Instance | undefined>(
|
||||
@@ -36,6 +39,28 @@ function App() {
|
||||
setIsSystemInfoModalOpen(true);
|
||||
};
|
||||
|
||||
// Show loading spinner while checking auth
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show login dialog if not authenticated
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<LoginDialog open={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show main app if authenticated
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header onCreateInstance={handleCreateInstance} onShowSystemInfo={handleShowSystemInfo} />
|
||||
@@ -43,14 +68,14 @@ function App() {
|
||||
<InstanceList editInstance={handleEditInstance} />
|
||||
</main>
|
||||
|
||||
<InstanceModal
|
||||
<InstanceDialog
|
||||
open={isInstanceModalOpen}
|
||||
onOpenChange={setIsInstanceModalOpen}
|
||||
onSave={handleSaveInstance}
|
||||
instance={editingInstance}
|
||||
/>
|
||||
|
||||
<SystemInfoModal
|
||||
<SystemInfoDialog
|
||||
open={isSystemInfoModalOpen}
|
||||
onOpenChange={setIsSystemInfoModalOpen}
|
||||
/>
|
||||
@@ -58,4 +83,4 @@ function App() {
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import App from '@/App'
|
||||
import { InstancesProvider } from '@/contexts/InstancesContext'
|
||||
import { instancesApi } from '@/lib/api'
|
||||
import type { Instance } from '@/types/instance'
|
||||
import { AuthProvider } from '@/contexts/AuthContext'
|
||||
|
||||
// Mock the API
|
||||
vi.mock('@/lib/api', () => ({
|
||||
@@ -35,9 +36,11 @@ vi.mock('@/lib/healthService', () => ({
|
||||
|
||||
function renderApp() {
|
||||
return render(
|
||||
<InstancesProvider>
|
||||
<App />
|
||||
</InstancesProvider>
|
||||
<AuthProvider>
|
||||
<InstancesProvider>
|
||||
<App />
|
||||
</InstancesProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -50,6 +53,12 @@ describe('App Component - Critical Business Logic Only', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(instancesApi.list).mockResolvedValue(mockInstances)
|
||||
window.sessionStorage.setItem('llamactl_management_key', 'test-api-key-123')
|
||||
global.fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 200 })))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('End-to-End Instance Management', () => {
|
||||
@@ -75,7 +84,7 @@ describe('App Component - Critical Business Logic Only', () => {
|
||||
const nameInput = screen.getByLabelText(/Instance Name/)
|
||||
await user.type(nameInput, 'new-test-instance')
|
||||
|
||||
await user.click(screen.getByTestId('modal-save-button'))
|
||||
await user.click(screen.getByTestId('dialog-save-button'))
|
||||
|
||||
// Verify correct API call
|
||||
await waitFor(() => {
|
||||
@@ -109,7 +118,7 @@ describe('App Component - Critical Business Logic Only', () => {
|
||||
const editButtons = screen.getAllByTitle('Edit instance')
|
||||
await user.click(editButtons[0])
|
||||
|
||||
await user.click(screen.getByTestId('modal-save-button'))
|
||||
await user.click(screen.getByTestId('dialog-save-button'))
|
||||
|
||||
// Verify correct API call with existing instance data
|
||||
await waitFor(() => {
|
||||
@@ -167,7 +176,6 @@ describe('App Component - Critical Business Logic Only', () => {
|
||||
renderApp()
|
||||
|
||||
// App should still render and show error
|
||||
expect(screen.getByText('Llamactl Dashboard')).toBeInTheDocument()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Error loading instances')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HelpCircle } from "lucide-react";
|
||||
import { HelpCircle, LogOut } from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
interface HeaderProps {
|
||||
onCreateInstance: () => void;
|
||||
@@ -7,6 +8,14 @@ interface HeaderProps {
|
||||
}
|
||||
|
||||
function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) {
|
||||
const { logout } = useAuth();
|
||||
|
||||
const handleLogout = () => {
|
||||
if (confirm("Are you sure you want to logout?")) {
|
||||
logout();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="bg-white border-b border-gray-200">
|
||||
<div className="container mx-auto max-w-4xl px-4 py-4">
|
||||
@@ -16,7 +25,9 @@ function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) {
|
||||
</h1>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button onClick={onCreateInstance} data-testid="create-instance-button">Create Instance</Button>
|
||||
<Button onClick={onCreateInstance} data-testid="create-instance-button">
|
||||
Create Instance
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -27,6 +38,16 @@ function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) {
|
||||
>
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleLogout}
|
||||
data-testid="logout-button"
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,4 +55,4 @@ function Header({ onCreateInstance, onShowSystemInfo }: HeaderProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export default Header;
|
||||
export default Header;
|
||||
@@ -3,7 +3,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import type { Instance } from "@/types/instance";
|
||||
import { Edit, FileText, Play, Square, Trash2 } from "lucide-react";
|
||||
import LogsModal from "@/components/LogModal";
|
||||
import LogsDialog from "@/components/LogDialog";
|
||||
import HealthBadge from "@/components/HealthBadge";
|
||||
import { useState } from "react";
|
||||
import { useInstanceHealth } from "@/hooks/useInstanceHealth";
|
||||
@@ -118,7 +118,7 @@ function InstanceCard({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<LogsModal
|
||||
<LogsDialog
|
||||
open={isLogsOpen}
|
||||
onOpenChange={setIsLogsOpen}
|
||||
instanceName={instance.name}
|
||||
|
||||
@@ -15,14 +15,14 @@ import { getBasicFields, getAdvancedFields } from "@/lib/zodFormUtils";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import ZodFormField from "@/components/ZodFormField";
|
||||
|
||||
interface InstanceModalProps {
|
||||
interface InstanceDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (name: string, options: CreateInstanceOptions) => void;
|
||||
instance?: Instance; // For editing existing instance
|
||||
}
|
||||
|
||||
const InstanceModal: React.FC<InstanceModalProps> = ({
|
||||
const InstanceDialog: React.FC<InstanceDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
@@ -40,7 +40,7 @@ const InstanceModal: React.FC<InstanceModalProps> = ({
|
||||
const basicFields = getBasicFields();
|
||||
const advancedFields = getAdvancedFields();
|
||||
|
||||
// Reset form when modal opens/closes or when instance changes
|
||||
// Reset form when dialog opens/closes or when instance changes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (instance) {
|
||||
@@ -255,14 +255,14 @@ const InstanceModal: React.FC<InstanceModalProps> = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
data-testid="modal-cancel-button"
|
||||
data-testid="dialog-cancel-button"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!instanceName.trim() || !!nameError}
|
||||
data-testid="modal-save-button"
|
||||
data-testid="dialog-save-button"
|
||||
>
|
||||
{isEditing
|
||||
? isRunning
|
||||
@@ -276,4 +276,4 @@ const InstanceModal: React.FC<InstanceModalProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default InstanceModal;
|
||||
export default InstanceDialog;
|
||||
@@ -21,14 +21,14 @@ import {
|
||||
Settings
|
||||
} from 'lucide-react'
|
||||
|
||||
interface LogsModalProps {
|
||||
interface LogsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
instanceName: string
|
||||
isRunning: boolean
|
||||
}
|
||||
|
||||
const LogsModal: React.FC<LogsModalProps> = ({
|
||||
const LogsDialog: React.FC<LogsDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
instanceName,
|
||||
@@ -76,7 +76,7 @@ const LogsModal: React.FC<LogsModalProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Initial load when modal opens
|
||||
// Initial load when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && instanceName) {
|
||||
fetchLogs(lineCount)
|
||||
@@ -327,4 +327,4 @@ const LogsModal: React.FC<LogsModalProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default LogsModal
|
||||
export default LogsDialog
|
||||
151
webui/src/components/LoginDialog.tsx
Normal file
151
webui/src/components/LoginDialog.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { AlertCircle, Key, Loader2 } from 'lucide-react'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
|
||||
interface LoginDialogProps {
|
||||
open: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
const LoginDialog: React.FC<LoginDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}) => {
|
||||
const { login, isLoading, error, clearError } = useAuth()
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [localLoading, setLocalLoading] = useState(false)
|
||||
|
||||
// Clear form and errors when dialog opens/closes
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setApiKey('')
|
||||
clearError()
|
||||
}
|
||||
}, [open, clearError])
|
||||
|
||||
// Clear error when user starts typing
|
||||
useEffect(() => {
|
||||
if (error && apiKey) {
|
||||
clearError()
|
||||
}
|
||||
}, [apiKey, error, clearError])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!apiKey.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
setLocalLoading(true)
|
||||
|
||||
try {
|
||||
await login(apiKey.trim())
|
||||
// Login successful - dialog will close automatically when auth state changes
|
||||
setApiKey('')
|
||||
} catch (err) {
|
||||
// Error is handled by the AuthContext
|
||||
console.error('Login failed:', err)
|
||||
} finally {
|
||||
setLocalLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !isSubmitDisabled) {
|
||||
// Create a synthetic FormEvent to satisfy handleSubmit's type
|
||||
const syntheticEvent = {
|
||||
preventDefault: () => {},
|
||||
} as React.FormEvent<HTMLFormElement>;
|
||||
void handleSubmit(syntheticEvent)
|
||||
}
|
||||
}
|
||||
|
||||
const isSubmitDisabled = !apiKey.trim() || isLoading || localLoading
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
showCloseButton={false} // Prevent closing without auth
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5" />
|
||||
Authentication Required
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Please enter your management API key to access the Llamactl dashboard.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={(e) => { void handleSubmit(e) }}>
|
||||
<div className="grid gap-4 py-4">
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0" />
|
||||
<span className="text-sm text-destructive">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key Input */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="apiKey">
|
||||
Management API Key <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="apiKey"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="sk-management-..."
|
||||
disabled={isLoading || localLoading}
|
||||
className={error ? "border-red-500" : ""}
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your management API key is required to access instance management features.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitDisabled}
|
||||
data-testid="login-submit-button"
|
||||
>
|
||||
{(isLoading || localLoading) ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Authenticating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Key className="h-4 w-4" />
|
||||
Login
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginDialog
|
||||
@@ -30,7 +30,7 @@ interface SystemInfo {
|
||||
help: string
|
||||
}
|
||||
|
||||
const SystemInfoModal: React.FC<SystemInfoModalProps> = ({
|
||||
const SystemInfoDialog: React.FC<SystemInfoModalProps> = ({
|
||||
open,
|
||||
onOpenChange
|
||||
}) => {
|
||||
@@ -59,7 +59,7 @@ const SystemInfoModal: React.FC<SystemInfoModalProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Load data when modal opens
|
||||
// Load data when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchSystemInfo()
|
||||
@@ -180,4 +180,4 @@ const SystemInfoModal: React.FC<SystemInfoModalProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default SystemInfoModal
|
||||
export default SystemInfoDialog
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import InstanceCard from '@/components/InstanceCard'
|
||||
@@ -27,9 +27,15 @@ describe('InstanceCard - Instance Actions and State', () => {
|
||||
options: { model: 'running-model.gguf' }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.sessionStorage.setItem('llamactl_management_key', 'test-api-key-123')
|
||||
global.fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 200 })))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Instance Action Buttons', () => {
|
||||
it('calls startInstance when start button clicked on stopped instance', async () => {
|
||||
@@ -93,7 +99,7 @@ describe('InstanceCard - Instance Actions and State', () => {
|
||||
expect(mockEditInstance).toHaveBeenCalledWith(stoppedInstance)
|
||||
})
|
||||
|
||||
it('opens logs modal when logs button clicked', async () => {
|
||||
it('opens logs dialog when logs button clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
@@ -109,7 +115,7 @@ describe('InstanceCard - Instance Actions and State', () => {
|
||||
const logsButton = screen.getByTitle('View logs')
|
||||
await user.click(logsButton)
|
||||
|
||||
// Should open logs modal (we can verify this by checking if modal title appears)
|
||||
// Should open logs dialog (we can verify this by checking if dialog title appears)
|
||||
expect(screen.getByText(`Logs: ${stoppedInstance.name}`)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -272,19 +278,19 @@ describe('InstanceCard - Instance Actions and State', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
// Open logs modal
|
||||
// Open logs dialog
|
||||
await user.click(screen.getByTitle('View logs'))
|
||||
|
||||
// Verify modal opened with correct instance data
|
||||
// Verify dialog opened with correct instance data
|
||||
expect(screen.getByText('Logs: running-instance')).toBeInTheDocument()
|
||||
|
||||
// Close modal to test close functionality
|
||||
// Close dialog to test close functionality
|
||||
const closeButtons = screen.getAllByText('Close')
|
||||
const modalCloseButton = closeButtons.find(button =>
|
||||
const dialogCloseButton = closeButtons.find(button =>
|
||||
button.closest('[data-slot="dialog-content"]')
|
||||
)
|
||||
expect(modalCloseButton).toBeTruthy()
|
||||
await user.click(modalCloseButton!)
|
||||
expect(dialogCloseButton).toBeTruthy()
|
||||
await user.click(dialogCloseButton!)
|
||||
|
||||
// Modal should close
|
||||
expect(screen.queryByText('Logs: running-instance')).not.toBeInTheDocument()
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import InstanceList from '@/components/InstanceList'
|
||||
import { InstancesProvider } from '@/contexts/InstancesContext'
|
||||
import { instancesApi } from '@/lib/api'
|
||||
import type { Instance } from '@/types/instance'
|
||||
import { AuthProvider } from '@/contexts/AuthContext'
|
||||
|
||||
// Mock the API
|
||||
vi.mock('@/lib/api', () => ({
|
||||
@@ -30,13 +31,16 @@ vi.mock('@/lib/healthService', () => ({
|
||||
|
||||
function renderInstanceList(editInstance = vi.fn()) {
|
||||
return render(
|
||||
<InstancesProvider>
|
||||
<InstanceList editInstance={editInstance} />
|
||||
</InstancesProvider>
|
||||
<AuthProvider>
|
||||
<InstancesProvider>
|
||||
<InstanceList editInstance={editInstance} />
|
||||
</InstancesProvider>
|
||||
</AuthProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('InstanceList - State Management and UI Logic', () => {
|
||||
|
||||
const mockEditInstance = vi.fn()
|
||||
|
||||
const mockInstances: Instance[] = [
|
||||
@@ -45,12 +49,20 @@ describe('InstanceList - State Management and UI Logic', () => {
|
||||
{ name: 'instance-3', running: false, options: { model: 'model3.gguf' } }
|
||||
]
|
||||
|
||||
const DUMMY_API_KEY = 'test-api-key-123'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.sessionStorage.setItem('llamactl_management_key', DUMMY_API_KEY)
|
||||
global.fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 200 })))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('shows loading spinner while instances are being fetched', async () => {
|
||||
it('shows loading spinner while instances are being fetched', () => {
|
||||
// Mock a delayed response to test loading state
|
||||
vi.mocked(instancesApi.list).mockImplementation(() =>
|
||||
new Promise(resolve => setTimeout(() => resolve(mockInstances), 100))
|
||||
@@ -220,27 +232,5 @@ describe('InstanceList - State Management and UI Logic', () => {
|
||||
expect(await screen.findByText('Instances (3)')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Loading instances...')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles transition from error back to loaded state', async () => {
|
||||
// Start with error
|
||||
vi.mocked(instancesApi.list).mockRejectedValue(new Error('Network error'))
|
||||
|
||||
const { rerender } = renderInstanceList(mockEditInstance)
|
||||
|
||||
expect(await screen.findByText('Error loading instances')).toBeInTheDocument()
|
||||
|
||||
// Simulate recovery (e.g., retry after network recovery)
|
||||
vi.mocked(instancesApi.list).mockResolvedValue(mockInstances)
|
||||
|
||||
rerender(
|
||||
<InstancesProvider>
|
||||
<InstanceList editInstance={mockEditInstance} />
|
||||
</InstancesProvider>
|
||||
)
|
||||
|
||||
// Should eventually show instances
|
||||
// Note: This test is somewhat artificial since the context handles retries
|
||||
expect(screen.getByText('Error loading instances')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,23 +1,29 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import InstanceModal from '@/components/InstanceModal'
|
||||
import InstanceDialog from '@/components/InstanceDialog'
|
||||
import type { Instance } from '@/types/instance'
|
||||
|
||||
describe('InstanceModal - Form Logic and Validation', () => {
|
||||
const mockOnSave = vi.fn()
|
||||
const mockOnOpenChange = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
window.sessionStorage.setItem('llamactl_management_key', 'test-api-key-123')
|
||||
global.fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 200 })))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('Create Mode', () => {
|
||||
it('validates instance name is required', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<InstanceModal
|
||||
<InstanceDialog
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSave={mockOnSave}
|
||||
@@ -25,7 +31,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
|
||||
)
|
||||
|
||||
// Try to submit without name
|
||||
const saveButton = screen.getByTestId('modal-save-button')
|
||||
const saveButton = screen.getByTestId('dialog-save-button')
|
||||
expect(saveButton).toBeDisabled()
|
||||
|
||||
// Add name, button should be enabled
|
||||
@@ -41,7 +47,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<InstanceModal
|
||||
<InstanceDialog
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSave={mockOnSave}
|
||||
@@ -54,7 +60,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
|
||||
await user.type(nameInput, 'test instance!')
|
||||
|
||||
expect(screen.getByText(/can only contain letters, numbers, hyphens, and underscores/)).toBeInTheDocument()
|
||||
expect(screen.getByTestId('modal-save-button')).toBeDisabled()
|
||||
expect(screen.getByTestId('dialog-save-button')).toBeDisabled()
|
||||
|
||||
// Clear and test valid name
|
||||
await user.clear(nameInput)
|
||||
@@ -62,7 +68,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(/can only contain letters, numbers, hyphens, and underscores/)).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('modal-save-button')).not.toBeDisabled()
|
||||
expect(screen.getByTestId('dialog-save-button')).not.toBeDisabled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -70,7 +76,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<InstanceModal
|
||||
<InstanceDialog
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSave={mockOnSave}
|
||||
@@ -81,16 +87,16 @@ describe('InstanceModal - Form Logic and Validation', () => {
|
||||
await user.type(screen.getByLabelText(/Instance Name/), 'my-instance')
|
||||
|
||||
// Submit form
|
||||
await user.click(screen.getByTestId('modal-save-button'))
|
||||
await user.click(screen.getByTestId('dialog-save-button'))
|
||||
|
||||
expect(mockOnSave).toHaveBeenCalledWith('my-instance', {
|
||||
auto_restart: true, // Default value
|
||||
})
|
||||
})
|
||||
|
||||
it('form resets when modal reopens', async () => {
|
||||
it('form resets when dialog reopens', async () => {
|
||||
const { rerender } = render(
|
||||
<InstanceModal
|
||||
<InstanceDialog
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSave={mockOnSave}
|
||||
@@ -101,18 +107,18 @@ describe('InstanceModal - Form Logic and Validation', () => {
|
||||
const nameInput = screen.getByLabelText(/Instance Name/)
|
||||
await userEvent.setup().type(nameInput, 'temp-name')
|
||||
|
||||
// Close modal
|
||||
// Close dialog
|
||||
rerender(
|
||||
<InstanceModal
|
||||
<InstanceDialog
|
||||
open={false}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSave={mockOnSave}
|
||||
/>
|
||||
)
|
||||
|
||||
// Reopen modal
|
||||
// Reopen dialog
|
||||
rerender(
|
||||
<InstanceModal
|
||||
<InstanceDialog
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSave={mockOnSave}
|
||||
@@ -138,7 +144,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
|
||||
|
||||
it('pre-fills form with existing instance data', () => {
|
||||
render(
|
||||
<InstanceModal
|
||||
<InstanceDialog
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSave={mockOnSave}
|
||||
@@ -159,7 +165,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<InstanceModal
|
||||
<InstanceDialog
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSave={mockOnSave}
|
||||
@@ -168,7 +174,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
|
||||
)
|
||||
|
||||
// Submit without changes
|
||||
await user.click(screen.getByTestId('modal-save-button'))
|
||||
await user.click(screen.getByTestId('dialog-save-button'))
|
||||
|
||||
expect(mockOnSave).toHaveBeenCalledWith('existing-instance', {
|
||||
model: 'test-model.gguf',
|
||||
@@ -181,7 +187,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
|
||||
const runningInstance: Instance = { ...mockInstance, running: true }
|
||||
|
||||
const { rerender } = render(
|
||||
<InstanceModal
|
||||
<InstanceDialog
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSave={mockOnSave}
|
||||
@@ -189,10 +195,10 @@ describe('InstanceModal - Form Logic and Validation', () => {
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('modal-save-button')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('dialog-save-button')).toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<InstanceModal
|
||||
<InstanceDialog
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSave={mockOnSave}
|
||||
@@ -207,7 +213,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
|
||||
describe('Auto Restart Configuration', () => {
|
||||
it('shows restart options when auto restart is enabled', () => {
|
||||
render(
|
||||
<InstanceModal
|
||||
<InstanceDialog
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSave={mockOnSave}
|
||||
@@ -227,7 +233,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<InstanceModal
|
||||
<InstanceDialog
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSave={mockOnSave}
|
||||
@@ -247,7 +253,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<InstanceModal
|
||||
<InstanceDialog
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSave={mockOnSave}
|
||||
@@ -261,7 +267,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
|
||||
await user.type(screen.getByLabelText(/Max Restarts/), '5')
|
||||
await user.type(screen.getByLabelText(/Restart Delay/), '10')
|
||||
|
||||
await user.click(screen.getByTestId('modal-save-button'))
|
||||
await user.click(screen.getByTestId('dialog-save-button'))
|
||||
|
||||
expect(mockOnSave).toHaveBeenCalledWith('test-instance', {
|
||||
auto_restart: true,
|
||||
@@ -276,7 +282,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<InstanceModal
|
||||
<InstanceDialog
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSave={mockOnSave}
|
||||
@@ -300,7 +306,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<InstanceModal
|
||||
<InstanceDialog
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSave={mockOnSave}
|
||||
@@ -310,7 +316,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
|
||||
// Fill only required field
|
||||
await user.type(screen.getByLabelText(/Instance Name/), 'clean-instance')
|
||||
|
||||
await user.click(screen.getByTestId('modal-save-button'))
|
||||
await user.click(screen.getByTestId('dialog-save-button'))
|
||||
|
||||
// Should only include non-empty values
|
||||
expect(mockOnSave).toHaveBeenCalledWith('clean-instance', {
|
||||
@@ -322,7 +328,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<InstanceModal
|
||||
<InstanceDialog
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSave={mockOnSave}
|
||||
@@ -335,7 +341,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
|
||||
const gpuLayersInput = screen.getByLabelText(/GPU Layers/)
|
||||
await user.type(gpuLayersInput, '15')
|
||||
|
||||
await user.click(screen.getByTestId('modal-save-button'))
|
||||
await user.click(screen.getByTestId('dialog-save-button'))
|
||||
|
||||
expect(mockOnSave).toHaveBeenCalledWith('numeric-test', {
|
||||
auto_restart: true,
|
||||
@@ -349,14 +355,14 @@ describe('InstanceModal - Form Logic and Validation', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<InstanceModal
|
||||
<InstanceDialog
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSave={mockOnSave}
|
||||
/>
|
||||
)
|
||||
|
||||
await user.click(screen.getByTestId('modal-cancel-button'))
|
||||
await user.click(screen.getByTestId('dialog-cancel-button'))
|
||||
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
@@ -365,7 +371,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<InstanceModal
|
||||
<InstanceDialog
|
||||
open={true}
|
||||
onOpenChange={mockOnOpenChange}
|
||||
onSave={mockOnSave}
|
||||
@@ -373,7 +379,7 @@ describe('InstanceModal - Form Logic and Validation', () => {
|
||||
)
|
||||
|
||||
await user.type(screen.getByLabelText(/Instance Name/), 'test')
|
||||
await user.click(screen.getByTestId('modal-save-button'))
|
||||
await user.click(screen.getByTestId('dialog-save-button'))
|
||||
|
||||
expect(mockOnSave).toHaveBeenCalled()
|
||||
expect(mockOnOpenChange).toHaveBeenCalledWith(false)
|
||||
|
||||
162
webui/src/contexts/AuthContext.tsx
Normal file
162
webui/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { type ReactNode, createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||
|
||||
interface AuthContextState {
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
apiKey: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
interface AuthContextActions {
|
||||
login: (apiKey: string) => Promise<void>
|
||||
logout: () => void
|
||||
clearError: () => void
|
||||
validateAuth: () => Promise<boolean>
|
||||
}
|
||||
|
||||
type AuthContextType = AuthContextState & AuthContextActions
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const AUTH_STORAGE_KEY = 'llamactl_management_key'
|
||||
|
||||
export const AuthProvider = ({ children }: AuthProviderProps) => {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [apiKey, setApiKey] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Load auth state from sessionStorage on mount
|
||||
useEffect(() => {
|
||||
const loadStoredAuth = async () => {
|
||||
try {
|
||||
const storedKey = sessionStorage.getItem(AUTH_STORAGE_KEY)
|
||||
if (storedKey) {
|
||||
setApiKey(storedKey)
|
||||
// Validate the stored key
|
||||
const isValid = await validateApiKey(storedKey)
|
||||
if (isValid) {
|
||||
setIsAuthenticated(true)
|
||||
} else {
|
||||
// Invalid key, remove it
|
||||
sessionStorage.removeItem(AUTH_STORAGE_KEY)
|
||||
setApiKey(null)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading stored auth:', err)
|
||||
// Clear potentially corrupted storage
|
||||
sessionStorage.removeItem(AUTH_STORAGE_KEY)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
void loadStoredAuth()
|
||||
}, [])
|
||||
|
||||
// Validate API key by making a test request
|
||||
const validateApiKey = async (key: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch('/api/v1/instances', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${key}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
return response.ok
|
||||
} catch (err) {
|
||||
console.error('Auth validation error:', err)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const login = useCallback(async (key: string) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
// Validate the provided API key
|
||||
const isValid = await validateApiKey(key)
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid API key')
|
||||
}
|
||||
|
||||
// Store the key and update state
|
||||
sessionStorage.setItem(AUTH_STORAGE_KEY, key)
|
||||
setApiKey(key)
|
||||
setIsAuthenticated(true)
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Authentication failed'
|
||||
setError(errorMessage)
|
||||
throw new Error(errorMessage)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
sessionStorage.removeItem(AUTH_STORAGE_KEY)
|
||||
setApiKey(null)
|
||||
setIsAuthenticated(false)
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null)
|
||||
}, [])
|
||||
|
||||
const validateAuth = useCallback(async (): Promise<boolean> => {
|
||||
if (!apiKey) return false
|
||||
|
||||
const isValid = await validateApiKey(apiKey)
|
||||
if (!isValid) {
|
||||
logout()
|
||||
}
|
||||
return isValid
|
||||
}, [apiKey, logout])
|
||||
|
||||
const value: AuthContextType = {
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
apiKey,
|
||||
error,
|
||||
login,
|
||||
logout,
|
||||
clearError,
|
||||
validateAuth,
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useAuth = (): AuthContextType => {
|
||||
const context = useContext(AuthContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// Helper hook for getting auth headers
|
||||
export const useAuthHeaders = (): HeadersInit => {
|
||||
const { apiKey, isAuthenticated } = useAuth()
|
||||
|
||||
if (!isAuthenticated || !apiKey) {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||
import { type ReactNode, createContext, useContext, useState, useEffect, useCallback } from 'react'
|
||||
import type { CreateInstanceOptions, Instance } from '@/types/instance'
|
||||
import { instancesApi } from '@/lib/api'
|
||||
import { useAuth } from '@/contexts/AuthContext'
|
||||
|
||||
interface InstancesContextState {
|
||||
instances: Instance[]
|
||||
@@ -29,6 +29,7 @@ interface InstancesProviderProps {
|
||||
}
|
||||
|
||||
export const InstancesProvider = ({ children }: InstancesProviderProps) => {
|
||||
const { isAuthenticated, isLoading: authLoading } = useAuth()
|
||||
const [instancesMap, setInstancesMap] = useState<Map<string, Instance>>(new Map())
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -41,6 +42,11 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
|
||||
}, [])
|
||||
|
||||
const fetchInstances = useCallback(async () => {
|
||||
if (!isAuthenticated) {
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
@@ -57,7 +63,7 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
}, [isAuthenticated])
|
||||
|
||||
const updateInstanceInMap = useCallback((name: string, updates: Partial<Instance>) => {
|
||||
setInstancesMap(prev => {
|
||||
@@ -154,9 +160,19 @@ export const InstancesProvider = ({ children }: InstancesProviderProps) => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Only fetch instances when auth is ready and user is authenticated
|
||||
useEffect(() => {
|
||||
fetchInstances()
|
||||
}, [fetchInstances])
|
||||
if (!authLoading) {
|
||||
if (isAuthenticated) {
|
||||
void fetchInstances()
|
||||
} else {
|
||||
// Clear instances when not authenticated
|
||||
setInstancesMap(new Map())
|
||||
setLoading(false)
|
||||
setError(null)
|
||||
}
|
||||
}
|
||||
}, [authLoading, isAuthenticated, fetchInstances])
|
||||
|
||||
const value: InstancesContextType = {
|
||||
instances,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { InstancesProvider, useInstances } from '@/contexts/InstancesContext'
|
||||
import { instancesApi } from '@/lib/api'
|
||||
import type { Instance } from '@/types/instance'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { InstancesProvider, useInstances } from "@/contexts/InstancesContext";
|
||||
import { instancesApi } from "@/lib/api";
|
||||
import type { Instance } from "@/types/instance";
|
||||
import { AuthProvider } from "../AuthContext";
|
||||
|
||||
// Mock the API module
|
||||
vi.mock('@/lib/api', () => ({
|
||||
vi.mock("@/lib/api", () => ({
|
||||
instancesApi: {
|
||||
list: vi.fn(),
|
||||
create: vi.fn(),
|
||||
@@ -15,8 +16,8 @@ vi.mock('@/lib/api', () => ({
|
||||
stop: vi.fn(),
|
||||
restart: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
}
|
||||
}))
|
||||
},
|
||||
}));
|
||||
|
||||
// Test component to access context
|
||||
function TestComponent() {
|
||||
@@ -30,366 +31,389 @@ function TestComponent() {
|
||||
stopInstance,
|
||||
restartInstance,
|
||||
deleteInstance,
|
||||
clearError
|
||||
} = useInstances()
|
||||
clearError,
|
||||
} = useInstances();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div data-testid="loading">{loading.toString()}</div>
|
||||
<div data-testid="error">{error || 'no-error'}</div>
|
||||
<div data-testid="error">{error || "no-error"}</div>
|
||||
<div data-testid="instances-count">{instances.length}</div>
|
||||
{instances.map(instance => (
|
||||
{instances.map((instance) => (
|
||||
<div key={instance.name} data-testid={`instance-${instance.name}`}>
|
||||
{instance.name}:{instance.running.toString()}
|
||||
</div>
|
||||
))}
|
||||
|
||||
|
||||
{/* Action buttons for testing with specific instances */}
|
||||
<button
|
||||
onClick={() => createInstance('new-instance', { model: 'test.gguf' })}
|
||||
<button
|
||||
onClick={() => createInstance("new-instance", { model: "test.gguf" })}
|
||||
data-testid="create-instance"
|
||||
>
|
||||
Create Instance
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateInstance('instance1', { model: 'updated.gguf' })}
|
||||
<button
|
||||
onClick={() => updateInstance("instance1", { model: "updated.gguf" })}
|
||||
data-testid="update-instance"
|
||||
>
|
||||
Update Instance
|
||||
</button>
|
||||
<button
|
||||
onClick={() => startInstance('instance2')}
|
||||
<button
|
||||
onClick={() => startInstance("instance2")}
|
||||
data-testid="start-instance"
|
||||
>
|
||||
Start Instance2
|
||||
</button>
|
||||
<button
|
||||
onClick={() => stopInstance('instance1')}
|
||||
<button
|
||||
onClick={() => stopInstance("instance1")}
|
||||
data-testid="stop-instance"
|
||||
>
|
||||
Stop Instance1
|
||||
</button>
|
||||
<button
|
||||
onClick={() => restartInstance('instance1')}
|
||||
<button
|
||||
onClick={() => restartInstance("instance1")}
|
||||
data-testid="restart-instance"
|
||||
>
|
||||
Restart Instance1
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteInstance('instance2')}
|
||||
<button
|
||||
onClick={() => deleteInstance("instance2")}
|
||||
data-testid="delete-instance"
|
||||
>
|
||||
Delete Instance2
|
||||
</button>
|
||||
<button
|
||||
onClick={clearError}
|
||||
data-testid="clear-error"
|
||||
>
|
||||
<button onClick={clearError} data-testid="clear-error">
|
||||
Clear Error
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function renderWithProvider(children: ReactNode) {
|
||||
return render(
|
||||
<InstancesProvider>
|
||||
{children}
|
||||
</InstancesProvider>
|
||||
)
|
||||
<AuthProvider>
|
||||
<InstancesProvider>{children}</InstancesProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('InstancesContext', () => {
|
||||
describe("InstancesContext", () => {
|
||||
const mockInstances: Instance[] = [
|
||||
{ name: 'instance1', running: true, options: { model: 'model1.gguf' } },
|
||||
{ name: 'instance2', running: false, options: { model: 'model2.gguf' } }
|
||||
]
|
||||
{ name: "instance1", running: true, options: { model: "model1.gguf" } },
|
||||
{ name: "instance2", running: false, options: { model: "model2.gguf" } },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.clearAllMocks();
|
||||
window.sessionStorage.setItem('llamactl_management_key', 'test-api-key-123');
|
||||
global.fetch = vi.fn(() => Promise.resolve(new Response(null, { status: 200 })));
|
||||
// Default successful API responses
|
||||
vi.mocked(instancesApi.list).mockResolvedValue(mockInstances)
|
||||
})
|
||||
vi.mocked(instancesApi.list).mockResolvedValue(mockInstances);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Initial Loading', () => {
|
||||
it('loads instances on mount', async () => {
|
||||
renderWithProvider(<TestComponent />)
|
||||
describe("Initial Loading", () => {
|
||||
it("loads instances on mount", async () => {
|
||||
renderWithProvider(<TestComponent />);
|
||||
|
||||
// Should start loading
|
||||
expect(screen.getByTestId('loading')).toHaveTextContent('true')
|
||||
|
||||
expect(screen.getByTestId("loading")).toHaveTextContent("true");
|
||||
|
||||
// Should fetch instances
|
||||
await waitFor(() => {
|
||||
expect(instancesApi.list).toHaveBeenCalledOnce()
|
||||
})
|
||||
expect(instancesApi.list).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
// Should display loaded instances
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('loading')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('instances-count')).toHaveTextContent('2')
|
||||
expect(screen.getByTestId('instance-instance1')).toHaveTextContent('instance1:true')
|
||||
expect(screen.getByTestId('instance-instance2')).toHaveTextContent('instance2:false')
|
||||
})
|
||||
})
|
||||
expect(screen.getByTestId("loading")).toHaveTextContent("false");
|
||||
expect(screen.getByTestId("instances-count")).toHaveTextContent("2");
|
||||
expect(screen.getByTestId("instance-instance1")).toHaveTextContent(
|
||||
"instance1:true"
|
||||
);
|
||||
expect(screen.getByTestId("instance-instance2")).toHaveTextContent(
|
||||
"instance2:false"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles API error during initial load', async () => {
|
||||
const errorMessage = 'Network error'
|
||||
vi.mocked(instancesApi.list).mockRejectedValue(new Error(errorMessage))
|
||||
it("handles API error during initial load", async () => {
|
||||
const errorMessage = "Network error";
|
||||
vi.mocked(instancesApi.list).mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
renderWithProvider(<TestComponent />)
|
||||
renderWithProvider(<TestComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('loading')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('error')).toHaveTextContent(errorMessage)
|
||||
expect(screen.getByTestId('instances-count')).toHaveTextContent('0')
|
||||
})
|
||||
})
|
||||
})
|
||||
expect(screen.getByTestId("loading")).toHaveTextContent("false");
|
||||
expect(screen.getByTestId("error")).toHaveTextContent(errorMessage);
|
||||
expect(screen.getByTestId("instances-count")).toHaveTextContent("0");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Create Instance', () => {
|
||||
it('creates instance and adds it to state', async () => {
|
||||
const newInstance: Instance = {
|
||||
name: 'new-instance',
|
||||
running: false,
|
||||
options: { model: 'test.gguf' }
|
||||
}
|
||||
vi.mocked(instancesApi.create).mockResolvedValue(newInstance)
|
||||
describe("Create Instance", () => {
|
||||
it("creates instance and adds it to state", async () => {
|
||||
const newInstance: Instance = {
|
||||
name: "new-instance",
|
||||
running: false,
|
||||
options: { model: "test.gguf" },
|
||||
};
|
||||
vi.mocked(instancesApi.create).mockResolvedValue(newInstance);
|
||||
|
||||
renderWithProvider(<TestComponent />)
|
||||
renderWithProvider(<TestComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('loading')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('instances-count')).toHaveTextContent('2')
|
||||
})
|
||||
expect(screen.getByTestId("loading")).toHaveTextContent("false");
|
||||
expect(screen.getByTestId("instances-count")).toHaveTextContent("2");
|
||||
});
|
||||
|
||||
screen.getByTestId('create-instance').click()
|
||||
screen.getByTestId("create-instance").click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(instancesApi.create).toHaveBeenCalledWith('new-instance', { model: 'test.gguf' })
|
||||
})
|
||||
expect(instancesApi.create).toHaveBeenCalledWith("new-instance", {
|
||||
model: "test.gguf",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('instances-count')).toHaveTextContent('3')
|
||||
expect(screen.getByTestId('instance-new-instance')).toHaveTextContent('new-instance:false')
|
||||
})
|
||||
})
|
||||
expect(screen.getByTestId("instances-count")).toHaveTextContent("3");
|
||||
expect(screen.getByTestId("instance-new-instance")).toHaveTextContent(
|
||||
"new-instance:false"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles create instance error without changing state', async () => {
|
||||
const errorMessage = 'Instance already exists'
|
||||
vi.mocked(instancesApi.create).mockRejectedValue(new Error(errorMessage))
|
||||
it("handles create instance error without changing state", async () => {
|
||||
const errorMessage = "Instance already exists";
|
||||
vi.mocked(instancesApi.create).mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
renderWithProvider(<TestComponent />)
|
||||
renderWithProvider(<TestComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('loading')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('instances-count')).toHaveTextContent('2')
|
||||
})
|
||||
expect(screen.getByTestId("loading")).toHaveTextContent("false");
|
||||
expect(screen.getByTestId("instances-count")).toHaveTextContent("2");
|
||||
});
|
||||
|
||||
screen.getByTestId('create-instance').click()
|
||||
screen.getByTestId("create-instance").click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error')).toHaveTextContent(errorMessage)
|
||||
})
|
||||
expect(screen.getByTestId("error")).toHaveTextContent(errorMessage);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('instances-count')).toHaveTextContent('2')
|
||||
expect(screen.queryByTestId('instance-new-instance')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
expect(screen.getByTestId("instances-count")).toHaveTextContent("2");
|
||||
expect(
|
||||
screen.queryByTestId("instance-new-instance")
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Update Instance', () => {
|
||||
it('updates instance and maintains it in state', async () => {
|
||||
const updatedInstance: Instance = {
|
||||
name: 'instance1',
|
||||
running: true,
|
||||
options: { model: 'updated.gguf' }
|
||||
}
|
||||
vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance)
|
||||
describe("Update Instance", () => {
|
||||
it("updates instance and maintains it in state", async () => {
|
||||
const updatedInstance: Instance = {
|
||||
name: "instance1",
|
||||
running: true,
|
||||
options: { model: "updated.gguf" },
|
||||
};
|
||||
vi.mocked(instancesApi.update).mockResolvedValue(updatedInstance);
|
||||
|
||||
renderWithProvider(<TestComponent />)
|
||||
renderWithProvider(<TestComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('loading')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('instances-count')).toHaveTextContent('2')
|
||||
})
|
||||
expect(screen.getByTestId("loading")).toHaveTextContent("false");
|
||||
expect(screen.getByTestId("instances-count")).toHaveTextContent("2");
|
||||
});
|
||||
|
||||
screen.getByTestId('update-instance').click()
|
||||
screen.getByTestId("update-instance").click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(instancesApi.update).toHaveBeenCalledWith('instance1', { model: 'updated.gguf' })
|
||||
})
|
||||
expect(instancesApi.update).toHaveBeenCalledWith("instance1", {
|
||||
model: "updated.gguf",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('instances-count')).toHaveTextContent('2')
|
||||
expect(screen.getByTestId('instance-instance1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
expect(screen.getByTestId("instances-count")).toHaveTextContent("2");
|
||||
expect(screen.getByTestId("instance-instance1")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Start/Stop Instance', () => {
|
||||
it('starts existing instance and updates its running state', async () => {
|
||||
vi.mocked(instancesApi.start).mockResolvedValue({} as Instance)
|
||||
describe("Start/Stop Instance", () => {
|
||||
it("starts existing instance and updates its running state", async () => {
|
||||
vi.mocked(instancesApi.start).mockResolvedValue({} as Instance);
|
||||
|
||||
renderWithProvider(<TestComponent />)
|
||||
renderWithProvider(<TestComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('loading')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId("loading")).toHaveTextContent("false");
|
||||
// instance2 starts as not running
|
||||
expect(screen.getByTestId('instance-instance2')).toHaveTextContent('instance2:false')
|
||||
})
|
||||
expect(screen.getByTestId("instance-instance2")).toHaveTextContent(
|
||||
"instance2:false"
|
||||
);
|
||||
});
|
||||
|
||||
// Start instance2 (button already configured to start instance2)
|
||||
screen.getByTestId('start-instance').click()
|
||||
screen.getByTestId("start-instance").click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(instancesApi.start).toHaveBeenCalledWith('instance2')
|
||||
expect(instancesApi.start).toHaveBeenCalledWith("instance2");
|
||||
// The running state should be updated to true
|
||||
expect(screen.getByTestId('instance-instance2')).toHaveTextContent('instance2:true')
|
||||
})
|
||||
})
|
||||
expect(screen.getByTestId("instance-instance2")).toHaveTextContent(
|
||||
"instance2:true"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('stops instance and updates running state to false', async () => {
|
||||
vi.mocked(instancesApi.stop).mockResolvedValue({} as Instance)
|
||||
it("stops instance and updates running state to false", async () => {
|
||||
vi.mocked(instancesApi.stop).mockResolvedValue({} as Instance);
|
||||
|
||||
renderWithProvider(<TestComponent />)
|
||||
renderWithProvider(<TestComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('loading')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId("loading")).toHaveTextContent("false");
|
||||
// instance1 starts as running
|
||||
expect(screen.getByTestId('instance-instance1')).toHaveTextContent('instance1:true')
|
||||
})
|
||||
expect(screen.getByTestId("instance-instance1")).toHaveTextContent(
|
||||
"instance1:true"
|
||||
);
|
||||
});
|
||||
|
||||
// Stop instance1 (button already configured to stop instance1)
|
||||
screen.getByTestId('stop-instance').click()
|
||||
screen.getByTestId("stop-instance").click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(instancesApi.stop).toHaveBeenCalledWith('instance1')
|
||||
expect(instancesApi.stop).toHaveBeenCalledWith("instance1");
|
||||
// The running state should be updated to false
|
||||
expect(screen.getByTestId('instance-instance1')).toHaveTextContent('instance1:false')
|
||||
})
|
||||
})
|
||||
expect(screen.getByTestId("instance-instance1")).toHaveTextContent(
|
||||
"instance1:false"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('handles start instance error', async () => {
|
||||
const errorMessage = 'Failed to start instance'
|
||||
vi.mocked(instancesApi.start).mockRejectedValue(new Error(errorMessage))
|
||||
it("handles start instance error", async () => {
|
||||
const errorMessage = "Failed to start instance";
|
||||
vi.mocked(instancesApi.start).mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
renderWithProvider(<TestComponent />)
|
||||
renderWithProvider(<TestComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('loading')).toHaveTextContent('false')
|
||||
})
|
||||
expect(screen.getByTestId("loading")).toHaveTextContent("false");
|
||||
});
|
||||
|
||||
screen.getByTestId('start-instance').click()
|
||||
screen.getByTestId("start-instance").click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error')).toHaveTextContent(errorMessage)
|
||||
})
|
||||
})
|
||||
})
|
||||
expect(screen.getByTestId("error")).toHaveTextContent(errorMessage);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Delete Instance', () => {
|
||||
it('deletes instance and removes it from state', async () => {
|
||||
vi.mocked(instancesApi.delete).mockResolvedValue(undefined)
|
||||
describe("Delete Instance", () => {
|
||||
it("deletes instance and removes it from state", async () => {
|
||||
vi.mocked(instancesApi.delete).mockResolvedValue(undefined);
|
||||
|
||||
renderWithProvider(<TestComponent />)
|
||||
renderWithProvider(<TestComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('loading')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('instances-count')).toHaveTextContent('2')
|
||||
expect(screen.getByTestId('instance-instance2')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByTestId("loading")).toHaveTextContent("false");
|
||||
expect(screen.getByTestId("instances-count")).toHaveTextContent("2");
|
||||
expect(screen.getByTestId("instance-instance2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
screen.getByTestId('delete-instance').click()
|
||||
screen.getByTestId("delete-instance").click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(instancesApi.delete).toHaveBeenCalledWith('instance2')
|
||||
})
|
||||
expect(instancesApi.delete).toHaveBeenCalledWith("instance2");
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('instances-count')).toHaveTextContent('1')
|
||||
expect(screen.queryByTestId('instance-instance2')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('instance-instance1')).toBeInTheDocument() // instance1 should still exist
|
||||
})
|
||||
})
|
||||
expect(screen.getByTestId("instances-count")).toHaveTextContent("1");
|
||||
expect(
|
||||
screen.queryByTestId("instance-instance2")
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("instance-instance1")).toBeInTheDocument(); // instance1 should still exist
|
||||
});
|
||||
});
|
||||
|
||||
it('handles delete instance error without changing state', async () => {
|
||||
const errorMessage = 'Instance is running'
|
||||
vi.mocked(instancesApi.delete).mockRejectedValue(new Error(errorMessage))
|
||||
it("handles delete instance error without changing state", async () => {
|
||||
const errorMessage = "Instance is running";
|
||||
vi.mocked(instancesApi.delete).mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
renderWithProvider(<TestComponent />)
|
||||
renderWithProvider(<TestComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('loading')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('instances-count')).toHaveTextContent('2')
|
||||
})
|
||||
expect(screen.getByTestId("loading")).toHaveTextContent("false");
|
||||
expect(screen.getByTestId("instances-count")).toHaveTextContent("2");
|
||||
});
|
||||
|
||||
screen.getByTestId('delete-instance').click()
|
||||
screen.getByTestId("delete-instance").click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error')).toHaveTextContent(errorMessage)
|
||||
})
|
||||
expect(screen.getByTestId("error")).toHaveTextContent(errorMessage);
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('instances-count')).toHaveTextContent('2')
|
||||
expect(screen.getByTestId('instance-instance2')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
expect(screen.getByTestId("instances-count")).toHaveTextContent("2");
|
||||
expect(screen.getByTestId("instance-instance2")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Management', () => {
|
||||
it('clears error when clearError is called', async () => {
|
||||
const errorMessage = 'Test error'
|
||||
vi.mocked(instancesApi.list).mockRejectedValue(new Error(errorMessage))
|
||||
describe("Error Management", () => {
|
||||
it("clears error when clearError is called", async () => {
|
||||
const errorMessage = "Test error";
|
||||
vi.mocked(instancesApi.list).mockRejectedValue(new Error(errorMessage));
|
||||
|
||||
renderWithProvider(<TestComponent />)
|
||||
renderWithProvider(<TestComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error')).toHaveTextContent(errorMessage)
|
||||
})
|
||||
expect(screen.getByTestId("error")).toHaveTextContent(errorMessage);
|
||||
});
|
||||
|
||||
screen.getByTestId('clear-error').click()
|
||||
screen.getByTestId("clear-error").click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('error')).toHaveTextContent('no-error')
|
||||
})
|
||||
})
|
||||
})
|
||||
expect(screen.getByTestId("error")).toHaveTextContent("no-error");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('State Consistency', () => {
|
||||
it('maintains consistent state during multiple operations', async () => {
|
||||
describe("State Consistency", () => {
|
||||
it("maintains consistent state during multiple operations", async () => {
|
||||
// Test that operations don't interfere with each other
|
||||
const newInstance: Instance = {
|
||||
name: 'new-instance',
|
||||
running: false,
|
||||
options: {}
|
||||
}
|
||||
vi.mocked(instancesApi.create).mockResolvedValue(newInstance)
|
||||
vi.mocked(instancesApi.start).mockResolvedValue({} as Instance)
|
||||
const newInstance: Instance = {
|
||||
name: "new-instance",
|
||||
running: false,
|
||||
options: {},
|
||||
};
|
||||
vi.mocked(instancesApi.create).mockResolvedValue(newInstance);
|
||||
vi.mocked(instancesApi.start).mockResolvedValue({} as Instance);
|
||||
|
||||
renderWithProvider(<TestComponent />)
|
||||
renderWithProvider(<TestComponent />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('loading')).toHaveTextContent('false')
|
||||
expect(screen.getByTestId('instances-count')).toHaveTextContent('2')
|
||||
})
|
||||
expect(screen.getByTestId("loading")).toHaveTextContent("false");
|
||||
expect(screen.getByTestId("instances-count")).toHaveTextContent("2");
|
||||
});
|
||||
|
||||
// Create new instance
|
||||
screen.getByTestId('create-instance').click()
|
||||
screen.getByTestId("create-instance").click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('instances-count')).toHaveTextContent('3')
|
||||
})
|
||||
expect(screen.getByTestId("instances-count")).toHaveTextContent("3");
|
||||
});
|
||||
|
||||
// Start an instance (this should not affect the count)
|
||||
screen.getByTestId('start-instance').click()
|
||||
screen.getByTestId("start-instance").click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(instancesApi.start).toHaveBeenCalled()
|
||||
expect(screen.getByTestId('instances-count')).toHaveTextContent('3') // Still 3
|
||||
expect(instancesApi.start).toHaveBeenCalled();
|
||||
expect(screen.getByTestId("instances-count")).toHaveTextContent("3"); // Still 3
|
||||
// But the running state should change
|
||||
expect(screen.getByTestId('instance-instance2')).toHaveTextContent('instance2:true')
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
expect(screen.getByTestId("instance-instance2")).toHaveTextContent(
|
||||
"instance2:true"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,18 +10,31 @@ async function apiCall<T>(
|
||||
): Promise<T> {
|
||||
const url = `${API_BASE}${endpoint}`;
|
||||
|
||||
// Prepare headers
|
||||
const headers: HeadersInit = {
|
||||
// Get auth token from sessionStorage (same as AuthContext)
|
||||
const storedKey = sessionStorage.getItem('llamactl_management_key');
|
||||
|
||||
// Prepare headers with auth
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
// Add auth header if available
|
||||
if (storedKey) {
|
||||
headers['Authorization'] = `Bearer ${storedKey}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
// Handle authentication errors
|
||||
if (response.status === 401) {
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
// Try to get error message from response
|
||||
let errorMessage = `HTTP ${response.status}`;
|
||||
@@ -47,7 +60,7 @@ async function apiCall<T>(
|
||||
const text = await response.text();
|
||||
return text as T;
|
||||
} else {
|
||||
const data = await response.json();
|
||||
const data = await response.json() as T;
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -121,4 +134,7 @@ export const instancesApi = {
|
||||
const params = lines ? `?lines=${lines}` : "";
|
||||
return apiCall<string>(`/instances/${name}/logs${params}`, {}, "text");
|
||||
},
|
||||
|
||||
// GET /instances/{name}/proxy/health
|
||||
getHealth: (name: string) => apiCall<any>(`/instances/${name}/proxy/health`),
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type HealthStatus } from '@/types/instance'
|
||||
import { instancesApi } from '@/lib/api'
|
||||
|
||||
type HealthCallback = (health: HealthStatus) => void
|
||||
|
||||
@@ -8,31 +9,33 @@ class HealthService {
|
||||
|
||||
async checkHealth(instanceName: string): Promise<HealthStatus> {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/instances/${instanceName}/proxy/health`)
|
||||
await instancesApi.getHealth(instanceName)
|
||||
|
||||
if (response.status === 200) {
|
||||
return {
|
||||
status: 'ok',
|
||||
lastChecked: new Date()
|
||||
return {
|
||||
status: 'ok',
|
||||
lastChecked: new Date()
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
// Check if it's a 503 (service unavailable - loading)
|
||||
if (error.message.includes('503')) {
|
||||
return {
|
||||
status: 'loading',
|
||||
message: 'Instance is starting up',
|
||||
lastChecked: new Date()
|
||||
}
|
||||
}
|
||||
} else if (response.status === 503) {
|
||||
const data = await response.json()
|
||||
return {
|
||||
status: 'loading',
|
||||
message: data.error.message,
|
||||
lastChecked: new Date()
|
||||
}
|
||||
} else {
|
||||
|
||||
return {
|
||||
status: 'error',
|
||||
message: `HTTP ${response.status}`,
|
||||
message: error.message,
|
||||
lastChecked: new Date()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
return {
|
||||
status: 'error',
|
||||
message: 'Network error',
|
||||
message: 'Unknown error',
|
||||
lastChecked: new Date()
|
||||
}
|
||||
}
|
||||
@@ -82,7 +85,7 @@ class HealthService {
|
||||
}, 60000)
|
||||
|
||||
this.intervals.set(instanceName, interval)
|
||||
}, 2000)
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
private stopHealthCheck(instanceName: string): void {
|
||||
|
||||
@@ -3,11 +3,14 @@ import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import { InstancesProvider } from './contexts/InstancesContext'
|
||||
import './index.css'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<InstancesProvider>
|
||||
<App />
|
||||
</InstancesProvider>
|
||||
<AuthProvider>
|
||||
<InstancesProvider>
|
||||
<App />
|
||||
</InstancesProvider>
|
||||
</AuthProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
Reference in New Issue
Block a user