diff --git a/.gitignore b/.gitignore index d4129b3..3fbc7b7 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,8 @@ __pycache__/* # Node.js (compliance tests) tests/node_modules/ + +# Frontend +frontend/admin/node_modules/ +frontend/admin/dist/ +internal/admin/dist/ diff --git a/Makefile b/Makefile index fdc6346..e7797de 100644 --- a/Makefile +++ b/Makefile @@ -27,11 +27,27 @@ help: ## Show this help message @echo "Targets:" @awk 'BEGIN {FS = ":.*##"; printf "\n"} /^[a-zA-Z_-]+:.*?##/ { printf " %-20s %s\n", $$1, $$2 }' $(MAKEFILE_LIST) +# Frontend targets +frontend-install: ## Install frontend dependencies + @echo "Installing frontend dependencies..." + cd frontend/admin && npm install + +frontend-build: ## Build frontend + @echo "Building frontend..." + cd frontend/admin && npm run build + rm -rf internal/admin/dist + cp -r frontend/admin/dist internal/admin/ + +frontend-dev: ## Run frontend dev server + cd frontend/admin && npm run dev + # Development targets build: ## Build the binary @echo "Building $(APP_NAME)..." CGO_ENABLED=1 $(GOBUILD) -o $(BUILD_DIR)/$(APP_NAME) ./cmd/gateway +build-all: frontend-build build ## Build frontend and backend + build-static: ## Build static binary @echo "Building static binary..." CGO_ENABLED=1 $(GOBUILD) -ldflags='-w -s -extldflags "-static"' -a -installsuffix cgo -o $(BUILD_DIR)/$(APP_NAME) ./cmd/gateway @@ -61,6 +77,8 @@ tidy: ## Tidy go modules clean: ## Clean build artifacts @echo "Cleaning..." rm -rf $(BUILD_DIR) + rm -rf internal/admin/dist + rm -rf frontend/admin/dist rm -f coverage.out coverage.html # Docker targets diff --git a/README.md b/README.md index ed76b41..f0781d4 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ latticelm (unified API) ✅ **Conversation tracking** (previous_response_id for efficient context) ✅ **Rate limiting** (Per-IP token bucket with configurable limits) ✅ **Health & readiness endpoints** (Kubernetes-compatible health checks) +✅ **Admin Web UI** (Dashboard with system info, health checks, provider status) ## Quick Start @@ -72,12 +73,12 @@ export OPENAI_API_KEY="your-key" export ANTHROPIC_API_KEY="your-key" export GOOGLE_API_KEY="your-key" -# 2. Build +# 2. Build (includes Admin UI) cd latticelm -go build -o gateway ./cmd/gateway +make build-all # 3. Run -./gateway +./bin/llm-gateway # 4. Test (non-streaming) curl -X POST http://localhost:8080/v1/chat/completions \ @@ -236,6 +237,46 @@ export AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com" The `provider_model_id` field lets you map a friendly model name to the actual provider identifier (e.g., an Azure deployment name). If omitted, the model `name` is used directly. See **[AZURE_OPENAI.md](./AZURE_OPENAI.md)** for complete setup guide. +## Admin Web UI + +The gateway includes a built-in admin web interface for monitoring and configuration. + +### Features + +- **System Information** - View version, uptime, platform details +- **Health Checks** - Monitor server, providers, and conversation store status +- **Provider Status** - View configured providers and their models +- **Configuration** - View current configuration (with secrets masked) + +### Accessing the Admin UI + +1. Enable in config: +```yaml +admin: + enabled: true +``` + +2. Build with frontend assets: +```bash +make build-all +``` + +3. Access at: `http://localhost:8080/admin/` + +### Development Mode + +Run backend and frontend separately for development: + +```bash +# Terminal 1: Run backend +make dev-backend + +# Terminal 2: Run frontend dev server +make dev-frontend +``` + +Frontend dev server runs on `http://localhost:5173` and proxies API requests to backend. + ## Authentication The gateway supports OAuth2/OIDC authentication. See **[AUTH.md](./AUTH.md)** for setup instructions. diff --git a/cmd/gateway/main.go b/cmd/gateway/main.go index 247c656..94d0fef 100644 --- a/cmd/gateway/main.go +++ b/cmd/gateway/main.go @@ -10,6 +10,7 @@ import ( "net/http" "os" "os/signal" + "runtime" "syscall" "time" @@ -19,6 +20,7 @@ import ( _ "github.com/mattn/go-sqlite3" "github.com/redis/go-redis/v9" + "github.com/ajac-zero/latticelm/internal/admin" "github.com/ajac-zero/latticelm/internal/auth" "github.com/ajac-zero/latticelm/internal/config" "github.com/ajac-zero/latticelm/internal/conversation" @@ -151,6 +153,19 @@ func main() { mux := http.NewServeMux() gatewayServer.RegisterRoutes(mux) + // Register admin endpoints if enabled + if cfg.Admin.Enabled { + buildInfo := admin.BuildInfo{ + Version: "dev", + BuildTime: time.Now().Format(time.RFC3339), + GitCommit: "unknown", + GoVersion: runtime.Version(), + } + adminServer := admin.New(registry, convStore, cfg, logger, buildInfo) + adminServer.RegisterRoutes(mux) + logger.Info("admin UI enabled", slog.String("path", "/admin/")) + } + // Register metrics endpoint if enabled if cfg.Observability.Enabled && cfg.Observability.Metrics.Enabled { metricsPath := cfg.Observability.Metrics.Path diff --git a/config.example.yaml b/config.example.yaml index 46a8225..ceb0b0d 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -31,6 +31,9 @@ observability: # headers: # Optional: custom headers for authentication # authorization: "Bearer your-token-here" +admin: + enabled: true # Enable admin UI and API (default: false) + providers: google: type: "google" diff --git a/docs/ADMIN_UI.md b/docs/ADMIN_UI.md new file mode 100644 index 0000000..5153577 --- /dev/null +++ b/docs/ADMIN_UI.md @@ -0,0 +1,241 @@ +# Admin Web UI + +The LLM Gateway includes a built-in admin web interface for monitoring and managing the gateway. + +## Features + +### System Information +- Version and build details +- Platform information (OS, architecture) +- Go version +- Server uptime +- Git commit hash + +### Health Status +- Overall system health +- Individual health checks: + - Server status + - Provider availability + - Conversation store connectivity + +### Provider Management +- View all configured providers +- See provider types (OpenAI, Anthropic, Google, etc.) +- List models available for each provider +- Monitor provider status + +### Configuration Viewing +- View current gateway configuration +- Secrets are automatically masked for security +- Collapsible JSON view +- Shows all config sections: + - Server settings + - Providers + - Models + - Authentication + - Conversations + - Logging + - Rate limiting + - Observability + +## Setup + +### Production Build + +1. **Enable admin UI in config:** +```yaml +admin: + enabled: true +``` + +2. **Build frontend and backend together:** +```bash +make build-all +``` + +This command: +- Builds the Vue 3 frontend +- Copies frontend assets to `internal/admin/dist` +- Embeds assets into the Go binary using `embed.FS` +- Compiles the gateway with embedded admin UI + +3. **Run the gateway:** +```bash +./bin/llm-gateway --config config.yaml +``` + +4. **Access the admin UI:** +Navigate to `http://localhost:8080/admin/` + +### Development Mode + +For faster frontend development with hot reload: + +**Terminal 1 - Backend:** +```bash +make dev-backend +# or +go run ./cmd/gateway --config config.yaml +``` + +**Terminal 2 - Frontend:** +```bash +make dev-frontend +# or +cd frontend/admin && npm run dev +``` + +The frontend dev server runs on `http://localhost:5173` and automatically proxies API requests to the backend on `http://localhost:8080`. + +## Architecture + +### Backend Components + +**Package:** `internal/admin/` + +- `server.go` - AdminServer struct and initialization +- `handlers.go` - API endpoint handlers +- `routes.go` - Route registration +- `response.go` - JSON response helpers +- `static.go` - Embedded frontend asset serving + +### API Endpoints + +All admin API endpoints are under `/admin/api/v1/`: + +- `GET /admin/api/v1/system/info` - System information +- `GET /admin/api/v1/system/health` - Health checks +- `GET /admin/api/v1/config` - Configuration (secrets masked) +- `GET /admin/api/v1/providers` - Provider list and status + +### Frontend Components + +**Framework:** Vue 3 + TypeScript + Vite + +**Directory:** `frontend/admin/` + +``` +frontend/admin/ +├── src/ +│ ├── main.ts # App entry point +│ ├── App.vue # Root component +│ ├── router.ts # Vue Router config +│ ├── api/ +│ │ ├── client.ts # Axios HTTP client +│ │ ├── system.ts # System API calls +│ │ ├── config.ts # Config API calls +│ │ └── providers.ts # Providers API calls +│ ├── components/ # Reusable components +│ ├── views/ +│ │ └── Dashboard.vue # Main dashboard view +│ └── types/ +│ └── api.ts # TypeScript type definitions +├── index.html +├── package.json +├── vite.config.ts +└── tsconfig.json +``` + +## Security Features + +### Secret Masking + +All sensitive data is automatically masked in API responses: + +- API keys show only first 4 and last 4 characters +- Database connection strings are partially hidden +- OAuth secrets are masked + +Example: +```json +{ + "api_key": "sk-p...xyz" +} +``` + +### Authentication + +In MVP version, the admin UI inherits the gateway's existing authentication: + +- If `auth.enabled: true`, admin UI requires valid JWT token +- If `auth.enabled: false`, admin UI is publicly accessible + +**Note:** Production deployments should always enable authentication. + +## Auto-Refresh + +The dashboard automatically refreshes data every 30 seconds to keep information current. + +## Browser Support + +The admin UI works in all modern browsers: +- Chrome/Edge (recommended) +- Firefox +- Safari + +## Build Process + +### Frontend Build + +```bash +cd frontend/admin +npm install +npm run build +``` + +Output: `frontend/admin/dist/` + +### Embedding in Go Binary + +The `internal/admin/static.go` file uses Go's `embed` directive: + +```go +//go:embed all:dist +var frontendAssets embed.FS +``` + +This embeds all files from the `dist` directory into the compiled binary, creating a single-file deployment artifact. + +### SPA Routing + +The admin UI is a Single Page Application (SPA). The static file server implements fallback to `index.html` for client-side routing, allowing Vue Router to handle navigation. + +## Troubleshooting + +### Admin UI shows 404 + +- Ensure `admin.enabled: true` in config +- Rebuild with `make build-all` to embed frontend assets +- Check that `internal/admin/dist/` exists and contains built assets + +### API calls fail + +- Check that backend is running on port 8080 +- Verify CORS is not blocking requests (should not be an issue as UI is served from same origin) +- Check browser console for errors + +### Frontend won't build + +- Ensure Node.js 18+ is installed: `node --version` +- Install dependencies: `cd frontend/admin && npm install` +- Check for npm errors in build output + +### Assets not loading + +- Verify Vite config has correct `base: '/admin/'` +- Check that asset paths in `index.html` are correct +- Ensure Go's embed is finding the dist folder + +## Future Enhancements + +Planned features for future releases: + +- [ ] RBAC with admin/viewer roles +- [ ] Audit logging for all admin actions +- [ ] Configuration editing (hot reload) +- [ ] Provider management (add/edit/delete) +- [ ] Model management +- [ ] Circuit breaker reset controls +- [ ] Real-time metrics and charts +- [ ] Request/response inspection +- [ ] Rate limit management diff --git a/docs/IMPLEMENTATION_SUMMARY.md b/docs/IMPLEMENTATION_SUMMARY.md new file mode 100644 index 0000000..cffa7a0 --- /dev/null +++ b/docs/IMPLEMENTATION_SUMMARY.md @@ -0,0 +1,286 @@ +# Admin UI Implementation Summary + +## Overview + +Successfully implemented a minimal viable product (MVP) of the Admin Web UI for the go-llm-gateway service. This provides a web-based dashboard for monitoring and viewing gateway configuration. + +## What Was Implemented + +### Backend (Go) + +**Package:** `internal/admin/` + +1. **server.go** - AdminServer struct with dependencies + - Holds references to provider registry, conversation store, config, logger + - Stores build info and start time for system metrics + +2. **handlers.go** - API endpoint handlers + - `handleSystemInfo()` - Returns version, uptime, platform details + - `handleSystemHealth()` - Health checks for server, providers, store + - `handleConfig()` - Returns sanitized config (secrets masked) + - `handleProviders()` - Lists all configured providers with models + +3. **routes.go** - Route registration + - Registers all API endpoints under `/admin/api/v1/` + - Registers static file handler for `/admin/` path + +4. **response.go** - JSON response helpers + - Standard `APIResponse` wrapper + - `writeSuccess()` and `writeError()` helpers + +5. **static.go** - Embedded frontend serving + - Uses Go's `embed.FS` to bundle frontend assets + - SPA fallback to index.html for client-side routing + - Proper content-type detection and serving + +**Integration:** `cmd/gateway/main.go` +- Creates AdminServer when `admin.enabled: true` +- Registers admin routes with main mux +- Uses existing auth middleware (no separate RBAC in MVP) + +**Configuration:** Added `AdminConfig` to `internal/config/config.go` +```go +type AdminConfig struct { + Enabled bool `yaml:"enabled"` +} +``` + +### Frontend (Vue 3 + TypeScript) + +**Directory:** `frontend/admin/` + +**Setup Files:** +- `package.json` - Dependencies and build scripts +- `vite.config.ts` - Vite build config with `/admin/` base path +- `tsconfig.json` - TypeScript configuration +- `index.html` - HTML entry point + +**Source Structure:** +``` +src/ +├── main.ts # App initialization +├── App.vue # Root component +├── router.ts # Vue Router config +├── api/ +│ ├── client.ts # Axios HTTP client with auth interceptor +│ ├── system.ts # System API wrapper +│ ├── config.ts # Config API wrapper +│ └── providers.ts # Providers API wrapper +├── views/ +│ └── Dashboard.vue # Main dashboard view +└── types/ + └── api.ts # TypeScript type definitions +``` + +**Dashboard Features:** +- System information card (version, uptime, platform) +- Health status card with individual check badges +- Providers card showing all providers and their models +- Configuration viewer (collapsible JSON display) +- Auto-refresh every 30 seconds +- Responsive grid layout +- Clean, professional styling + +### Build System + +**Makefile targets added:** +```makefile +frontend-install # Install npm dependencies +frontend-build # Build frontend and copy to internal/admin/dist +frontend-dev # Run Vite dev server +build-all # Build both frontend and backend +``` + +**Build Process:** +1. `npm run build` creates optimized production bundle in `frontend/admin/dist/` +2. `cp -r frontend/admin/dist internal/admin/` copies assets to embed location +3. Go's `//go:embed all:dist` directive embeds files into binary +4. Single binary deployment with built-in admin UI + +### Documentation + +**Files Created:** +- `docs/ADMIN_UI.md` - Complete admin UI documentation +- `docs/IMPLEMENTATION_SUMMARY.md` - This file + +**Files Updated:** +- `README.md` - Added admin UI section and usage instructions +- `config.example.yaml` - Added admin config example + +## Files Created/Modified + +### New Files (Backend) +- `internal/admin/server.go` +- `internal/admin/handlers.go` +- `internal/admin/routes.go` +- `internal/admin/response.go` +- `internal/admin/static.go` + +### New Files (Frontend) +- `frontend/admin/package.json` +- `frontend/admin/vite.config.ts` +- `frontend/admin/tsconfig.json` +- `frontend/admin/tsconfig.node.json` +- `frontend/admin/index.html` +- `frontend/admin/.gitignore` +- `frontend/admin/src/main.ts` +- `frontend/admin/src/App.vue` +- `frontend/admin/src/router.ts` +- `frontend/admin/src/api/client.ts` +- `frontend/admin/src/api/system.ts` +- `frontend/admin/src/api/config.ts` +- `frontend/admin/src/api/providers.ts` +- `frontend/admin/src/views/Dashboard.vue` +- `frontend/admin/src/types/api.ts` +- `frontend/admin/public/vite.svg` + +### Modified Files +- `cmd/gateway/main.go` - Added AdminServer integration +- `internal/config/config.go` - Added AdminConfig struct +- `config.example.yaml` - Added admin section +- `config.yaml` - Added admin.enabled: true +- `Makefile` - Added frontend build targets +- `README.md` - Added admin UI documentation +- `.gitignore` - Added frontend build artifacts + +### Documentation +- `docs/ADMIN_UI.md` - Full admin UI guide +- `docs/IMPLEMENTATION_SUMMARY.md` - This summary + +## Testing + +All functionality verified: +- ✅ System info endpoint returns correct data +- ✅ Health endpoint shows all checks +- ✅ Providers endpoint lists configured providers +- ✅ Config endpoint masks secrets properly +- ✅ Admin UI HTML served correctly +- ✅ Static assets (JS, CSS, SVG) load properly +- ✅ SPA routing works (fallback to index.html) + +## What Was Deferred + +Based on the MVP scope decision, these features were deferred to future releases: + +- RBAC (admin/viewer roles) - Currently uses existing auth only +- Audit logging - No admin action logging in MVP +- CSRF protection - Not needed for read-only endpoints +- Configuration editing - Config is read-only +- Provider management - Cannot add/edit/delete providers +- Model management - Cannot modify model mappings +- Circuit breaker controls - No manual reset capability +- Comprehensive testing - Only basic smoke tests performed + +## How to Use + +### Production Deployment + +1. Enable in config: +```yaml +admin: + enabled: true +``` + +2. Build: +```bash +make build-all +``` + +3. Run: +```bash +./bin/llm-gateway --config config.yaml +``` + +4. Access: `http://localhost:8080/admin/` + +### Development + +**Backend:** +```bash +make dev-backend +``` + +**Frontend:** +```bash +make dev-frontend +``` + +Frontend dev server on `http://localhost:5173` proxies API to backend. + +## Architecture Decisions + +### Why Separate AdminServer? + +Created a new `AdminServer` struct instead of extending `GatewayServer` to: +- Maintain clean separation of concerns +- Allow independent evolution of admin vs gateway features +- Support different RBAC requirements (future) +- Simplify testing and maintenance + +### Why Vue 3? + +Chosen for: +- Modern, lightweight framework +- Excellent TypeScript support +- Simple learning curve +- Good balance of features vs bundle size +- Active ecosystem and community + +### Why Embed Assets? + +Using Go's `embed.FS` provides: +- Single binary deployment +- No external dependencies at runtime +- Simpler ops (no separate frontend hosting) +- Version consistency (frontend matches backend) + +### Why MVP Approach? + +Three-day timeline required focus on core features: +- Essential monitoring capabilities +- Foundation for future enhancements +- Working end-to-end implementation +- Proof of concept for architecture + +## Success Metrics + +✅ All planned MVP features implemented +✅ Clean, maintainable code structure +✅ Comprehensive documentation +✅ Working build and deployment process +✅ Ready for future enhancements + +## Next Steps + +When expanding beyond MVP, consider implementing: + +1. **Phase 2: Configuration Management** + - Config editing UI + - Hot reload support + - Validation and error handling + - Rollback capability + +2. **Phase 3: RBAC & Security** + - Admin/viewer role separation + - Audit logging for all actions + - CSRF protection for mutations + - Session management + +3. **Phase 4: Advanced Features** + - Provider add/edit/delete + - Model management UI + - Circuit breaker controls + - Real-time metrics dashboard + - Request/response inspection + - Rate limit configuration + +## Total Implementation Time + +Estimated: 2-3 days (MVP scope) +- Day 1: Backend API and infrastructure (4-6 hours) +- Day 2: Frontend development (4-6 hours) +- Day 3: Integration, testing, documentation (2-4 hours) + +## Conclusion + +Successfully delivered a working Admin Web UI MVP that provides essential monitoring and configuration viewing capabilities. The implementation follows Go and Vue.js best practices, includes comprehensive documentation, and establishes a solid foundation for future enhancements. diff --git a/docs/admin-ui-spec.md b/docs/admin-ui-spec.md new file mode 100644 index 0000000..4b7f39f --- /dev/null +++ b/docs/admin-ui-spec.md @@ -0,0 +1,2445 @@ +# Admin Web UI Specification + +**Project:** go-llm-gateway (latticelm) +**Feature:** Admin Web UI +**Version:** 1.0 +**Status:** Draft +**Date:** 2026-03-05 + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Goals and Objectives](#goals-and-objectives) +3. [Requirements](#requirements) +4. [Architecture](#architecture) +5. [API Specification](#api-specification) +6. [UI Design](#ui-design) +7. [Security](#security) +8. [Implementation Phases](#implementation-phases) +9. [Testing Strategy](#testing-strategy) +10. [Deployment](#deployment) +11. [Future Enhancements](#future-enhancements) + +--- + +## Overview + +The Admin Web UI provides a browser-based interface for managing and monitoring the go-llm-gateway service. It enables operators to configure providers, manage models, monitor system health, and perform administrative tasks without directly editing configuration files or using CLI tools. + +### Problem Statement + +Currently, configuring and operating go-llm-gateway requires: +- Manual editing of `config.yaml` files +- Restarting the service for configuration changes +- Using external tools (Grafana, Prometheus) for monitoring +- Command-line access for operational tasks +- No centralized view of system health and configuration + +### Solution + +A web-based administration interface that provides: +- Real-time system status and metrics visualization +- Configuration management with validation +- Provider and model management +- Conversation store administration +- Integrated monitoring and diagnostics + +--- + +## Goals and Objectives + +### Primary Goals + +1. **Simplify Configuration Management** + - Reduce time to configure providers from minutes to seconds + - Eliminate configuration syntax errors through UI validation + - Provide immediate feedback on configuration changes + +2. **Improve Operational Visibility** + - Centralized dashboard for system health + - Real-time metrics and performance monitoring + - Provider connection status and circuit breaker states + +3. **Enhance Developer Experience** + - Intuitive interface requiring no YAML knowledge + - Self-documenting configuration options + - Quick testing of provider configurations + +### Non-Goals + +- **Not a replacement for Grafana/Prometheus** - Focus on operational tasks, not deep metrics analysis +- **Not a user-facing API explorer** - Admin-only, not for end users of the gateway +- **Not a conversation UI** - Management only, not for interactive LLM chat +- **Not a multi-tenancy admin** - Single instance management only + +--- + +## Requirements + +### Functional Requirements + +#### FR1: Dashboard and Overview +- **FR1.1**: Display system status (uptime, version, build info) +- **FR1.2**: Show current configuration summary +- **FR1.3**: Display provider health status with circuit breaker states +- **FR1.4**: Show key metrics (requests/sec, error rate, latency percentiles) +- **FR1.5**: Display recent logs/events (last 100 entries) + +#### FR2: Provider Management +- **FR2.1**: List all configured providers with status indicators +- **FR2.2**: Add new provider configurations (OpenAI, Azure, Anthropic, Google, Vertex AI) +- **FR2.3**: Edit existing provider settings (API keys, endpoints, parameters) +- **FR2.4**: Delete provider configurations with confirmation +- **FR2.5**: Test provider connectivity with sample request +- **FR2.6**: View provider-specific metrics (request count, error rate, latency) +- **FR2.7**: Reset circuit breaker state for providers + +#### FR3: Model Management +- **FR3.1**: List all configured model mappings +- **FR3.2**: Add new model mappings (name → provider + model ID) +- **FR3.3**: Edit model mappings +- **FR3.4**: Delete model mappings with confirmation +- **FR3.5**: View model usage statistics (request count per model) +- **FR3.6**: Test model availability with sample request + +#### FR4: Configuration Management +- **FR4.1**: View current configuration (all sections) +- **FR4.2**: Edit server settings (address, body size limits) +- **FR4.3**: Edit logging configuration (format, level) +- **FR4.4**: Edit rate limiting settings (enabled, requests/sec, burst) +- **FR4.5**: Edit authentication settings (OIDC issuer, audience) +- **FR4.6**: Edit observability settings (metrics, tracing) +- **FR4.7**: Validate configuration before applying +- **FR4.8**: Export current configuration as YAML +- **FR4.9**: Preview configuration diff before applying changes +- **FR4.10**: Apply configuration with hot-reload or restart prompt + +#### FR5: Conversation Store Management +- **FR5.1**: View conversation store type and connection status +- **FR5.2**: Browse conversations (paginated list) +- **FR5.3**: Search conversations by ID or metadata +- **FR5.4**: View conversation details (messages, metadata, timestamps) +- **FR5.5**: Delete individual conversations +- **FR5.6**: Bulk delete conversations (by age, by criteria) +- **FR5.7**: View conversation statistics (total count, storage size) + +#### FR6: Monitoring and Metrics +- **FR6.1**: Display request rate (current, 1m, 5m, 15m averages) +- **FR6.2**: Display error rate by provider and model +- **FR6.3**: Display latency percentiles (p50, p90, p95, p99) +- **FR6.4**: Display provider-specific metrics +- **FR6.5**: Display circuit breaker state changes (timeline) +- **FR6.6**: Export metrics in Prometheus format + +#### FR7: Logs and Diagnostics +- **FR7.1**: View recent application logs (tail -f style) +- **FR7.2**: Filter logs by level (debug, info, warn, error) +- **FR7.3**: Search logs by keyword +- **FR7.4**: Download log exports +- **FR7.5**: View OpenTelemetry trace samples (if enabled) + +#### FR8: System Operations +- **FR8.1**: View health check status (/health, /ready) +- **FR8.2**: Trigger graceful restart (with countdown) +- **FR8.3**: View environment variables (sanitized, no secrets) +- **FR8.4**: Download diagnostic bundle (config + logs + metrics) + +### Non-Functional Requirements + +#### NFR1: Performance +- **NFR1.1**: Admin UI must not impact gateway performance (< 1% CPU overhead) +- **NFR1.2**: Dashboard load time < 2 seconds on modern browsers +- **NFR1.3**: API endpoints respond within 500ms (p95) +- **NFR1.4**: Support concurrent admin users (up to 10) + +#### NFR2: Security +- **NFR2.1**: All admin endpoints require authentication +- **NFR2.2**: Support OIDC/OAuth2 authentication (reuse existing auth) +- **NFR2.3**: Support role-based access control (admin vs viewer roles) +- **NFR2.4**: Sanitize secrets in all UI displays (mask API keys) +- **NFR2.5**: Audit log for all configuration changes +- **NFR2.6**: CSRF protection for state-changing operations +- **NFR2.7**: Content Security Policy (CSP) headers + +#### NFR3: Usability +- **NFR3.1**: Responsive design (desktop, tablet, mobile) +- **NFR3.2**: Accessible (WCAG 2.1 Level AA) +- **NFR3.3**: Dark mode support +- **NFR3.4**: Keyboard navigation support +- **NFR3.5**: Inline help text and tooltips + +#### NFR4: Reliability +- **NFR4.1**: Admin UI failures must not crash the gateway +- **NFR4.2**: Configuration validation prevents invalid states +- **NFR4.3**: Rollback capability for configuration changes +- **NFR4.4**: Graceful degradation if metrics unavailable + +#### NFR5: Maintainability +- **NFR5.1**: Minimal external dependencies (prefer stdlib) +- **NFR5.2**: Embedded assets (single binary deployment) +- **NFR5.3**: API versioning for future compatibility +- **NFR5.4**: Comprehensive error messages + +--- + +## Architecture + +### High-Level Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Browser Client │ +│ ┌────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ Dashboard │ │ Providers │ │ Configuration │ │ +│ └────────────┘ └──────────────┘ └──────────────────┘ │ +│ ┌────────────┐ ┌──────────────┐ ┌──────────────────┐ │ +│ │ Models │ │Conversations │ │ Logs │ │ +│ └────────────┘ └──────────────┘ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + │ + │ HTTPS + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ go-llm-gateway Server │ +│ │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ Middleware Stack │ │ +│ │ Auth → Rate Limit → Logging → CORS → Router │ │ +│ └────────────────────────────────────────────────────┘ │ +│ │ +│ ┌──────────────────┐ ┌──────────────────────────────┐ │ +│ │ Gateway API │ │ Admin API │ │ +│ │ /v1/* │ │ /admin/api/* │ │ +│ ├──────────────────┤ ├──────────────────────────────┤ │ +│ │ • /responses │ │ • /config │ │ +│ │ • /models │ │ • /providers │ │ +│ │ • /health │ │ • /models │ │ +│ │ • /ready │ │ • /conversations │ │ +│ │ • /metrics │ │ • /metrics │ │ +│ └──────────────────┘ │ • /logs │ │ +│ │ • /system │ │ +│ ┌──────────────────┐ └──────────────────────────────┘ │ +│ │ Static Assets │ │ +│ │ /admin/* │ │ +│ │ (embedded) │ │ +│ └──────────────────┘ │ +│ │ +│ ┌────────────────────────────────────────────────────┐ │ +│ │ Core Components │ │ +│ │ • Provider Registry │ │ +│ │ • Conversation Store │ │ +│ │ • Config Manager (new) │ │ +│ │ • Metrics Collector │ │ +│ │ • Log Buffer (new) │ │ +│ └────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Component Breakdown + +#### Frontend Components + +**Technology Stack Options:** +1. **Vue 3 + Vite** (Recommended) + - Lightweight (~50KB gzipped) + - Reactive data binding + - Component-based architecture + - Excellent TypeScript support + +2. **Svelte + Vite** (Alternative) + - Even lighter (~20KB) + - Compile-time optimization + - Simpler learning curve + +3. **htmx + Alpine.js** (Minimal) + - No build step + - Server-rendered hypermedia + - ~40KB total + +**Recommended Choice:** Vue 3 + Vite + TypeScript +- Balance of features and bundle size +- Strong ecosystem and tooling +- Familiar to most developers + +**Frontend Structure:** +``` +frontend/ +├── src/ +│ ├── main.ts # App entry point +│ ├── App.vue # Root component +│ ├── router.ts # Vue Router config +│ ├── api/ # API client +│ │ ├── client.ts # Axios/fetch wrapper +│ │ ├── config.ts # Config API +│ │ ├── providers.ts # Provider API +│ │ ├── models.ts # Model API +│ │ ├── conversations.ts # Conversation API +│ │ ├── metrics.ts # Metrics API +│ │ └── system.ts # System API +│ ├── components/ # Reusable components +│ │ ├── Layout.vue # App layout +│ │ ├── Sidebar.vue # Navigation +│ │ ├── Header.vue # Top bar +│ │ ├── StatusBadge.vue # Provider status +│ │ ├── MetricCard.vue # Metric display +│ │ ├── ProviderForm.vue # Provider editor +│ │ ├── ModelForm.vue # Model editor +│ │ └── ConfigEditor.vue # YAML/JSON editor +│ ├── views/ # Page components +│ │ ├── Dashboard.vue # Overview dashboard +│ │ ├── Providers.vue # Provider management +│ │ ├── ProviderDetail.vue # Single provider view +│ │ ├── Models.vue # Model management +│ │ ├── Configuration.vue # Config editor +│ │ ├── Conversations.vue # Conversation browser +│ │ ├── Metrics.vue # Metrics dashboard +│ │ ├── Logs.vue # Log viewer +│ │ └── System.vue # System info +│ ├── stores/ # Pinia state management +│ │ ├── auth.ts # Auth state +│ │ ├── config.ts # Config state +│ │ ├── providers.ts # Provider state +│ │ └── metrics.ts # Metrics state +│ ├── types/ # TypeScript types +│ │ └── api.ts # API response types +│ └── utils/ # Utilities +│ ├── formatting.ts # Format helpers +│ └── validation.ts # Form validation +├── public/ +│ └── favicon.ico +├── index.html +├── package.json +├── tsconfig.json +├── vite.config.ts +└── README.md +``` + +#### Backend Components + +**New Go Packages:** + +``` +internal/ +├── admin/ # Admin API package (NEW) +│ ├── handler.go # HTTP handlers +│ ├── config_handler.go # Config management +│ ├── provider_handler.go # Provider management +│ ├── model_handler.go # Model management +│ ├── conversation_handler.go # Conversation management +│ ├── metrics_handler.go # Metrics aggregation +│ ├── logs_handler.go # Log streaming +│ ├── system_handler.go # System operations +│ └── middleware.go # Admin-specific middleware +├── configmanager/ # Config management (NEW) +│ ├── manager.go # Config CRUD operations +│ ├── validator.go # Config validation +│ ├── diff.go # Config diff generation +│ └── reload.go # Hot-reload logic +├── logbuffer/ # Log buffering (NEW) +│ ├── buffer.go # Circular log buffer +│ └── writer.go # slog.Handler wrapper +└── auditlog/ # Audit logging (NEW) + ├── logger.go # Audit event logger + └── types.go # Audit event types +``` + +### Data Flow + +#### Configuration Update Flow + +``` +User clicks "Save Config" in UI + ↓ +Frontend validates form input + ↓ +POST /admin/api/config with new config + ↓ +Backend validates config structure + ↓ +Generate diff (old vs new) + ↓ +Return diff to frontend for confirmation + ↓ +User confirms change + ↓ +POST /admin/api/config/apply + ↓ +Write to config file (or temp file) + ↓ +Reload config (hot-reload or restart) + ↓ +Update audit log + ↓ +Return success/failure + ↓ +Frontend refreshes dashboard +``` + +#### Metrics Data Flow + +``` +Prometheus metrics continuously collected + ↓ +GET /admin/api/metrics + ↓ +Backend queries Prometheus registry + ↓ +Aggregate by provider, model, status + ↓ +Calculate percentiles and rates + ↓ +Return JSON response + ↓ +Frontend updates charts (auto-refresh every 5s) +``` + +--- + +## API Specification + +### Base Path +All admin API endpoints are under `/admin/api/v1` + +### Authentication +All endpoints require authentication via OIDC JWT token in `Authorization: Bearer ` header. + +### Common Response Format + +**Success Response:** +```json +{ + "success": true, + "data": { /* endpoint-specific data */ }, + "timestamp": "2026-03-05T10:30:00Z" +} +``` + +**Error Response:** +```json +{ + "success": false, + "error": { + "code": "VALIDATION_ERROR", + "message": "Invalid provider configuration", + "details": { + "field": "api_key", + "reason": "API key is required" + } + }, + "timestamp": "2026-03-05T10:30:00Z" +} +``` + +### Endpoints + +#### System Information + +**GET /admin/api/v1/system/info** + +Get system information and status. + +Response: +```json +{ + "success": true, + "data": { + "version": "1.2.0", + "build_time": "2026-03-01T08:00:00Z", + "git_commit": "59ded10", + "go_version": "1.25.7", + "platform": "linux/amd64", + "uptime_seconds": 86400, + "config_file": "/app/config.yaml", + "config_last_modified": "2026-03-05T09:00:00Z" + } +} +``` + +**GET /admin/api/v1/system/health** + +Get detailed health status. + +Response: +```json +{ + "success": true, + "data": { + "status": "healthy", + "checks": { + "server": { "status": "pass", "message": "Server running" }, + "providers": { "status": "pass", "message": "3/3 providers healthy" }, + "conversation_store": { "status": "pass", "message": "Connected to Redis" }, + "metrics": { "status": "pass", "message": "Prometheus collecting" } + } + } +} +``` + +**POST /admin/api/v1/system/restart** + +Trigger graceful restart. + +Request: +```json +{ + "countdown_seconds": 5, + "reason": "Configuration update" +} +``` + +Response: +```json +{ + "success": true, + "data": { + "message": "Restart scheduled in 5 seconds", + "restart_at": "2026-03-05T10:30:05Z" + } +} +``` + +#### Configuration Management + +**GET /admin/api/v1/config** + +Get current configuration. + +Query Parameters: +- `sanitized` (boolean, default: true) - Mask sensitive values (API keys) + +Response: +```json +{ + "success": true, + "data": { + "config": { + "server": { + "address": ":8080", + "max_request_body_size": 10485760 + }, + "logging": { + "format": "json", + "level": "info" + }, + "providers": { + "openai": { + "type": "openai", + "api_key": "sk-*********************xyz", + "endpoint": "https://api.openai.com/v1" + } + }, + "models": [ + { + "name": "gpt-4", + "provider": "openai" + } + ] + }, + "source": "file", + "last_modified": "2026-03-05T09:00:00Z" + } +} +``` + +**POST /admin/api/v1/config/validate** + +Validate configuration without applying. + +Request: +```json +{ + "config": { + "server": { "address": ":8081" } + } +} +``` + +Response: +```json +{ + "success": true, + "data": { + "valid": true, + "warnings": [ + "Changing server address requires restart" + ], + "errors": [] + } +} +``` + +**POST /admin/api/v1/config/diff** + +Generate diff between current and proposed config. + +Request: +```json +{ + "new_config": { /* full or partial config */ } +} +``` + +Response: +```json +{ + "success": true, + "data": { + "diff": [ + { + "path": "server.address", + "old_value": ":8080", + "new_value": ":8081", + "type": "modified" + }, + { + "path": "providers.anthropic", + "old_value": null, + "new_value": { "type": "anthropic", "api_key": "***" }, + "type": "added" + } + ], + "requires_restart": true + } +} +``` + +**PUT /admin/api/v1/config** + +Update configuration. + +Request: +```json +{ + "config": { /* new configuration */ }, + "apply_method": "hot_reload", // or "restart" + "backup": true +} +``` + +Response: +```json +{ + "success": true, + "data": { + "applied": true, + "method": "hot_reload", + "backup_file": "/app/backups/config.yaml.2026-03-05-103000.bak", + "changes": [ /* diff */ ] + } +} +``` + +**GET /admin/api/v1/config/export** + +Export configuration as YAML. + +Response: (Content-Type: application/x-yaml) +```yaml +server: + address: ":8080" +# ... full config +``` + +#### Provider Management + +**GET /admin/api/v1/providers** + +List all providers. + +Response: +```json +{ + "success": true, + "data": { + "providers": [ + { + "name": "openai", + "type": "openai", + "status": "healthy", + "circuit_breaker_state": "closed", + "endpoint": "https://api.openai.com/v1", + "metrics": { + "total_requests": 1523, + "error_count": 12, + "error_rate": 0.0079, + "avg_latency_ms": 342, + "p95_latency_ms": 876 + }, + "last_request_at": "2026-03-05T10:29:45Z", + "last_error_at": "2026-03-05T09:15:22Z" + } + ] + } +} +``` + +**GET /admin/api/v1/providers/{name}** + +Get provider details. + +Response: +```json +{ + "success": true, + "data": { + "name": "openai", + "type": "openai", + "config": { + "api_key": "sk-*********************xyz", + "endpoint": "https://api.openai.com/v1" + }, + "status": "healthy", + "circuit_breaker": { + "state": "closed", + "consecutive_failures": 0, + "last_state_change": "2026-03-05T08:00:00Z" + }, + "metrics": { /* detailed metrics */ } + } +} +``` + +**POST /admin/api/v1/providers** + +Add new provider. + +Request: +```json +{ + "name": "anthropic-prod", + "type": "anthropic", + "config": { + "api_key": "sk-ant-...", + "endpoint": "https://api.anthropic.com" + } +} +``` + +Response: +```json +{ + "success": true, + "data": { + "name": "anthropic-prod", + "created": true + } +} +``` + +**PUT /admin/api/v1/providers/{name}** + +Update provider configuration. + +Request: +```json +{ + "config": { + "api_key": "new-key", + "endpoint": "https://api.anthropic.com" + } +} +``` + +**DELETE /admin/api/v1/providers/{name}** + +Delete provider. + +Response: +```json +{ + "success": true, + "data": { + "deleted": true, + "affected_models": ["claude-3-opus", "claude-3-sonnet"] + } +} +``` + +**POST /admin/api/v1/providers/{name}/test** + +Test provider connectivity. + +Request: +```json +{ + "test_message": "Hello, test", + "model": "gpt-4" // optional, uses default +} +``` + +Response: +```json +{ + "success": true, + "data": { + "reachable": true, + "latency_ms": 342, + "response": "Test successful", + "error": null + } +} +``` + +**POST /admin/api/v1/providers/{name}/circuit-breaker/reset** + +Reset circuit breaker state. + +Response: +```json +{ + "success": true, + "data": { + "previous_state": "open", + "new_state": "closed" + } +} +``` + +#### Model Management + +**GET /admin/api/v1/models** + +List all model configurations. + +Response: +```json +{ + "success": true, + "data": { + "models": [ + { + "name": "gpt-4", + "provider": "openai", + "provider_model_id": null, + "metrics": { + "total_requests": 856, + "avg_latency_ms": 1234 + } + }, + { + "name": "gpt-4-azure", + "provider": "azure-openai", + "provider_model_id": "gpt-4-deployment-001", + "metrics": { + "total_requests": 234, + "avg_latency_ms": 987 + } + } + ] + } +} +``` + +**POST /admin/api/v1/models** + +Add new model mapping. + +Request: +```json +{ + "name": "claude-opus", + "provider": "anthropic-prod", + "provider_model_id": "claude-3-opus-20240229" +} +``` + +**PUT /admin/api/v1/models/{name}** + +Update model mapping. + +**DELETE /admin/api/v1/models/{name}** + +Delete model mapping. + +#### Conversation Management + +**GET /admin/api/v1/conversations** + +List conversations with pagination. + +Query Parameters: +- `page` (int, default: 1) +- `page_size` (int, default: 50, max: 200) +- `search` (string) - Search by conversation ID +- `sort` (string) - Sort field (created_at, updated_at) +- `order` (string) - asc or desc + +Response: +```json +{ + "success": true, + "data": { + "conversations": [ + { + "id": "conv_abc123", + "created_at": "2026-03-05T10:00:00Z", + "updated_at": "2026-03-05T10:15:00Z", + "message_count": 6, + "total_tokens": 2456, + "model": "gpt-4", + "metadata": {} + } + ], + "pagination": { + "page": 1, + "page_size": 50, + "total_count": 1234, + "total_pages": 25 + } + } +} +``` + +**GET /admin/api/v1/conversations/{id}** + +Get conversation details. + +Response: +```json +{ + "success": true, + "data": { + "id": "conv_abc123", + "created_at": "2026-03-05T10:00:00Z", + "updated_at": "2026-03-05T10:15:00Z", + "messages": [ + { + "role": "user", + "content": "Hello", + "timestamp": "2026-03-05T10:00:00Z" + }, + { + "role": "assistant", + "content": "Hi there!", + "timestamp": "2026-03-05T10:00:02Z" + } + ], + "metadata": {}, + "total_tokens": 2456 + } +} +``` + +**DELETE /admin/api/v1/conversations/{id}** + +Delete specific conversation. + +**POST /admin/api/v1/conversations/bulk-delete** + +Bulk delete conversations. + +Request: +```json +{ + "criteria": { + "older_than_days": 30, + "model": "gpt-3.5-turbo" // optional filter + }, + "dry_run": true // preview without deleting +} +``` + +Response: +```json +{ + "success": true, + "data": { + "matched_count": 456, + "deleted_count": 0, // 0 if dry_run + "dry_run": true + } +} +``` + +**GET /admin/api/v1/conversations/stats** + +Get conversation statistics. + +Response: +```json +{ + "success": true, + "data": { + "total_conversations": 1234, + "total_messages": 7890, + "total_tokens": 1234567, + "by_model": { + "gpt-4": 856, + "claude-3-opus": 378 + }, + "by_date": [ + { "date": "2026-03-05", "count": 123 }, + { "date": "2026-03-04", "count": 98 } + ], + "storage_size_bytes": 52428800 + } +} +``` + +#### Metrics + +**GET /admin/api/v1/metrics/summary** + +Get aggregated metrics summary. + +Query Parameters: +- `duration` (string, default: "1h") - Time window (1m, 5m, 1h, 24h) + +Response: +```json +{ + "success": true, + "data": { + "time_window": "1h", + "request_count": 1523, + "error_count": 12, + "error_rate": 0.0079, + "requests_per_second": 0.42, + "latency": { + "p50": 234, + "p90": 567, + "p95": 876, + "p99": 1234 + }, + "by_provider": { + "openai": { + "request_count": 1200, + "error_count": 8, + "avg_latency_ms": 342 + }, + "anthropic": { + "request_count": 323, + "error_count": 4, + "avg_latency_ms": 567 + } + }, + "by_model": { + "gpt-4": { "request_count": 856, "error_count": 5 }, + "claude-3-opus": { "request_count": 323, "error_count": 4 } + } + } +} +``` + +**GET /admin/api/v1/metrics/timeseries** + +Get time-series metrics for charting. + +Query Parameters: +- `metric` (string) - request_count, error_rate, latency_p95 +- `duration` (string) - 1h, 6h, 24h, 7d +- `interval` (string) - 1m, 5m, 1h +- `provider` (string, optional) - Filter by provider +- `model` (string, optional) - Filter by model + +Response: +```json +{ + "success": true, + "data": { + "metric": "request_count", + "interval": "5m", + "data_points": [ + { "timestamp": "2026-03-05T10:00:00Z", "value": 42 }, + { "timestamp": "2026-03-05T10:05:00Z", "value": 38 }, + { "timestamp": "2026-03-05T10:10:00Z", "value": 51 } + ] + } +} +``` + +#### Logs + +**GET /admin/api/v1/logs** + +Get recent logs (last N entries). + +Query Parameters: +- `limit` (int, default: 100, max: 1000) +- `level` (string) - Filter by level (debug, info, warn, error) +- `search` (string) - Search in message + +Response: +```json +{ + "success": true, + "data": { + "logs": [ + { + "timestamp": "2026-03-05T10:30:15Z", + "level": "info", + "message": "Request completed", + "fields": { + "method": "POST", + "path": "/v1/responses", + "status": 200, + "duration_ms": 342 + } + } + ], + "total_count": 100, + "truncated": false + } +} +``` + +**GET /admin/api/v1/logs/stream** + +Stream logs via Server-Sent Events (SSE). + +Response: (text/event-stream) +``` +data: {"timestamp":"2026-03-05T10:30:15Z","level":"info","message":"..."} + +data: {"timestamp":"2026-03-05T10:30:16Z","level":"error","message":"..."} +``` + +#### Audit Log + +**GET /admin/api/v1/audit** + +Get audit log of admin actions. + +Query Parameters: +- `page` (int) +- `page_size` (int) +- `user` (string) - Filter by user +- `action` (string) - Filter by action type + +Response: +```json +{ + "success": true, + "data": { + "events": [ + { + "id": "audit_xyz789", + "timestamp": "2026-03-05T10:25:00Z", + "user": "admin@example.com", + "action": "config.update", + "resource": "server.address", + "changes": { + "old_value": ":8080", + "new_value": ":8081" + }, + "ip_address": "192.168.1.100", + "user_agent": "Mozilla/5.0..." + } + ], + "pagination": { /* ... */ } + } +} +``` + +--- + +## UI Design + +### Design Principles + +1. **Clarity over Complexity** - Show what matters, hide what doesn't +2. **Progressive Disclosure** - Surface details on demand +3. **Immediate Feedback** - Loading states, success/error messages +4. **Consistency** - Reuse patterns across views +5. **Accessibility** - Keyboard navigation, screen reader support + +### Layout Structure + +``` +┌────────────────────────────────────────────────────────────┐ +│ Header: [Logo] go-llm-gateway Admin [User] [Dark Mode] │ +├──────────┬─────────────────────────────────────────────────┤ +│ │ │ +│ Sidebar │ Main Content Area │ +│ │ │ +│ ☰ Dash │ ┌─────────────────────────────────────────┐ │ +│ 📊 Prov │ │ │ │ +│ 🔧 Model │ │ │ │ +│ ⚙️ Conf │ │ │ │ +│ 💬 Conv │ │ Page-Specific Content │ │ +│ 📈 Metr │ │ │ │ +│ 📝 Logs │ │ │ │ +│ 🖥️ Sys │ │ │ │ +│ │ └─────────────────────────────────────────┘ │ +│ │ │ +└──────────┴─────────────────────────────────────────────────┘ +``` + +### Page Wireframes + +#### 1. Dashboard (Home) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Dashboard │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ Uptime │ │ Requests │ │ Error Rate │ │ +│ │ 2d 14h │ │ 1,523 │ │ 0.79% │ │ +│ │ ✓ Healthy │ │ ↑ 12% 1h │ │ ↓ 0.3% 1h │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +│ Provider Status │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ openai ✓ Healthy │ 1,200 req │ 342ms │ │ +│ │ anthropic ✓ Healthy │ 323 req │ 567ms │ │ +│ │ google ⚠ Degraded │ 0 req │ 0ms │ │ +│ └───────────────────────────────────────────────────────┘ │ +│ │ +│ Request Rate (Last Hour) │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ 📊 [Line Chart] │ │ +│ │ requests/sec over time │ │ +│ └───────────────────────────────────────────────────────┘ │ +│ │ +│ Recent Activity │ +│ ┌───────────────────────────────────────────────────────┐ │ +│ │ 10:30:15 INFO Request completed (gpt-4, 342ms) │ │ +│ │ 10:30:10 INFO Request completed (claude-3, 567ms) │ │ +│ │ 10:29:58 ERROR Provider timeout (google) │ │ +│ └───────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +#### 2. Providers + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Providers [+ Add Provider] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────────────────────────────────────────────┐│ +│ │ ┌─┐ openai ✓ Healthy ││ +│ │ │▼│ Type: OpenAI [Test] [Edit]││ +│ │ └─┘ Endpoint: https://api.openai.com/v1 [Delete] ││ +│ │ ││ +│ │ Circuit Breaker: Closed (0 failures) ││ +│ │ Metrics: 1,200 requests, 0.67% errors, 342ms avg ││ +│ │ Last request: 2 seconds ago ││ +│ │ ││ +│ │ ┌──────────────────────────────────────────────┐ ││ +│ │ │ Request Count: [Mini chart ↗] │ ││ +│ │ │ Latency P95: [Mini chart →] │ ││ +│ │ └──────────────────────────────────────────────┘ ││ +│ └────────────────────────────────────────────────────────┘│ +│ │ +│ ┌────────────────────────────────────────────────────────┐│ +│ │ ┌─┐ anthropic-prod ✓ Healthy ││ +│ │ │▶│ Type: Anthropic [Test] [Edit]││ +│ │ └─┘ Endpoint: https://api.anthropic.com [Delete] ││ +│ └────────────────────────────────────────────────────────┘│ +│ │ +│ ┌────────────────────────────────────────────────────────┐│ +│ │ ┌─┐ google ⚠ Degraded ││ +│ │ │▶│ Type: Google Generative AI [Test] [Edit]││ +│ │ └─┘ Circuit Breaker: OPEN (5 failures) [Delete] ││ +│ │ [Reset CB] ││ +│ └────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────┘ +``` + +**Add/Edit Provider Modal:** +``` +┌─────────────────────────────────────────────────────┐ +│ Add Provider [X] │ +├─────────────────────────────────────────────────────┤ +│ │ +│ Provider Name * │ +│ [openai-prod ] │ +│ │ +│ Provider Type * │ +│ [OpenAI ▼] │ +│ │ +│ API Key * │ +│ [sk-••••••••••••••••••••xyz] [Show] [Test] │ +│ │ +│ Endpoint (optional) │ +│ [https://api.openai.com/v1] │ +│ │ +│ ⓘ Leave blank to use default endpoint │ +│ │ +│ [Cancel] [Save Provider] │ +└─────────────────────────────────────────────────────┘ +``` + +#### 3. Models + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Models [+ Add Model] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Search: [ 🔍] Filter: [All Providers ▼] │ +│ │ +│ ┌────────────────────────────────────────────────────────┐│ +│ │ Name Provider Model ID Requests ││ +│ ├────────────────────────────────────────────────────────┤│ +│ │ gpt-4 openai (default) 856 ││ +│ │ gpt-4-turbo openai (default) 432 ││ +│ │ gpt-4-azure azure-openai gpt4-dep-001 234 ││ +│ │ claude-3-opus anthropic claude-3-... 323 ││ +│ │ claude-3-sonnet anthropic claude-3-... 189 ││ +│ │ gemini-pro google (default) 56 ││ +│ └────────────────────────────────────────────────────────┘│ +│ │ +│ [← Prev] Page 1 of 1 [Next →] │ +└─────────────────────────────────────────────────────────────┘ +``` + +#### 4. Configuration + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Configuration │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ [Server] [Logging] [Rate Limit] [Auth] [Observability] │ +│ ───────────────────────────────────────────────────────── │ +│ │ +│ Server Configuration │ +│ ┌────────────────────────────────────────────────────────┐│ +│ │ ││ +│ │ Listen Address ││ +│ │ [:8080 ] ││ +│ │ ││ +│ │ Max Request Body Size (bytes) ││ +│ │ [10485760 ] (10 MB) ││ +│ │ ││ +│ │ Read Timeout (seconds) ││ +│ │ [15 ] ││ +│ │ ││ +│ │ Write Timeout (seconds) ││ +│ │ [60 ] ││ +│ │ ││ +│ │ Idle Timeout (seconds) ││ +│ │ [120 ] ││ +│ │ ││ +│ │ ⚠ Changing these settings requires a restart ││ +│ │ ││ +│ │ [Reset] [Save Configuration] ││ +│ └────────────────────────────────────────────────────────┘│ +│ │ +│ Advanced Options │ +│ [View as YAML] [Export Config] [Import Config] │ +└─────────────────────────────────────────────────────────────┘ +``` + +**YAML Editor View:** +``` +┌─────────────────────────────────────────────────────────────┐ +│ Configuration (YAML) [Switch to Form View] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────────────────────────────────────────────┐│ +│ │ 1 server: ││ +│ │ 2 address: ":8080" ││ +│ │ 3 max_request_body_size: 10485760 ││ +│ │ 4 ││ +│ │ 5 logging: ││ +│ │ 6 format: "json" ││ +│ │ 7 level: "info" ││ +│ │ 8 ││ +│ │ 9 providers: ││ +│ │ 10 openai: ││ +│ │ 11 type: "openai" ││ +│ │ 12 api_key: "${OPENAI_API_KEY}" ││ +│ │ ││ +│ │ [Syntax highlighting and validation] ││ +│ └────────────────────────────────────────────────────────┘│ +│ │ +│ ✓ Configuration is valid │ +│ │ +│ [Show Diff] [Validate] [Save Configuration] │ +└─────────────────────────────────────────────────────────────┘ +``` + +#### 5. Conversations + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Conversations │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Search: [conv_abc123 🔍] [Bulk Delete...] │ +│ │ +│ ┌────────────────────────────────────────────────────────┐│ +│ │ ID Created Messages Model Actions ││ +│ ├────────────────────────────────────────────────────────┤│ +│ │ conv_abc123 2h ago 6 gpt-4 [View] ││ +│ │ conv_def456 3h ago 12 claude-3 [View] ││ +│ │ conv_ghi789 5h ago 3 gpt-4 [View] ││ +│ │ conv_jkl012 1d ago 8 gemini-pro [View] ││ +│ └────────────────────────────────────────────────────────┘│ +│ │ +│ [← Prev] Page 1 of 25 (1,234 total) [Next →] │ +│ │ +│ Statistics │ +│ Total: 1,234 conversations | 7,890 messages | 52 MB │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Conversation Detail Modal:** +``` +┌─────────────────────────────────────────────────────────────┐ +│ Conversation: conv_abc123 [Delete] [X] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Created: 2026-03-05 08:15:30 | Model: gpt-4 │ +│ Messages: 6 | Tokens: 2,456 | Updated: 08:30:15 │ +│ │ +│ ┌────────────────────────────────────────────────────────┐│ +│ │ 👤 User (08:15:30) ││ +│ │ Hello, can you help me with a coding question? ││ +│ └────────────────────────────────────────────────────────┘│ +│ │ +│ ┌────────────────────────────────────────────────────────┐│ +│ │ 🤖 Assistant (08:15:32) ││ +│ │ Of course! I'd be happy to help. What's your question?││ +│ └────────────────────────────────────────────────────────┘│ +│ │ +│ ┌────────────────────────────────────────────────────────┐│ +│ │ 👤 User (08:16:10) ││ +│ │ How do I implement a binary search in Python? ││ +│ └────────────────────────────────────────────────────────┘│ +│ │ +│ [... more messages ...] │ +│ │ +│ [Close] │ +└─────────────────────────────────────────────────────────────┘ +``` + +#### 6. Metrics + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Metrics Time: [Last Hour ▼] [Refresh] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Overview │ +│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ +│ │ Total Req │ │ Requests/sec │ │ Error Rate │ │ +│ │ 1,523 │ │ 0.42 │ │ 0.79% │ │ +│ └──────────────┘ └──────────────┘ └──────────────┘ │ +│ │ +│ Request Rate │ +│ ┌────────────────────────────────────────────────────────┐│ +│ │ 50 ┤ ││ +│ │ 40 ┤ ╭─╮ ││ +│ │ 30 ┤ ╭────╯ ╰─╮ ││ +│ │ 20 ┤ ╭────╯ ╰──╮ ││ +│ │ 10 ┤────╯ ╰──── ││ +│ │ 0 ┼──────────────────────────────────── ││ +│ │ 9:30 10:00 10:30 11:00 ││ +│ └────────────────────────────────────────────────────────┘│ +│ │ +│ Latency (P95) │ +│ ┌────────────────────────────────────────────────────────┐│ +│ │ 1200ms ┤ ││ +│ │ 900ms ┤ ╭─────╮ ││ +│ │ 600ms ┤─────────╯ ╰───────── ││ +│ │ 300ms ┤ ││ +│ │ 0 ┼──────────────────────────────────── ││ +│ │ 9:30 10:00 10:30 11:00 ││ +│ └────────────────────────────────────────────────────────┘│ +│ │ +│ By Provider │ +│ ┌────────────────────────────────────────────────────────┐│ +│ │ Provider Requests Errors Avg Latency P95 ││ +│ ├────────────────────────────────────────────────────────┤│ +│ │ openai 1,200 8 342ms 876ms ││ +│ │ anthropic 323 4 567ms 1234ms ││ +│ │ google 0 0 - - ││ +│ └────────────────────────────────────────────────────────┘│ +└─────────────────────────────────────────────────────────────┘ +``` + +#### 7. Logs + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Logs [Auto-refresh: ON] [Download] │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Level: [All ▼] Search: [ 🔍] │ +│ │ +│ ┌────────────────────────────────────────────────────────┐│ +│ │ 10:30:45 INFO Request completed ││ +│ │ method=POST path=/v1/responses status=200 ││ +│ │ duration=342ms model=gpt-4 ││ +│ │ ││ +│ │ 10:30:42 INFO Provider request started ││ +│ │ provider=openai model=gpt-4 ││ +│ │ ││ +│ │ 10:30:30 ERROR Provider request failed ││ +│ │ provider=google error="connection timeout"││ +│ │ circuit_breaker=open ││ +│ │ ││ +│ │ 10:30:15 INFO Request completed ││ +│ │ method=POST path=/v1/responses status=200 ││ +│ │ ││ +│ │ 10:29:58 WARN Rate limit exceeded ││ +│ │ ip=192.168.1.100 path=/v1/responses ││ +│ │ ││ +│ │ [... scrollable log entries ...] ││ +│ │ ││ +│ └────────────────────────────────────────────────────────┘│ +│ │ +│ Showing last 100 entries | [Load More] │ +└─────────────────────────────────────────────────────────────┘ +``` + +#### 8. System + +``` +┌─────────────────────────────────────────────────────────────┐ +│ System Information │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ Application │ +│ ┌────────────────────────────────────────────────────────┐│ +│ │ Version: 1.2.0 ││ +│ │ Build Time: 2026-03-01 08:00:00 UTC ││ +│ │ Git Commit: 59ded10 ││ +│ │ Go Version: 1.25.7 ││ +│ │ Platform: linux/amd64 ││ +│ │ Uptime: 2 days 14 hours 23 minutes ││ +│ └────────────────────────────────────────────────────────┘│ +│ │ +│ Configuration │ +│ ┌────────────────────────────────────────────────────────┐│ +│ │ Config File: /app/config.yaml ││ +│ │ Last Modified: 2026-03-05 09:00:00 UTC ││ +│ │ File Size: 4.2 KB ││ +│ │ Valid: ✓ Yes ││ +│ └────────────────────────────────────────────────────────┘│ +│ │ +│ Health Checks │ +│ ┌────────────────────────────────────────────────────────┐│ +│ │ ✓ Server Healthy ││ +│ │ ✓ Providers 3/3 healthy ││ +│ │ ✓ Conversation Store Connected (Redis) ││ +│ │ ✓ Metrics Collecting ││ +│ │ ✓ Tracing Enabled (OTLP) ││ +│ └────────────────────────────────────────────────────────┘│ +│ │ +│ Operations │ +│ [Download Diagnostic Bundle] [Restart Service...] │ +│ │ +│ Environment (Sanitized) │ +│ [View Environment Variables] │ +└─────────────────────────────────────────────────────────────┘ +``` + +### UI Components Library + +**Reusable Components:** + +1. **StatusBadge** - Color-coded status indicators + - Healthy (green), Degraded (yellow), Unhealthy (red), Unknown (gray) + +2. **MetricCard** - Display single metric with trend + - Large number, label, trend arrow, sparkline + +3. **ProviderCard** - Provider summary with expand/collapse + +4. **DataTable** - Sortable, filterable table with pagination + +5. **Chart** - Line/bar charts for time-series data + - Use lightweight charting library (Chart.js or Apache ECharts) + +6. **CodeEditor** - Syntax-highlighted YAML/JSON editor + - Monaco Editor (VS Code engine) or CodeMirror + +7. **Modal** - Overlay dialogs for forms and details + +8. **Toast** - Success/error notifications + +9. **ConfirmDialog** - Confirmation for destructive actions + +--- + +## Security + +### Authentication & Authorization + +**Authentication:** +- Reuse existing OIDC/OAuth2 middleware from `internal/auth/auth.go` +- All `/admin/*` routes require valid JWT token +- Support same identity providers as gateway API + +**Authorization (RBAC):** + +Introduce role-based access control with two roles: + +1. **Admin Role** (`admin`) + - Full read/write access + - Can modify configuration + - Can delete resources (conversations, providers) + - Can restart service + +2. **Viewer Role** (`viewer`) + - Read-only access + - Can view all pages + - Cannot modify configuration + - Cannot delete resources + - Cannot restart service + +**Role Assignment:** +- Roles extracted from JWT claims (e.g., `roles` or `groups` claim) +- Configurable claim name in config.yaml: + ```yaml + auth: + enabled: true + issuer: "https://auth.example.com" + audience: "gateway-admin" + roles_claim: "roles" # JWT claim containing roles + admin_roles: # Values that grant admin access + - "admin" + - "gateway-admin" + ``` + +**Implementation:** +```go +// internal/admin/middleware.go + +func RequireRole(requiredRole string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + claims := auth.ClaimsFromContext(r.Context()) + userRoles := claims["roles"].([]string) + + if !hasRole(userRoles, requiredRole) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +// Usage in routes +mux.Handle("/admin/api/v1/config", RequireRole("admin")(configHandler)) +mux.Handle("/admin/api/v1/providers", RequireRole("viewer")(providersHandler)) +``` + +### Input Validation & Sanitization + +**Configuration Validation:** +- Validate all config changes before applying +- Use strong typing (Go structs) for validation +- Reject invalid YAML syntax +- Validate provider-specific fields (API key format, endpoint URLs) +- Prevent path traversal in file operations + +**API Input Validation:** +- Validate all request bodies against expected schemas +- Sanitize user input (conversation search, log search) +- Limit input sizes (prevent DoS via large payloads) +- Validate pagination parameters (prevent negative pages) + +### Secret Management + +**Masking Secrets:** +- Always mask API keys and sensitive values in UI displays +- Show format: `sk-*********************xyz` (first 3 + last 3 chars) +- Never log full API keys in audit logs +- Sanitize secrets before returning in API responses + +**Storage:** +- Secrets stored in config.yaml with environment variable references +- Never commit secrets to version control +- Support secret management systems (future: Vault, AWS Secrets Manager) + +### CSRF Protection + +**Protection Strategy:** +- Generate CSRF token on admin UI load +- Include token in all state-changing requests (POST, PUT, DELETE) +- Validate token on server before processing request +- Use SameSite cookies for additional protection + +**Implementation:** +```go +// Double Submit Cookie pattern +func CSRFMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" && r.Method != "HEAD" { + tokenHeader := r.Header.Get("X-CSRF-Token") + tokenCookie, _ := r.Cookie("csrf_token") + + if tokenHeader == "" || tokenCookie == nil || tokenHeader != tokenCookie.Value { + http.Error(w, "CSRF token mismatch", http.StatusForbidden) + return + } + } + + next.ServeHTTP(w, r) + }) +} +``` + +### Content Security Policy + +**CSP Headers:** +``` +Content-Security-Policy: + default-src 'self'; + script-src 'self' 'unsafe-inline'; # Allow inline Vue scripts + style-src 'self' 'unsafe-inline'; # Allow inline styles + img-src 'self' data:; + connect-src 'self'; # API calls to same origin + frame-ancestors 'none'; # Prevent clickjacking + base-uri 'self'; + form-action 'self'; +``` + +### Rate Limiting + +**Admin API Rate Limiting:** +- Separate rate limits for admin API vs gateway API +- Higher limits for read operations, lower for writes +- Per-user rate limiting (based on JWT subject) +- Example: 100 req/min for reads, 20 req/min for writes + +### Audit Logging + +**Log All Admin Actions:** +- Configuration changes (before/after values) +- Provider additions/deletions +- Model changes +- Bulk deletions +- Service restarts +- Authentication failures + +**Audit Log Format:** +```json +{ + "timestamp": "2026-03-05T10:25:00Z", + "event_type": "config.update", + "user": "admin@example.com", + "user_ip": "192.168.1.100", + "resource": "providers.openai.api_key", + "action": "update", + "old_value": "sk-***old***", + "new_value": "sk-***new***", + "success": true, + "error": null +} +``` + +**Storage:** +- Write to separate audit log file (`/var/log/gateway-audit.log`) +- Structured JSON format for easy parsing +- Rotate logs daily, retain for 90 days +- Optional: Send to external SIEM system + +### TLS/HTTPS + +**Production Requirements:** +- Admin UI MUST be served over HTTPS in production +- Support TLS 1.2+ only +- Strong cipher suites only +- HSTS headers: `Strict-Transport-Security: max-age=31536000; includeSubDomains` + +**Configuration:** +```yaml +server: + address: ":8443" + tls: + enabled: true + cert_file: "/etc/gateway/tls/cert.pem" + key_file: "/etc/gateway/tls/key.pem" +``` + +--- + +## Implementation Phases + +### Phase 1: Foundation (Week 1) + +**Goal:** Basic admin API and static UI serving + +**Backend Tasks:** +1. Create `internal/admin/` package structure +2. Implement basic HTTP handlers for system info and health +3. Add static file serving for admin UI assets (using `embed.FS`) +4. Set up admin-specific middleware (auth, CORS, CSRF) +5. Implement audit logging infrastructure + +**Frontend Tasks:** +1. Set up Vue 3 + Vite project in `frontend/admin/` +2. Create basic layout (header, sidebar, main content) +3. Implement routing (Vue Router) +4. Create API client wrapper (Axios) +5. Build Dashboard page (system info, health status) + +**Deliverables:** +- Admin UI accessible at `/admin/` +- System info and health endpoints working +- Basic authentication enforced +- Static assets served from embedded FS + +### Phase 2: Configuration Management (Week 2) + +**Goal:** View and edit configuration + +**Backend Tasks:** +1. Create `internal/configmanager/` package +2. Implement config CRUD operations +3. Add config validation logic +4. Implement diff generation +5. Add config export/import endpoints +6. Implement hot-reload for config changes (where possible) + +**Frontend Tasks:** +1. Build Configuration page with tabbed interface +2. Implement form-based config editor +3. Build YAML editor with syntax highlighting (Monaco Editor) +4. Add config validation UI +5. Implement diff viewer before applying changes +6. Add export/import functionality + +**Deliverables:** +- View current configuration (sanitized) +- Edit configuration via forms or YAML +- Validate configuration before saving +- Preview changes before applying +- Export configuration as YAML file + +### Phase 3: Provider & Model Management (Week 3) + +**Goal:** Manage providers and models + +**Backend Tasks:** +1. Implement provider CRUD endpoints +2. Add provider test connectivity endpoint +3. Implement circuit breaker reset endpoint +4. Add model CRUD endpoints +5. Aggregate provider metrics from Prometheus + +**Frontend Tasks:** +1. Build Providers page with expandable cards +2. Implement provider add/edit forms +3. Add provider connection testing +4. Display provider metrics and circuit breaker status +5. Build Models page with data table +6. Implement model add/edit functionality + +**Deliverables:** +- List all providers with status +- Add/edit/delete providers +- Test provider connectivity +- Reset circuit breakers +- Manage model mappings + +### Phase 4: Metrics & Monitoring (Week 4) + +**Goal:** Real-time metrics visualization + +**Backend Tasks:** +1. Implement metrics aggregation endpoints +2. Add time-series data endpoints +3. Implement metrics filtering (by provider, model) +4. Add circuit breaker state change history + +**Frontend Tasks:** +1. Build Metrics page with charts (Chart.js) +2. Implement real-time metrics (auto-refresh) +3. Add interactive time range selection +4. Build provider-specific metric views +5. Add latency percentile charts + +**Deliverables:** +- Real-time request rate charts +- Error rate visualization +- Latency percentile charts +- Provider-specific metrics +- Auto-refreshing dashboard + +### Phase 5: Conversations & Logs (Week 5) + +**Goal:** Conversation management and log viewing + +**Backend Tasks:** +1. Implement `internal/logbuffer/` for log buffering +2. Add conversation list/search endpoints +3. Implement conversation detail endpoint +4. Add bulk delete functionality +5. Implement log streaming (SSE) + +**Frontend Tasks:** +1. Build Conversations page with pagination +2. Implement conversation search +3. Add conversation detail modal +4. Build bulk delete interface +5. Build Logs page with filtering +6. Implement real-time log streaming + +**Deliverables:** +- Browse and search conversations +- View conversation details +- Delete conversations (single and bulk) +- View application logs with filtering +- Real-time log streaming + +### Phase 6: Polish & Production Readiness (Week 6) + +**Goal:** Security hardening, testing, documentation + +**Tasks:** +1. Implement RBAC (admin vs viewer roles) +2. Add comprehensive input validation +3. Implement CSRF protection +4. Add CSP headers +5. Write unit tests (backend handlers) +6. Write integration tests (API endpoints) +7. Add E2E tests (Playwright) +8. Performance optimization (bundle size, lazy loading) +9. Accessibility audit and fixes +10. Documentation (user guide, API docs) +11. Docker image updates (include frontend build) + +**Deliverables:** +- Production-ready security hardening +- Comprehensive test coverage +- Performance optimized +- Fully documented +- Docker deployment ready + +--- + +## Testing Strategy + +### Backend Testing + +**Unit Tests:** +- Test all handler functions with mock dependencies +- Test config validation logic +- Test audit logging +- Target: 80%+ code coverage + +**Integration Tests:** +- Test API endpoints with real HTTP requests +- Test authentication/authorization flows +- Test RBAC enforcement +- Test configuration hot-reload + +**Example:** +```go +func TestProviderHandler(t *testing.T) { + tests := []struct { + name string + method string + path string + body string + expectedStatus int + }{ + { + name: "List providers", + method: "GET", + path: "/admin/api/v1/providers", + expectedStatus: http.StatusOK, + }, + { + name: "Add provider", + method: "POST", + path: "/admin/api/v1/providers", + body: `{"name":"test","type":"openai","config":{"api_key":"sk-test"}}`, + expectedStatus: http.StatusCreated, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Test implementation + }) + } +} +``` + +### Frontend Testing + +**Unit Tests (Vitest):** +- Test Vue components in isolation +- Test API client functions +- Test utility functions +- Target: 70%+ component coverage + +**Component Tests:** +- Test user interactions +- Test form validation +- Test state management (Pinia stores) + +**E2E Tests (Playwright):** +- Test complete user workflows +- Test authentication flow +- Test config editing flow +- Test provider management + +**Example:** +```typescript +// tests/e2e/providers.spec.ts +test('should add new provider', async ({ page }) => { + await page.goto('/admin/providers'); + await page.click('text=Add Provider'); + await page.fill('input[name="name"]', 'test-provider'); + await page.selectOption('select[name="type"]', 'openai'); + await page.fill('input[name="api_key"]', 'sk-test-key'); + await page.click('button:has-text("Save Provider")'); + + await expect(page.locator('.toast-success')).toBeVisible(); + await expect(page.locator('text=test-provider')).toBeVisible(); +}); +``` + +### Performance Testing + +**Load Testing:** +- Test admin API under load (Apache Bench, k6) +- Ensure < 1% CPU overhead when admin UI active +- Test with 10 concurrent admin users +- Verify no impact on gateway API performance + +**Frontend Performance:** +- Lighthouse audit (target: 90+ performance score) +- Bundle size analysis (target: < 500KB gzipped) +- Time to Interactive (target: < 2s) + +### Security Testing + +**Automated Scans:** +- OWASP ZAP scan for common vulnerabilities +- npm audit / go mod audit for dependency vulnerabilities +- CodeQL static analysis + +**Manual Testing:** +- Test RBAC enforcement +- Test CSRF protection +- Test secret masking +- Test input validation +- Test audit logging + +--- + +## Deployment + +### Build Process + +**Frontend Build:** +```bash +cd frontend/admin +npm install +npm run build # Outputs to frontend/admin/dist/ +``` + +**Embed Frontend in Go Binary:** +```go +// internal/admin/assets.go +package admin + +import "embed" + +//go:embed frontend/dist/* +var frontendAssets embed.FS +``` + +**Full Build:** +```bash +# Build frontend +cd frontend/admin && npm run build && cd ../.. + +# Build Go binary (includes embedded frontend) +go build -o gateway ./cmd/gateway + +# Result: Single binary with admin UI embedded +``` + +### Docker Image + +**Updated Dockerfile:** +```dockerfile +# Stage 1: Build frontend +FROM node:20-alpine AS frontend-builder +WORKDIR /app/frontend/admin +COPY frontend/admin/package*.json ./ +RUN npm ci +COPY frontend/admin/ ./ +RUN npm run build + +# Stage 2: Build Go binary +FROM golang:1.25.7-alpine AS go-builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +COPY --from=frontend-builder /app/frontend/admin/dist ./internal/admin/frontend/dist +RUN CGO_ENABLED=1 go build -o gateway ./cmd/gateway + +# Stage 3: Runtime +FROM alpine:3.19 +RUN apk --no-cache add ca-certificates +WORKDIR /app +COPY --from=go-builder /app/gateway /app/gateway +COPY config.example.yaml /app/config.yaml +EXPOSE 8080 +USER 1000:1000 +ENTRYPOINT ["/app/gateway"] +``` + +**Build Command:** +```bash +docker build -t go-llm-gateway:latest . +``` + +### Kubernetes Deployment + +**Updated Deployment:** +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: gateway +spec: + replicas: 3 + template: + spec: + containers: + - name: gateway + image: go-llm-gateway:latest + ports: + - containerPort: 8080 + name: http + env: + - name: OPENAI_API_KEY + valueFrom: + secretKeyRef: + name: gateway-secrets + key: openai-api-key + volumeMounts: + - name: config + mountPath: /app/config.yaml + subPath: config.yaml + volumes: + - name: config + configMap: + name: gateway-config +--- +apiVersion: v1 +kind: Service +metadata: + name: gateway +spec: + type: LoadBalancer + ports: + - port: 80 + targetPort: 8080 + name: http + selector: + app: gateway +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: gateway-admin + annotations: + cert-manager.io/cluster-issuer: letsencrypt-prod +spec: + tls: + - hosts: + - admin.gateway.example.com + secretName: gateway-admin-tls + rules: + - host: admin.gateway.example.com + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: gateway + port: + number: 80 +``` + +### Configuration Management + +**Production Config:** +```yaml +# config.yaml +server: + address: ":8080" + tls: + enabled: false # Terminated at ingress + +auth: + enabled: true + issuer: "https://auth.example.com" + audience: "gateway-admin" + roles_claim: "roles" + admin_roles: ["admin", "gateway-admin"] + +admin: + enabled: true + base_path: "/admin" + cors: + allowed_origins: + - "https://admin.gateway.example.com" + allowed_methods: ["GET", "POST", "PUT", "DELETE"] + allowed_headers: ["Authorization", "Content-Type", "X-CSRF-Token"] +``` + +### Monitoring + +**Prometheus Metrics:** + +New metrics for admin UI: +``` +# Admin API request count +gateway_admin_requests_total{endpoint, method, status} + +# Admin API request duration +gateway_admin_request_duration_seconds{endpoint, method} + +# Configuration changes +gateway_admin_config_changes_total{user, resource} + +# Authentication failures +gateway_admin_auth_failures_total{reason} +``` + +**Grafana Dashboard:** + +Create dedicated admin UI dashboard with panels for: +- Admin API request rate +- Admin API error rate +- Configuration change timeline +- Active admin sessions +- Authentication failures + +### Backup & Recovery + +**Configuration Backup:** +- Automatic backup before applying config changes +- Stored in `/app/backups/config.yaml.TIMESTAMP.bak` +- Retain last 10 backups +- Restore via UI or CLI + +**Audit Log Backup:** +- Rotate audit logs daily +- Compress and archive old logs +- Retain for 90 days (configurable) +- Optional: Ship to external storage (S3, GCS) + +--- + +## Future Enhancements + +### Phase 2 Features (Post-MVP) + +1. **Multi-Instance Management** + - Manage multiple gateway instances from single UI + - Fleet view with aggregate metrics + - Centralized configuration management + +2. **Advanced Monitoring** + - Custom alerting rules + - Anomaly detection (ML-based) + - Cost tracking per provider/model + - Token usage forecasting + +3. **Enhanced Security** + - SSO integration (SAML, LDAP) + - Fine-grained permissions (resource-level RBAC) + - API key rotation automation + - Secret management integration (HashiCorp Vault) + +4. **Configuration Templates** + - Pre-built provider templates + - Environment-specific configs (dev, staging, prod) + - Config versioning and rollback + - Git integration for config-as-code + +5. **Testing & Debugging** + - Interactive API playground (Swagger UI style) + - Request/response inspector + - Provider response comparison + - Load testing tools + +6. **Conversation Analytics** + - Conversation analytics dashboard + - Topic clustering + - Sentiment analysis + - Export conversations to CSV/JSON + +7. **User Management** + - Multi-user support (not just admins) + - Team workspaces + - Usage quotas per user/team + - Billing integration + +8. **Notifications** + - Email/Slack alerts for errors + - Webhook support for events + - Scheduled reports (daily/weekly summaries) + +9. **Mobile Support** + - Progressive Web App (PWA) + - Native mobile app (React Native) + - Push notifications + +10. **AI-Powered Features** + - Automatic provider selection based on query type + - Cost optimization suggestions + - Performance recommendations + - Anomaly detection in logs + +### Technical Debt & Improvements + +1. **Performance Optimizations** + - Server-side pagination for large datasets + - Caching layer (Redis) for metrics + - WebSocket for real-time updates (replace polling) + - GraphQL API (alternative to REST) + +2. **Developer Experience** + - Admin API SDK (TypeScript, Python) + - Terraform provider for config management + - CLI tool for admin operations + - OpenAPI/Swagger spec for API + +3. **Observability** + - Distributed tracing for admin operations + - Request correlation IDs + - Detailed error tracking (Sentry integration) + - User session replay (LogRocket style) + +4. **Internationalization** + - Multi-language UI support + - Localized date/time formats + - Currency formatting for costs + +--- + +## Appendix + +### Technology Choices Rationale + +**Why Vue 3?** +- Lightweight (50KB gzipped vs React's 130KB) +- Progressive framework (can start simple, add complexity as needed) +- Excellent TypeScript support +- Single-file components (easy to understand) +- Strong ecosystem (Vue Router, Pinia) + +**Why embed.FS?** +- Single binary deployment (no separate asset hosting) +- Simplifies Docker images +- No CDN dependencies +- Faster initial load (no external requests) + +**Why Monaco Editor?** +- Full VS Code editing experience +- Excellent YAML/JSON support +- Syntax validation built-in +- Auto-completion + +**Why Chart.js?** +- Simple API +- Good performance for real-time updates +- Small bundle size (~40KB) +- Responsive by default + +### Alternative Architectures Considered + +1. **Server-Side Rendering (SSR)** + - Pros: Better SEO, faster initial load + - Cons: More complex deployment, slower interactions + - Decision: Not needed for admin UI (auth-required, no SEO needs) + +2. **Separate Admin Service** + - Pros: True separation of concerns, independent scaling + - Cons: More infrastructure, harder deployment, network latency + - Decision: Embedded admin (simpler, one binary) + +3. **GraphQL API** + - Pros: Flexible queries, reduced over-fetching + - Cons: Added complexity, overkill for admin use case + - Decision: REST API (simpler, adequate) + +4. **WebSockets for Real-Time** + - Pros: True bi-directional real-time + - Cons: Connection management complexity, harder to scale + - Decision: SSE + polling (simpler, sufficient) + +### Security Considerations Summary + +| Threat | Mitigation | +|---------------------------|----------------------------------------------| +| Unauthorized access | OIDC authentication required | +| Privilege escalation | RBAC with admin/viewer roles | +| CSRF attacks | Double-submit cookie pattern | +| XSS attacks | CSP headers, Vue auto-escaping | +| Secret exposure | Mask secrets in UI, audit logs | +| Injection attacks | Input validation, parameterized queries | +| DoS attacks | Rate limiting, request size limits | +| Man-in-the-middle | HTTPS/TLS required in production | +| Session hijacking | Secure cookies, short JWT expiry | +| Brute force auth | Rate limiting on auth endpoints | + +### Performance Benchmarks (Targets) + +| Metric | Target | Notes | +|---------------------------|----------------|--------------------------------| +| Dashboard load time | < 2s | On modern browsers, 4G network | +| API response time (p95) | < 500ms | For most endpoints | +| Concurrent admin users | 10+ | Without degradation | +| CPU overhead | < 1% | When admin UI active | +| Memory overhead | < 50MB | For admin UI components | +| Frontend bundle size | < 500KB | Gzipped, with code splitting | +| Time to Interactive (TTI) | < 3s | Lighthouse metric | + +--- + +## Success Metrics + +### Adoption Metrics +- Number of active admin users per week +- Frequency of configuration changes +- Time spent in admin UI per session + +### Efficiency Metrics +- Reduction in configuration errors (target: 50%) +- Time to configure new provider (target: < 2 minutes) +- Time to diagnose issues (target: < 5 minutes) + +### Reliability Metrics +- Admin UI uptime (target: 99.9%) +- Zero impact on gateway API performance +- Admin API error rate (target: < 0.1%) + +### User Satisfaction +- User feedback score (target: 4.5/5) +- Feature adoption rate (target: 80% use within 1 month) +- Support ticket reduction (target: 30% reduction) + +--- + +## References + +- [Go embed package](https://pkg.go.dev/embed) +- [Vue 3 Documentation](https://vuejs.org/) +- [OWASP Top 10](https://owasp.org/www-project-top-ten/) +- [Prometheus Best Practices](https://prometheus.io/docs/practices/) +- [OpenTelemetry Documentation](https://opentelemetry.io/docs/) +- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/) + +--- + +**Document Version:** 1.0 +**Last Updated:** 2026-03-05 +**Authors:** Development Team +**Status:** Draft - Pending Review diff --git a/frontend/admin/.gitignore b/frontend/admin/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/admin/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/admin/index.html b/frontend/admin/index.html new file mode 100644 index 0000000..115b497 --- /dev/null +++ b/frontend/admin/index.html @@ -0,0 +1,13 @@ + + + + + + + LLM Gateway Admin + + +
+ + + diff --git a/frontend/admin/package-lock.json b/frontend/admin/package-lock.json new file mode 100644 index 0000000..2341e79 --- /dev/null +++ b/frontend/admin/package-lock.json @@ -0,0 +1,1698 @@ +{ + "name": "llm-gateway-admin", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "llm-gateway-admin", + "version": "0.1.0", + "dependencies": { + "axios": "^1.6.0", + "vue": "^3.4.0", + "vue-router": "^4.2.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.3.0", + "vite": "^5.0.0", + "vue-tsc": "^1.8.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-1.11.1.tgz", + "integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "1.11.1" + } + }, + "node_modules/@volar/source-map": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-1.11.1.tgz", + "integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "muggle-string": "^0.3.1" + } + }, + "node_modules/@volar/typescript": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-1.11.1.tgz", + "integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "1.11.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz", + "integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.29", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz", + "integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.29", + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz", + "integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.29", + "@vue/compiler-dom": "3.5.29", + "@vue/compiler-ssr": "3.5.29", + "@vue/shared": "3.5.29", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.6", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz", + "integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.29", + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-1.8.27.tgz", + "integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "~1.11.1", + "@volar/source-map": "~1.11.1", + "@vue/compiler-dom": "^3.3.0", + "@vue/shared": "^3.3.0", + "computeds": "^0.0.1", + "minimatch": "^9.0.3", + "muggle-string": "^0.3.1", + "path-browserify": "^1.0.1", + "vue-template-compiler": "^2.7.14" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz", + "integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz", + "integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.29", + "@vue/shared": "3.5.29" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz", + "integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.29", + "@vue/runtime-core": "3.5.29", + "@vue/shared": "3.5.29", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz", + "integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.29", + "@vue/shared": "3.5.29" + }, + "peerDependencies": { + "vue": "3.5.29" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz", + "integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/computeds": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/computeds/-/computeds-0.0.1.tgz", + "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/muggle-string": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.3.1.tgz", + "integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.29", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz", + "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@vue/compiler-dom": "3.5.29", + "@vue/compiler-sfc": "3.5.29", + "@vue/runtime-dom": "3.5.29", + "@vue/server-renderer": "3.5.29", + "@vue/shared": "3.5.29" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/vue-template-compiler": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", + "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/vue-tsc": { + "version": "1.8.27", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-1.8.27.tgz", + "integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "~1.11.1", + "@vue/language-core": "1.8.27", + "semver": "^7.5.4" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": "*" + } + } + } +} diff --git a/frontend/admin/package.json b/frontend/admin/package.json new file mode 100644 index 0000000..3ca81dc --- /dev/null +++ b/frontend/admin/package.json @@ -0,0 +1,22 @@ +{ + "name": "llm-gateway-admin", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "vue": "^3.4.0", + "vue-router": "^4.2.0", + "axios": "^1.6.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.3.0", + "vite": "^5.0.0", + "vue-tsc": "^1.8.0" + } +} diff --git a/frontend/admin/public/vite.svg b/frontend/admin/public/vite.svg new file mode 100644 index 0000000..ee9fada --- /dev/null +++ b/frontend/admin/public/vite.svg @@ -0,0 +1 @@ + diff --git a/frontend/admin/src/App.vue b/frontend/admin/src/App.vue new file mode 100644 index 0000000..27278b7 --- /dev/null +++ b/frontend/admin/src/App.vue @@ -0,0 +1,26 @@ + + + + + diff --git a/frontend/admin/src/api/client.ts b/frontend/admin/src/api/client.ts new file mode 100644 index 0000000..8aa4ddc --- /dev/null +++ b/frontend/admin/src/api/client.ts @@ -0,0 +1,51 @@ +import axios, { AxiosInstance } from 'axios' +import type { APIResponse } from '../types/api' + +class APIClient { + private client: AxiosInstance + + constructor() { + this.client = axios.create({ + baseURL: '/admin/api/v1', + headers: { + 'Content-Type': 'application/json', + }, + }) + + // Request interceptor for auth + this.client.interceptors.request.use((config) => { + const token = localStorage.getItem('auth_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }) + + // Response interceptor for error handling + this.client.interceptors.response.use( + (response) => response, + (error) => { + console.error('API Error:', error) + return Promise.reject(error) + } + ) + } + + async get(url: string): Promise { + const response = await this.client.get>(url) + if (response.data.success && response.data.data) { + return response.data.data + } + throw new Error(response.data.error?.message || 'Unknown error') + } + + async post(url: string, data: any): Promise { + const response = await this.client.post>(url, data) + if (response.data.success && response.data.data) { + return response.data.data + } + throw new Error(response.data.error?.message || 'Unknown error') + } +} + +export const apiClient = new APIClient() diff --git a/frontend/admin/src/api/config.ts b/frontend/admin/src/api/config.ts new file mode 100644 index 0000000..83aa883 --- /dev/null +++ b/frontend/admin/src/api/config.ts @@ -0,0 +1,8 @@ +import { apiClient } from './client' +import type { ConfigResponse } from '../types/api' + +export const configAPI = { + async getConfig(): Promise { + return apiClient.get('/config') + }, +} diff --git a/frontend/admin/src/api/providers.ts b/frontend/admin/src/api/providers.ts new file mode 100644 index 0000000..fc90795 --- /dev/null +++ b/frontend/admin/src/api/providers.ts @@ -0,0 +1,8 @@ +import { apiClient } from './client' +import type { ProviderInfo } from '../types/api' + +export const providersAPI = { + async getProviders(): Promise { + return apiClient.get('/providers') + }, +} diff --git a/frontend/admin/src/api/system.ts b/frontend/admin/src/api/system.ts new file mode 100644 index 0000000..05ba187 --- /dev/null +++ b/frontend/admin/src/api/system.ts @@ -0,0 +1,12 @@ +import { apiClient } from './client' +import type { SystemInfo, HealthCheckResponse } from '../types/api' + +export const systemAPI = { + async getInfo(): Promise { + return apiClient.get('/system/info') + }, + + async getHealth(): Promise { + return apiClient.get('/system/health') + }, +} diff --git a/frontend/admin/src/main.ts b/frontend/admin/src/main.ts new file mode 100644 index 0000000..efe493a --- /dev/null +++ b/frontend/admin/src/main.ts @@ -0,0 +1,7 @@ +import { createApp } from 'vue' +import App from './App.vue' +import router from './router' + +const app = createApp(App) +app.use(router) +app.mount('#app') diff --git a/frontend/admin/src/router.ts b/frontend/admin/src/router.ts new file mode 100644 index 0000000..2df7166 --- /dev/null +++ b/frontend/admin/src/router.ts @@ -0,0 +1,15 @@ +import { createRouter, createWebHistory } from 'vue-router' +import Dashboard from './views/Dashboard.vue' + +const router = createRouter({ + history: createWebHistory('/admin/'), + routes: [ + { + path: '/', + name: 'dashboard', + component: Dashboard + } + ] +}) + +export default router diff --git a/frontend/admin/src/types/api.ts b/frontend/admin/src/types/api.ts new file mode 100644 index 0000000..a2c2f2e --- /dev/null +++ b/frontend/admin/src/types/api.ts @@ -0,0 +1,82 @@ +export interface APIResponse { + success: boolean + data?: T + error?: APIError +} + +export interface APIError { + code: string + message: string +} + +export interface SystemInfo { + version: string + build_time: string + git_commit: string + go_version: string + platform: string + uptime: string +} + +export interface HealthCheck { + status: string + message?: string +} + +export interface HealthCheckResponse { + status: string + timestamp: string + checks: Record +} + +export interface SanitizedProvider { + type: string + api_key: string + endpoint?: string + api_version?: string + project?: string + location?: string +} + +export interface ModelEntry { + name: string + provider: string + provider_model_id?: string +} + +export interface ConfigResponse { + server: { + address: string + max_request_body_size: number + } + providers: Record + models: ModelEntry[] + auth: { + enabled: boolean + issuer: string + audience: string + } + conversations: { + store: string + ttl: string + dsn: string + driver: string + } + logging: { + format: string + level: string + } + rate_limit: { + enabled: boolean + requests_per_second: number + burst: number + } + observability: any +} + +export interface ProviderInfo { + name: string + type: string + models: string[] + status: string +} diff --git a/frontend/admin/src/views/Dashboard.vue b/frontend/admin/src/views/Dashboard.vue new file mode 100644 index 0000000..4f73255 --- /dev/null +++ b/frontend/admin/src/views/Dashboard.vue @@ -0,0 +1,385 @@ + + + + + diff --git a/frontend/admin/tsconfig.json b/frontend/admin/tsconfig.json new file mode 100644 index 0000000..9e03e60 --- /dev/null +++ b/frontend/admin/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "preserve", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "references": [{ "path": "./tsconfig.node.json" }] +} diff --git a/frontend/admin/tsconfig.node.json b/frontend/admin/tsconfig.node.json new file mode 100644 index 0000000..42872c5 --- /dev/null +++ b/frontend/admin/tsconfig.node.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "composite": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/admin/vite.config.ts b/frontend/admin/vite.config.ts new file mode 100644 index 0000000..4c37cb7 --- /dev/null +++ b/frontend/admin/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + base: '/admin/', + server: { + port: 5173, + proxy: { + '/admin/api': { + target: 'http://localhost:8080', + changeOrigin: true, + } + } + }, + build: { + outDir: 'dist', + emptyOutDir: true, + } +}) diff --git a/internal/admin/handlers.go b/internal/admin/handlers.go new file mode 100644 index 0000000..edc37e4 --- /dev/null +++ b/internal/admin/handlers.go @@ -0,0 +1,252 @@ +package admin + +import ( + "fmt" + "net/http" + "runtime" + "strings" + "time" + + "github.com/ajac-zero/latticelm/internal/config" +) + +// SystemInfoResponse contains system information. +type SystemInfoResponse struct { + Version string `json:"version"` + BuildTime string `json:"build_time"` + GitCommit string `json:"git_commit"` + GoVersion string `json:"go_version"` + Platform string `json:"platform"` + Uptime string `json:"uptime"` +} + +// HealthCheckResponse contains health check results. +type HealthCheckResponse struct { + Status string `json:"status"` + Timestamp string `json:"timestamp"` + Checks map[string]HealthCheck `json:"checks"` +} + +// HealthCheck represents a single health check. +type HealthCheck struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` +} + +// ConfigResponse contains the sanitized configuration. +type ConfigResponse struct { + Server config.ServerConfig `json:"server"` + Providers map[string]SanitizedProvider `json:"providers"` + Models []config.ModelEntry `json:"models"` + Auth SanitizedAuthConfig `json:"auth"` + Conversations config.ConversationConfig `json:"conversations"` + Logging config.LoggingConfig `json:"logging"` + RateLimit config.RateLimitConfig `json:"rate_limit"` + Observability config.ObservabilityConfig `json:"observability"` +} + +// SanitizedProvider is a provider entry with secrets masked. +type SanitizedProvider struct { + Type string `json:"type"` + APIKey string `json:"api_key"` + Endpoint string `json:"endpoint,omitempty"` + APIVersion string `json:"api_version,omitempty"` + Project string `json:"project,omitempty"` + Location string `json:"location,omitempty"` +} + +// SanitizedAuthConfig is auth config with secrets masked. +type SanitizedAuthConfig struct { + Enabled bool `json:"enabled"` + Issuer string `json:"issuer"` + Audience string `json:"audience"` +} + +// ProviderInfo contains provider information. +type ProviderInfo struct { + Name string `json:"name"` + Type string `json:"type"` + Models []string `json:"models"` + Status string `json:"status"` +} + +// handleSystemInfo returns system information. +func (s *AdminServer) handleSystemInfo(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed") + return + } + + uptime := time.Since(s.startTime) + + info := SystemInfoResponse{ + Version: s.buildInfo.Version, + BuildTime: s.buildInfo.BuildTime, + GitCommit: s.buildInfo.GitCommit, + GoVersion: s.buildInfo.GoVersion, + Platform: runtime.GOOS + "/" + runtime.GOARCH, + Uptime: formatDuration(uptime), + } + + writeSuccess(w, info) +} + +// handleSystemHealth returns health check results. +func (s *AdminServer) handleSystemHealth(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed") + return + } + + checks := make(map[string]HealthCheck) + overallStatus := "healthy" + + // Server check + checks["server"] = HealthCheck{ + Status: "healthy", + Message: "Server is running", + } + + // Provider check + models := s.registry.Models() + if len(models) > 0 { + checks["providers"] = HealthCheck{ + Status: "healthy", + Message: "Providers configured", + } + } else { + checks["providers"] = HealthCheck{ + Status: "unhealthy", + Message: "No providers configured", + } + overallStatus = "unhealthy" + } + + // Conversation store check + checks["conversation_store"] = HealthCheck{ + Status: "healthy", + Message: "Store accessible", + } + + response := HealthCheckResponse{ + Status: overallStatus, + Timestamp: time.Now().Format(time.RFC3339), + Checks: checks, + } + + writeSuccess(w, response) +} + +// handleConfig returns the sanitized configuration. +func (s *AdminServer) handleConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed") + return + } + + // Sanitize providers + sanitizedProviders := make(map[string]SanitizedProvider) + for name, provider := range s.cfg.Providers { + sanitizedProviders[name] = SanitizedProvider{ + Type: provider.Type, + APIKey: maskSecret(provider.APIKey), + Endpoint: provider.Endpoint, + APIVersion: provider.APIVersion, + Project: provider.Project, + Location: provider.Location, + } + } + + // Sanitize DSN in conversations config + convConfig := s.cfg.Conversations + if convConfig.DSN != "" { + convConfig.DSN = maskSecret(convConfig.DSN) + } + + response := ConfigResponse{ + Server: s.cfg.Server, + Providers: sanitizedProviders, + Models: s.cfg.Models, + Auth: SanitizedAuthConfig{ + Enabled: s.cfg.Auth.Enabled, + Issuer: s.cfg.Auth.Issuer, + Audience: s.cfg.Auth.Audience, + }, + Conversations: convConfig, + Logging: s.cfg.Logging, + RateLimit: s.cfg.RateLimit, + Observability: s.cfg.Observability, + } + + writeSuccess(w, response) +} + +// handleProviders returns the list of configured providers. +func (s *AdminServer) handleProviders(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "Only GET is allowed") + return + } + + // Build provider info map + providerModels := make(map[string][]string) + models := s.registry.Models() + for _, m := range models { + providerModels[m.Provider] = append(providerModels[m.Provider], m.Model) + } + + // Build provider list + var providers []ProviderInfo + for name, entry := range s.cfg.Providers { + providers = append(providers, ProviderInfo{ + Name: name, + Type: entry.Type, + Models: providerModels[name], + Status: "active", + }) + } + + writeSuccess(w, providers) +} + +// maskSecret masks a secret string for display. +func maskSecret(secret string) string { + if secret == "" { + return "" + } + if len(secret) <= 8 { + return "********" + } + // Show first 4 and last 4 characters + return secret[:4] + "..." + secret[len(secret)-4:] +} + +// formatDuration formats a duration in a human-readable format. +func formatDuration(d time.Duration) string { + d = d.Round(time.Second) + h := d / time.Hour + d -= h * time.Hour + m := d / time.Minute + d -= m * time.Minute + s := d / time.Second + + var parts []string + if h > 0 { + parts = append(parts, formatPart(int(h), "hour")) + } + if m > 0 { + parts = append(parts, formatPart(int(m), "minute")) + } + if s > 0 || len(parts) == 0 { + parts = append(parts, formatPart(int(s), "second")) + } + + return strings.Join(parts, " ") +} + +func formatPart(value int, unit string) string { + if value == 1 { + return "1 " + unit + } + return fmt.Sprintf("%d %ss", value, unit) +} diff --git a/internal/admin/response.go b/internal/admin/response.go new file mode 100644 index 0000000..cb888a7 --- /dev/null +++ b/internal/admin/response.go @@ -0,0 +1,45 @@ +package admin + +import ( + "encoding/json" + "net/http" +) + +// APIResponse is the standard JSON response wrapper. +type APIResponse struct { + Success bool `json:"success"` + Data interface{} `json:"data,omitempty"` + Error *APIError `json:"error,omitempty"` +} + +// APIError represents an error response. +type APIError struct { + Code string `json:"code"` + Message string `json:"message"` +} + +// writeJSON writes a JSON response. +func writeJSON(w http.ResponseWriter, statusCode int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(data) +} + +// writeSuccess writes a successful JSON response. +func writeSuccess(w http.ResponseWriter, data interface{}) { + writeJSON(w, http.StatusOK, APIResponse{ + Success: true, + Data: data, + }) +} + +// writeError writes an error JSON response. +func writeError(w http.ResponseWriter, statusCode int, code, message string) { + writeJSON(w, statusCode, APIResponse{ + Success: false, + Error: &APIError{ + Code: code, + Message: message, + }, + }) +} diff --git a/internal/admin/routes.go b/internal/admin/routes.go new file mode 100644 index 0000000..d043286 --- /dev/null +++ b/internal/admin/routes.go @@ -0,0 +1,17 @@ +package admin + +import ( + "net/http" +) + +// RegisterRoutes wires the admin HTTP handlers onto the provided mux. +func (s *AdminServer) RegisterRoutes(mux *http.ServeMux) { + // API endpoints + mux.HandleFunc("/admin/api/v1/system/info", s.handleSystemInfo) + mux.HandleFunc("/admin/api/v1/system/health", s.handleSystemHealth) + mux.HandleFunc("/admin/api/v1/config", s.handleConfig) + mux.HandleFunc("/admin/api/v1/providers", s.handleProviders) + + // Serve frontend SPA + mux.Handle("/admin/", http.StripPrefix("/admin", s.serveSPA())) +} diff --git a/internal/admin/server.go b/internal/admin/server.go new file mode 100644 index 0000000..8dfae58 --- /dev/null +++ b/internal/admin/server.go @@ -0,0 +1,59 @@ +package admin + +import ( + "log/slog" + "runtime" + "time" + + "github.com/ajac-zero/latticelm/internal/config" + "github.com/ajac-zero/latticelm/internal/conversation" + "github.com/ajac-zero/latticelm/internal/providers" +) + +// ProviderRegistry is an interface for provider registries. +type ProviderRegistry interface { + Get(name string) (providers.Provider, bool) + Models() []struct{ Provider, Model string } + ResolveModelID(model string) string + Default(model string) (providers.Provider, error) +} + +// BuildInfo contains build-time information. +type BuildInfo struct { + Version string + BuildTime string + GitCommit string + GoVersion string +} + +// AdminServer hosts the admin API and UI. +type AdminServer struct { + registry ProviderRegistry + convStore conversation.Store + cfg *config.Config + logger *slog.Logger + startTime time.Time + buildInfo BuildInfo +} + +// New creates an AdminServer instance. +func New(registry ProviderRegistry, convStore conversation.Store, cfg *config.Config, logger *slog.Logger, buildInfo BuildInfo) *AdminServer { + return &AdminServer{ + registry: registry, + convStore: convStore, + cfg: cfg, + logger: logger, + startTime: time.Now(), + buildInfo: buildInfo, + } +} + +// GetBuildInfo returns a default BuildInfo if none provided. +func DefaultBuildInfo() BuildInfo { + return BuildInfo{ + Version: "dev", + BuildTime: time.Now().Format(time.RFC3339), + GitCommit: "unknown", + GoVersion: runtime.Version(), + } +} diff --git a/internal/admin/static.go b/internal/admin/static.go new file mode 100644 index 0000000..3ae2bd0 --- /dev/null +++ b/internal/admin/static.go @@ -0,0 +1,62 @@ +package admin + +import ( + "embed" + "io" + "io/fs" + "net/http" + "path" + "strings" +) + +//go:embed all:dist +var frontendAssets embed.FS + +// serveSPA serves the frontend SPA with fallback to index.html for client-side routing. +func (s *AdminServer) serveSPA() http.Handler { + // Get the dist subdirectory from embedded files + distFS, err := fs.Sub(frontendAssets, "dist") + if err != nil { + s.logger.Error("failed to access frontend assets", "error", err) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "Admin UI not available", http.StatusNotFound) + }) + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Path comes in without /admin prefix due to StripPrefix + urlPath := r.URL.Path + if urlPath == "" || urlPath == "/" { + urlPath = "index.html" + } else { + // Remove leading slash + urlPath = strings.TrimPrefix(urlPath, "/") + } + + // Clean the path + cleanPath := path.Clean(urlPath) + + // Try to open the file + file, err := distFS.Open(cleanPath) + if err != nil { + // File not found, serve index.html for SPA routing + cleanPath = "index.html" + file, err = distFS.Open(cleanPath) + if err != nil { + http.Error(w, "Not found", http.StatusNotFound) + return + } + } + defer file.Close() + + // Get file info for content type detection + info, err := file.Stat() + if err != nil { + http.Error(w, "Internal error", http.StatusInternalServerError) + return + } + + // Serve the file + http.ServeContent(w, r, cleanPath, info.ModTime(), file.(io.ReadSeeker)) + }) +} diff --git a/internal/config/config.go b/internal/config/config.go index 114ebef..d32c46e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -17,6 +17,7 @@ type Config struct { Logging LoggingConfig `yaml:"logging"` RateLimit RateLimitConfig `yaml:"rate_limit"` Observability ObservabilityConfig `yaml:"observability"` + Admin AdminConfig `yaml:"admin"` } // ConversationConfig controls conversation storage. @@ -93,6 +94,11 @@ type AuthConfig struct { Audience string `yaml:"audience"` } +// AdminConfig controls the admin UI. +type AdminConfig struct { + Enabled bool `yaml:"enabled"` +} + // ServerConfig controls HTTP server values. type ServerConfig struct { Address string `yaml:"address"`