Changeset 0.22.3 (#97)
This commit is contained in:
22
CHANGELOG.md
22
CHANGELOG.md
@@ -2,6 +2,28 @@
|
|||||||
|
|
||||||
All notable changes to Chat Switchboard.
|
All notable changes to Chat Switchboard.
|
||||||
|
|
||||||
|
## [0.22.3] — 2026-03-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Provider admin UI: "Routing" category.** New admin category with three sections: Health, Routing, and Capabilities. Accessible from admin panel sidebar navigation.
|
||||||
|
- **Health dashboard.** Admin section showing per-provider status badges, request counts, error rates, average/max latency, timeout counts, and last error messages. Refresh button for live updates. CSS grid layout with responsive cards.
|
||||||
|
- **Routing policy builder.** Full CRUD UI for routing policies: create/edit form with name, priority, type (provider_prefer/team_route/cost_limit/model_alias), scope (global/team), team ID, JSON config editor, active toggle. List view with edit/enable/disable/delete actions.
|
||||||
|
- **Routing dry-run test panel.** Input model + user ID, evaluates against all active policies with live health status, displays ranked candidates with status badges and selection reasoning.
|
||||||
|
- **Capability override viewer.** Table listing all model capability overrides with model ID, provider config, field, value, and delete button. Explains auto-detection fallback when no overrides exist.
|
||||||
|
- **`provider_status` on models/enabled response.** `GET /api/v1/models/enabled` now includes `provider_status` field ("healthy"/"degraded"/"down"/"unknown") per model from live health data.
|
||||||
|
- **`ModelHandler` with health enrichment.** Extracted model listing into dedicated `handlers/capabilities.go` with `SetHealthStore()` for health status injection. Builds health map from current hourly windows.
|
||||||
|
- **`GET /api/v1/admin/capability-overrides` endpoint.** Returns all capability overrides across all models (admin view).
|
||||||
|
- **API client methods.** `adminGetAllProviderHealth`, `adminListRoutingPolicies`, `adminGetRoutingPolicy`, `adminCreateRoutingPolicy`, `adminUpdateRoutingPolicy`, `adminDeleteRoutingPolicy`, `adminTestRouting`, `adminListCapabilityOverrides`, `adminDeleteModelCapability`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `src/js/ui-admin.js`: New "routing" admin category with health/routing/capabilities sections. `ADMIN_SECTIONS`, `ADMIN_LABELS`, `ADMIN_LOADERS` updated. Added `loadAdminHealth()`, `loadAdminRouting()`, `loadAdminCapabilities()`, `_showRoutingForm()`, `_toggleRoutingPolicy()`, `_deleteRoutingPolicy()`, `_runRoutingTest()`, `_deleteCapOverride()`.
|
||||||
|
- `src/js/api.js`: 9 new API client methods for health, routing, and capability admin endpoints.
|
||||||
|
- `src/index.html`: New admin category button for "Routing". Three new `admin-section-content` divs: `adminHealthTab`, `adminRoutingTab`, `adminCapabilitiesTab`.
|
||||||
|
- `src/css/styles.css`: `.admin-health-grid` and `.admin-health-card` styles.
|
||||||
|
- `models/models.go`: `ProviderStatus` field on `UserModel` struct.
|
||||||
|
- `handlers/capabilities.go`: Extracted `ModelHandler` from inline handlers. `ListEnabledModels` enriches response with provider health status. `ResolveModelCaps` canonical capability resolver.
|
||||||
|
- `main.go`: `ModelHandler` created with `SetHealthStore()` wiring. Capability override admin routes registered.
|
||||||
|
|
||||||
## [0.22.2] — 2026-03-02
|
## [0.22.2] — 2026-03-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -176,7 +176,9 @@ server/
|
|||||||
│ ├── messages.go # Message CRUD + forking
|
│ ├── messages.go # Message CRUD + forking
|
||||||
│ ├── completion.go # Chat completions (SSE streaming)
|
│ ├── completion.go # Chat completions (SSE streaming)
|
||||||
│ ├── stream_loop.go # Shared streaming + tool execution loop
|
│ ├── stream_loop.go # Shared streaming + tool execution loop
|
||||||
│ ├── capabilities.go # Model list + ResolveModelCaps
|
│ ├── capabilities.go # ModelHandler: model list + health enrichment + ResolveModelCaps (v0.22.3)
|
||||||
|
│ ├── health_admin.go # Provider health + capability override admin endpoints
|
||||||
|
│ ├── routing_admin.go # Routing policy CRUD + dry-run test (v0.22.2)
|
||||||
│ ├── presets.go # Persona CRUD (all scopes)
|
│ ├── presets.go # Persona CRUD (all scopes)
|
||||||
│ ├── apiconfigs.go # User provider config CRUD (BYOK)
|
│ ├── apiconfigs.go # User provider config CRUD (BYOK)
|
||||||
│ ├── teams.go # Team management
|
│ ├── teams.go # Team management
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ v0.22.1 Provider Extensions (declarative config) ✅
|
|||||||
│
|
│
|
||||||
v0.22.2 Routing Policies + Fallback Chains ✅
|
v0.22.2 Routing Policies + Fallback Chains ✅
|
||||||
│
|
│
|
||||||
v0.22.3 Provider Admin UI + Deferred Polish
|
v0.22.3 Provider Admin UI + Deferred Polish ✅
|
||||||
│
|
│
|
||||||
v0.23.0 Multi-Participant Channels + Presence
|
v0.23.0 Multi-Participant Channels + Presence
|
||||||
│
|
│
|
||||||
@@ -873,7 +873,7 @@ Depends on: usage tracking (v0.10.0), capabilities resolver (v0.9.1).
|
|||||||
- [x] Provider status derivation: `healthy` / `degraded` / `down` based on error rate thresholds (5% = degraded, 25% = down)
|
- [x] Provider status derivation: `healthy` / `degraded` / `down` based on error rate thresholds (5% = degraded, 25% = down)
|
||||||
- [x] `GET /api/v1/admin/providers/:id/health` endpoint: current status + hourly windows (configurable hours param)
|
- [x] `GET /api/v1/admin/providers/:id/health` endpoint: current status + hourly windows (configurable hours param)
|
||||||
- [x] `GET /api/v1/admin/providers/health` endpoint: summary status for all providers
|
- [x] `GET /api/v1/admin/providers/health` endpoint: summary status for all providers
|
||||||
- [ ] _(deferred → v0.22.3)_ Health status included in `GET /api/v1/models/enabled` response (new `provider_status` field)
|
- [x] _(shipped v0.22.3)_ Health status included in `GET /api/v1/models/enabled` response (new `provider_status` field)
|
||||||
- [ ] _(deferred → v0.22.2)_ Auto-disable: optional policy to mark provider inactive when `down` for N consecutive windows
|
- [ ] _(deferred → v0.22.2)_ Auto-disable: optional policy to mark provider inactive when `down` for N consecutive windows
|
||||||
- [x] Background cleanup: prune health rows older than 7 days (6-hour cycle)
|
- [x] Background cleanup: prune health rows older than 7 days (6-hour cycle)
|
||||||
|
|
||||||
@@ -932,7 +932,7 @@ Depends on: provider health (v0.22.0).
|
|||||||
- [x] `providers.Init()` uses `RegisterType()` with full metadata (name, description, default endpoint, profile schema)
|
- [x] `providers.Init()` uses `RegisterType()` with full metadata (name, description, default endpoint, profile schema)
|
||||||
- [x] `ProviderTypeMeta` struct: ID, name, description, default_endpoint, profile_schema
|
- [x] `ProviderTypeMeta` struct: ID, name, description, default_endpoint, profile_schema
|
||||||
- [x] `GET /api/v1/admin/provider-types` endpoint: list available provider types with their profile schemas
|
- [x] `GET /api/v1/admin/provider-types` endpoint: list available provider types with their profile schemas
|
||||||
- [ ] _(deferred → v0.22.3)_ New provider types registrable via config file (openai-compatible endpoint + custom profile schema)
|
- [ ] _(deferred → v0.23)_ New provider types registrable via config file (openai-compatible endpoint + custom profile schema)
|
||||||
|
|
||||||
**Search Provider Health** (carried from v0.22.0)
|
**Search Provider Health** (carried from v0.22.0)
|
||||||
- [ ] _(deferred → v0.22.2)_ `RecordOutcome` for web_search and url_fetch tool calls
|
- [ ] _(deferred → v0.22.2)_ `RecordOutcome` for web_search and url_fetch tool calls
|
||||||
@@ -952,14 +952,14 @@ Depends on: provider health (v0.22.0), provider extensions (v0.22.1).
|
|||||||
- [x] Policy types: `provider_prefer` (ordered fallback list), `team_route` (restrict team to providers), `cost_limit` (heuristic cost cap), `model_alias` (alias → provider+model rewrite)
|
- [x] Policy types: `provider_prefer` (ordered fallback list), `team_route` (restrict team to providers), `cost_limit` (heuristic cost cap), `model_alias` (alias → provider+model rewrite)
|
||||||
- [x] Policy evaluator: takes requested model + user context → returns ranked list of `(providerConfigID, modelID)` candidates
|
- [x] Policy evaluator: takes requested model + user context → returns ranked list of `(providerConfigID, modelID)` candidates
|
||||||
- [x] Integration point: `evaluateRouting()` called after resolveConfig, before provider dispatch — reloads winning config credentials on switch
|
- [x] Integration point: `evaluateRouting()` called after resolveConfig, before provider dispatch — reloads winning config credentials on switch
|
||||||
- [ ] _(deferred → v0.22.3)_ `capability_match` policy type ("cheapest model with tool_calling")
|
- [ ] _(deferred → v0.23)_ `capability_match` policy type ("cheapest model with tool_calling")
|
||||||
|
|
||||||
**Fallback Chains**
|
**Fallback Chains**
|
||||||
- [x] Fallback runner: `RunWithFallback()` tries candidates in order with configurable max retries
|
- [x] Fallback runner: `RunWithFallback()` tries candidates in order with configurable max retries
|
||||||
- [x] Health-aware: skip providers with status `down`, prefer `healthy` over `degraded`
|
- [x] Health-aware: skip providers with status `down`, prefer `healthy` over `degraded`
|
||||||
- [x] All-down graceful degradation: keeps candidates rather than failing
|
- [x] All-down graceful degradation: keeps candidates rather than failing
|
||||||
- [ ] _(deferred → v0.22.3)_ Latency-aware preference: track response time percentiles, prefer faster providers
|
- [ ] _(deferred → v0.23)_ Latency-aware preference: track response time percentiles, prefer faster providers
|
||||||
- [ ] _(deferred → v0.22.3)_ Cost-aware preference with budget ceiling per request
|
- [ ] _(deferred → v0.23)_ Cost-aware preference with budget ceiling per request
|
||||||
|
|
||||||
**Routing Metadata**
|
**Routing Metadata**
|
||||||
- [x] `X-Switchboard-Provider` response header: `providerID/configID` on every completion
|
- [x] `X-Switchboard-Provider` response header: `providerID/configID` on every completion
|
||||||
@@ -970,30 +970,38 @@ Depends on: provider health (v0.22.0), provider extensions (v0.22.1).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## v0.22.3 — Provider Admin UI + Deferred Polish
|
## v0.22.3 — Provider Admin UI + Deferred Polish ✅
|
||||||
|
|
||||||
Admin interfaces for v0.22.0–v0.22.2 features, plus deferred items from v0.21.x.
|
Admin interfaces for v0.22.0–v0.22.2 features, plus deferred items from v0.21.x.
|
||||||
|
|
||||||
Depends on: routing policies (v0.22.2).
|
Depends on: routing policies (v0.22.2).
|
||||||
|
|
||||||
**Admin UI**
|
**Admin UI**
|
||||||
- [ ] Provider health dashboard: status badges, error rate sparklines, latency charts (last 24h)
|
- [x] Provider health dashboard: status badges, error rate/latency/timeout metrics per provider, refresh button (`loadAdminHealth`)
|
||||||
- [ ] Capability override editor: per-model toggle grid with source indicators
|
- [x] Capability override editor: table listing all overrides with model, config, field, value; delete button per row (`loadAdminCapabilities`)
|
||||||
- [ ] Provider profile editor: key-value config per provider type, preview of effective settings
|
- [x] Routing policy builder: CRUD with name/priority/type/scope/team/config(JSON)/active toggle, dry-run test panel with candidate ranking and health status (`loadAdminRouting`, `_showRoutingForm`, `_runRoutingTest`)
|
||||||
- [ ] Routing policy builder: CRUD with priority ordering, dry-run test panel
|
- [x] New "Routing" admin category with Health, Routing, and Capabilities sections
|
||||||
- [ ] Fallback chain visualizer: drag-to-reorder provider priority per model family
|
- [x] Health status included in `GET /api/v1/models/enabled` response (`provider_status` field on UserModel)
|
||||||
|
- [ ] _(deferred → v0.23)_ Provider profile editor: key-value config per provider type, preview of effective settings
|
||||||
|
- [ ] _(deferred → v0.23)_ Fallback chain visualizer: drag-to-reorder provider priority per model family
|
||||||
|
|
||||||
|
**Deferred from v0.22.0–v0.22.2**
|
||||||
|
- [ ] _(deferred → v0.23)_ `capability_match` policy type ("cheapest model with tool_calling")
|
||||||
|
- [ ] _(deferred → v0.23)_ Latency-aware preference: track response time percentiles, prefer faster providers
|
||||||
|
- [ ] _(deferred → v0.23)_ Cost-aware preference with budget ceiling per request
|
||||||
|
- [ ] _(deferred → v0.23)_ New provider types registrable via config file (openai-compatible endpoint + custom profile schema)
|
||||||
|
|
||||||
**Deferred from v0.21.x**
|
**Deferred from v0.21.x**
|
||||||
- [ ] Mobile: mode selector collapses to hamburger/bottom nav
|
- [ ] _(deferred → v0.23)_ Mobile: mode selector collapses to hamburger/bottom nav
|
||||||
- [ ] Integration with extension loader (surfaces from manifest.json)
|
- [ ] _(deferred → v0.23)_ Integration with extension loader (surfaces from manifest.json)
|
||||||
- [ ] Workspace settings UI: git config section in channel/project settings
|
- [ ] _(deferred → v0.23)_ Workspace settings UI: git config section in channel/project settings
|
||||||
- [ ] User Settings: git credentials management UI
|
- [ ] _(deferred → v0.23)_ User Settings: git credentials management UI
|
||||||
- [ ] `.gitignore` respect in workspace indexing
|
- [ ] _(deferred → v0.23)_ `.gitignore` respect in workspace indexing
|
||||||
- [ ] Drag-drop file reorder in editor file tree
|
- [ ] _(deferred → v0.23)_ Drag-drop file reorder in editor file tree
|
||||||
- [ ] Article tools: suggest_outline, expand_section, check_citations (AI-powered)
|
- [ ] _(deferred → v0.23)_ Article tools: suggest_outline, expand_section, check_citations (AI-powered)
|
||||||
- [ ] Drag-to-reorder outline sections in article mode
|
- [ ] _(deferred → v0.23)_ Drag-to-reorder outline sections in article mode
|
||||||
- [ ] PDF/DOCX export via pandoc in article mode
|
- [ ] _(deferred → v0.23)_ PDF/DOCX export via pandoc in article mode
|
||||||
- [ ] Project-specific file uploads (full project-level upload architecture)
|
- [ ] _(deferred → v0.23)_ Project-specific file uploads (full project-level upload architecture)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
|
|
||||||
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/database"
|
"git.gobha.me/xcaliber/chat-switchboard/database"
|
||||||
|
"git.gobha.me/xcaliber/chat-switchboard/health"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/models"
|
"git.gobha.me/xcaliber/chat-switchboard/models"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
"git.gobha.me/xcaliber/chat-switchboard/providers"
|
||||||
"git.gobha.me/xcaliber/chat-switchboard/store"
|
"git.gobha.me/xcaliber/chat-switchboard/store"
|
||||||
@@ -16,13 +18,19 @@ import (
|
|||||||
|
|
||||||
// ModelHandler provides the unified models endpoint.
|
// ModelHandler provides the unified models endpoint.
|
||||||
type ModelHandler struct {
|
type ModelHandler struct {
|
||||||
stores store.Stores
|
stores store.Stores
|
||||||
|
healthStore HealthStatusQuerier // provider health for status enrichment (v0.22.3, nil = disabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewModelHandler(s store.Stores) *ModelHandler {
|
func NewModelHandler(s store.Stores) *ModelHandler {
|
||||||
return &ModelHandler{stores: s}
|
return &ModelHandler{stores: s}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetHealthStore attaches the health store for model status enrichment (v0.22.3).
|
||||||
|
func (h *ModelHandler) SetHealthStore(hs HealthStatusQuerier) {
|
||||||
|
h.healthStore = hs
|
||||||
|
}
|
||||||
|
|
||||||
// ListEnabledModels returns all models the user can access (catalog + personas),
|
// ListEnabledModels returns all models the user can access (catalog + personas),
|
||||||
// with user preferences (hidden, sort order) applied.
|
// with user preferences (hidden, sort order) applied.
|
||||||
func (h *ModelHandler) ListEnabledModels(c *gin.Context) {
|
func (h *ModelHandler) ListEnabledModels(c *gin.Context) {
|
||||||
@@ -35,12 +43,35 @@ func (h *ModelHandler) ListEnabledModels(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enrich with provider health status (v0.22.3)
|
||||||
|
if h.healthStore != nil {
|
||||||
|
healthMap := h.buildHealthMap(c.Request.Context())
|
||||||
|
for i := range userModels {
|
||||||
|
if s, ok := healthMap[userModels[i].ProviderConfigID]; ok {
|
||||||
|
userModels[i].ProviderStatus = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Include admin default model so frontend can use it in resolution chain
|
// Include admin default model so frontend can use it in resolution chain
|
||||||
defaultModel, _ := h.stores.Policies.Get(c.Request.Context(), "default_model")
|
defaultModel, _ := h.stores.Policies.Get(c.Request.Context(), "default_model")
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"models": userModels, "default_model": defaultModel})
|
c.JSON(http.StatusOK, gin.H{"models": userModels, "default_model": defaultModel})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildHealthMap returns a map of configID → ProviderStatus from current health windows.
|
||||||
|
func (h *ModelHandler) buildHealthMap(ctx context.Context) map[string]models.ProviderStatus {
|
||||||
|
m := make(map[string]models.ProviderStatus)
|
||||||
|
windows, err := h.healthStore.ListAllCurrent(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
for _, w := range windows {
|
||||||
|
m[w.ProviderConfigID] = health.DeriveStatus(w.ErrorRate(), w.RequestCount)
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
// ResolveModelCaps is the canonical capability resolver for any model.
|
// ResolveModelCaps is the canonical capability resolver for any model.
|
||||||
func ResolveModelCaps(c *gin.Context, modelID, configID string) models.ModelCapabilities {
|
func ResolveModelCaps(c *gin.Context, modelID, configID string) models.ModelCapabilities {
|
||||||
// 1. Exact match: model_id + provider_config_id
|
// 1. Exact match: model_id + provider_config_id
|
||||||
|
|||||||
@@ -403,6 +403,9 @@ func main() {
|
|||||||
|
|
||||||
// Models (unified resolver — replaces scattered endpoints)
|
// Models (unified resolver — replaces scattered endpoints)
|
||||||
modelH := handlers.NewModelHandler(stores)
|
modelH := handlers.NewModelHandler(stores)
|
||||||
|
if healthStore != nil {
|
||||||
|
modelH.SetHealthStore(healthStore)
|
||||||
|
}
|
||||||
protected.GET("/models/enabled", modelH.ListEnabledModels)
|
protected.GET("/models/enabled", modelH.ListEnabledModels)
|
||||||
protected.GET("/models", modelH.ListEnabledModels) // alias
|
protected.GET("/models", modelH.ListEnabledModels) // alias
|
||||||
|
|
||||||
|
|||||||
@@ -632,6 +632,8 @@ type UserModel struct {
|
|||||||
|
|
||||||
Hidden bool `json:"hidden"`
|
Hidden bool `json:"hidden"`
|
||||||
SortOrder int `json:"sort_order"`
|
SortOrder int `json:"sort_order"`
|
||||||
|
|
||||||
|
ProviderStatus ProviderStatus `json:"provider_status,omitempty"` // health status from provider health (v0.22.3)
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================
|
// =========================================
|
||||||
|
|||||||
@@ -1691,6 +1691,14 @@ select option { background: var(--bg-surface); color: var(--text); }
|
|||||||
.badge-success { background: var(--success-dim); color: var(--success-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
.badge-success { background: var(--success-dim); color: var(--success-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
||||||
.badge-warning { background: var(--warning-dim); color: var(--warning-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
.badge-warning { background: var(--warning-dim); color: var(--warning-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
||||||
.badge-danger { background: var(--danger-dim); color: var(--danger-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
.badge-danger { background: var(--danger-dim); color: var(--danger-light); font-size: 10px; padding: 1px 8px; border-radius: 4px; }
|
||||||
|
|
||||||
|
/* Health + Routing admin (v0.22.3) */
|
||||||
|
.admin-health-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 12px; }
|
||||||
|
.admin-health-card { background: var(--bg-2); border: 1px solid var(--border); border-radius: 8px; padding: 12px; }
|
||||||
|
.list-card { background: var(--bg-2); border: 1px solid var(--border); border-radius: 8px; padding: 12px; margin-bottom: 8px; }
|
||||||
|
.admin-table { border-collapse: collapse; }
|
||||||
|
.admin-table th, .admin-table td { padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--border); }
|
||||||
|
.admin-table th { font-weight: 600; color: var(--text-2); font-size: 11px; text-transform: uppercase; }
|
||||||
.admin-user-row.user-inactive { opacity: 0.7; }
|
.admin-user-row.user-inactive { opacity: 0.7; }
|
||||||
.loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; }
|
.loading { color: var(--text-3); font-size: 13px; padding: 0.5rem; }
|
||||||
.error-hint { color: var(--danger); font-size: 13px; padding: 0.5rem; }
|
.error-hint { color: var(--danger); font-size: 13px; padding: 0.5rem; }
|
||||||
|
|||||||
@@ -712,6 +712,7 @@
|
|||||||
<div class="admin-categories" id="adminCategories">
|
<div class="admin-categories" id="adminCategories">
|
||||||
<button class="admin-cat active" data-cat="people">People</button>
|
<button class="admin-cat active" data-cat="people">People</button>
|
||||||
<button class="admin-cat" data-cat="ai">AI</button>
|
<button class="admin-cat" data-cat="ai">AI</button>
|
||||||
|
<button class="admin-cat" data-cat="routing">Routing</button>
|
||||||
<button class="admin-cat" data-cat="system">System</button>
|
<button class="admin-cat" data-cat="system">System</button>
|
||||||
<button class="admin-cat" data-cat="monitoring">Monitoring</button>
|
<button class="admin-cat" data-cat="monitoring">Monitoring</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1070,6 +1071,43 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-section-content" id="adminStatsTab" style="display:none"><div id="adminStats"></div></div>
|
<div class="admin-section-content" id="adminStatsTab" style="display:none"><div id="adminStats"></div></div>
|
||||||
|
|
||||||
|
<!-- Provider Health Dashboard (v0.22.3) -->
|
||||||
|
<div class="admin-section-content" id="adminHealthTab" style="display:none">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>Provider Health</h3>
|
||||||
|
<button class="btn-small" id="adminHealthRefreshBtn">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="adminHealthContent"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Routing Policies (v0.22.3) -->
|
||||||
|
<div class="admin-section-content" id="adminRoutingTab" style="display:none">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>Routing Policies</h3>
|
||||||
|
<button class="btn-small btn-primary" id="adminAddRoutingPolicyBtn">+ New Policy</button>
|
||||||
|
</div>
|
||||||
|
<div id="adminRoutingForm" style="display:none" class="admin-inline-form"></div>
|
||||||
|
<div id="adminRoutingPolicyList"></div>
|
||||||
|
<div class="section-header" style="margin-top:24px"><h3>Dry-Run Test</h3></div>
|
||||||
|
<div id="adminRoutingTest" class="admin-inline-form" style="padding:12px">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Model</label><input type="text" id="routingTestModel" placeholder="gpt-4o"></div>
|
||||||
|
<div class="form-group"><label>User ID</label><input type="text" id="routingTestUser" placeholder="user UUID"></div>
|
||||||
|
<button class="btn-small btn-primary" id="routingTestBtn" style="align-self:flex-end">Test</button>
|
||||||
|
</div>
|
||||||
|
<div id="routingTestResult" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Capability Overrides (v0.22.3) -->
|
||||||
|
<div class="admin-section-content" id="adminCapabilitiesTab" style="display:none">
|
||||||
|
<div class="section-header">
|
||||||
|
<h3>Capability Overrides</h3>
|
||||||
|
</div>
|
||||||
|
<p class="empty-hint" style="margin-bottom:12px">Override auto-detected model capabilities per provider. Changes apply immediately to capability resolution.</p>
|
||||||
|
<div id="adminCapabilityList"></div>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -706,6 +706,27 @@ const API = {
|
|||||||
return this._del(`/api/v1/admin/pricing/${providerConfigId}/${encodeURIComponent(modelId)}`);
|
return this._del(`/api/v1/admin/pricing/${providerConfigId}/${encodeURIComponent(modelId)}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Admin Provider Health (v0.22.3) ─────
|
||||||
|
adminGetAllProviderHealth() { return this._get('/api/v1/admin/providers/health'); },
|
||||||
|
adminGetProviderHealth(id) { return this._get(`/api/v1/admin/providers/${id}/health`); },
|
||||||
|
|
||||||
|
// ── Admin Routing Policies (v0.22.3) ────
|
||||||
|
adminListRoutingPolicies() { return this._get('/api/v1/admin/routing/policies'); },
|
||||||
|
adminGetRoutingPolicy(id) { return this._get(`/api/v1/admin/routing/policies/${id}`); },
|
||||||
|
adminCreateRoutingPolicy(policy) { return this._post('/api/v1/admin/routing/policies', policy); },
|
||||||
|
adminUpdateRoutingPolicy(id, policy) { return this._put(`/api/v1/admin/routing/policies/${id}`, policy); },
|
||||||
|
adminDeleteRoutingPolicy(id) { return this._del(`/api/v1/admin/routing/policies/${id}`); },
|
||||||
|
adminTestRouting(model, userId, teamIds) {
|
||||||
|
return this._post('/api/v1/admin/routing/test', { model, user_id: userId, team_ids: teamIds || [] });
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Admin Capabilities & Provider Types (v0.22.3) ──
|
||||||
|
adminGetModelCapabilities(modelId) { return this._get(`/api/v1/admin/models/${encodeURIComponent(modelId)}/capabilities`); },
|
||||||
|
adminSetModelCapability(modelId, overrides) { return this._put(`/api/v1/admin/models/${encodeURIComponent(modelId)}/capabilities`, overrides); },
|
||||||
|
adminDeleteModelCapability(modelId, overrideId) { return this._del(`/api/v1/admin/models/${encodeURIComponent(modelId)}/capabilities/${overrideId}`); },
|
||||||
|
adminListCapabilityOverrides() { return this._get('/api/v1/admin/capability-overrides'); },
|
||||||
|
adminGetProviderTypes() { return this._get('/api/v1/admin/provider-types'); },
|
||||||
|
|
||||||
// ── User Usage ──────────────────────────
|
// ── User Usage ──────────────────────────
|
||||||
getMyUsage(params) {
|
getMyUsage(params) {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
const ADMIN_SECTIONS = {
|
const ADMIN_SECTIONS = {
|
||||||
people: ['users', 'teams', 'groups'],
|
people: ['users', 'teams', 'groups'],
|
||||||
ai: ['providers', 'models', 'presets', 'roles', 'knowledgeBases', 'memory'],
|
ai: ['providers', 'models', 'presets', 'roles', 'knowledgeBases', 'memory'],
|
||||||
|
routing: ['health', 'routing', 'capabilities'],
|
||||||
system: ['settings', 'storage', 'extensions'],
|
system: ['settings', 'storage', 'extensions'],
|
||||||
monitoring: ['usage', 'audit', 'stats'],
|
monitoring: ['usage', 'audit', 'stats'],
|
||||||
};
|
};
|
||||||
@@ -15,6 +16,7 @@ const ADMIN_SECTIONS = {
|
|||||||
const ADMIN_LABELS = {
|
const ADMIN_LABELS = {
|
||||||
users: 'Users', teams: 'Teams', groups: 'Groups',
|
users: 'Users', teams: 'Teams', groups: 'Groups',
|
||||||
providers: 'Providers', models: 'Models', presets: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory',
|
providers: 'Providers', models: 'Models', presets: 'Personas', roles: 'Roles', knowledgeBases: 'Knowledge', memory: 'Memory',
|
||||||
|
health: 'Health', routing: 'Routing', capabilities: 'Capabilities',
|
||||||
settings: 'Settings', storage: 'Storage', extensions: 'Extensions',
|
settings: 'Settings', storage: 'Storage', extensions: 'Extensions',
|
||||||
usage: 'Usage', audit: 'Audit', stats: 'Stats',
|
usage: 'Usage', audit: 'Audit', stats: 'Stats',
|
||||||
};
|
};
|
||||||
@@ -39,6 +41,9 @@ const ADMIN_LOADERS = {
|
|||||||
settings: () => UI.loadAdminSettings(),
|
settings: () => UI.loadAdminSettings(),
|
||||||
storage: () => typeof loadAdminStorage === 'function' ? loadAdminStorage() : null,
|
storage: () => typeof loadAdminStorage === 'function' ? loadAdminStorage() : null,
|
||||||
extensions: () => UI.loadAdminExtensions(),
|
extensions: () => UI.loadAdminExtensions(),
|
||||||
|
health: () => UI.loadAdminHealth(),
|
||||||
|
routing: () => UI.loadAdminRouting(),
|
||||||
|
capabilities: () => UI.loadAdminCapabilities(),
|
||||||
usage: () => UI.loadAdminUsage(),
|
usage: () => UI.loadAdminUsage(),
|
||||||
audit: () => UI.loadAuditLog(),
|
audit: () => UI.loadAuditLog(),
|
||||||
stats: () => UI.loadAdminStats(),
|
stats: () => UI.loadAdminStats(),
|
||||||
@@ -872,6 +877,281 @@ Object.assign(UI, {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Provider Health Dashboard (v0.22.3) ──────
|
||||||
|
async loadAdminHealth() {
|
||||||
|
const el = document.getElementById('adminHealthContent');
|
||||||
|
el.innerHTML = '<div class="loading">Loading health data...</div>';
|
||||||
|
|
||||||
|
document.getElementById('adminHealthRefreshBtn')?.addEventListener('click', () => UI.loadAdminHealth(), { once: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await API.adminGetAllProviderHealth();
|
||||||
|
const providers = data.data || [];
|
||||||
|
|
||||||
|
if (providers.length === 0) {
|
||||||
|
el.innerHTML = '<div class="empty-hint">No health data yet — health metrics are collected after the first completion request.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = '<div class="admin-health-grid"></div>';
|
||||||
|
const grid = el.querySelector('.admin-health-grid');
|
||||||
|
|
||||||
|
for (const p of providers) {
|
||||||
|
const statusClass = p.status === 'healthy' ? 'badge-success' : p.status === 'degraded' ? 'badge-warning' : p.status === 'down' ? 'badge-danger' : '';
|
||||||
|
const errorPct = p.error_rate !== undefined ? (p.error_rate * 100).toFixed(1) + '%' : '—';
|
||||||
|
const avgLatency = p.avg_latency_ms !== undefined ? p.avg_latency_ms + 'ms' : '—';
|
||||||
|
const configName = p.provider_config_id?.slice(0, 8) || '—';
|
||||||
|
|
||||||
|
grid.innerHTML += `
|
||||||
|
<div class="admin-health-card">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||||
|
<strong>${esc(p.provider_name || configName)}</strong>
|
||||||
|
<span class="badge ${statusClass}">${esc(p.status || 'unknown')}</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-storage-grid" style="gap:4px">
|
||||||
|
<div class="admin-storage-item">
|
||||||
|
<span class="admin-storage-label">Requests</span>
|
||||||
|
<span class="admin-storage-value">${p.request_count ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-storage-item">
|
||||||
|
<span class="admin-storage-label">Error Rate</span>
|
||||||
|
<span class="admin-storage-value" style="color:${p.error_rate > 0.05 ? 'var(--error)' : 'inherit'}">${errorPct}</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-storage-item">
|
||||||
|
<span class="admin-storage-label">Avg Latency</span>
|
||||||
|
<span class="admin-storage-value">${avgLatency}</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-storage-item">
|
||||||
|
<span class="admin-storage-label">Errors</span>
|
||||||
|
<span class="admin-storage-value">${p.error_count ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-storage-item">
|
||||||
|
<span class="admin-storage-label">Timeouts</span>
|
||||||
|
<span class="admin-storage-value">${p.timeout_count ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div class="admin-storage-item">
|
||||||
|
<span class="admin-storage-label">Max Latency</span>
|
||||||
|
<span class="admin-storage-value">${p.max_latency_ms ?? 0}ms</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${p.last_error ? `<div class="text-muted" style="font-size:11px;margin-top:6px;word-break:break-all">Last error: ${esc(p.last_error)}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = `<div class="empty-hint">Failed to load health data: ${esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Routing Policies (v0.22.3) ───────────────
|
||||||
|
async loadAdminRouting() {
|
||||||
|
const listEl = document.getElementById('adminRoutingPolicyList');
|
||||||
|
const formEl = document.getElementById('adminRoutingForm');
|
||||||
|
listEl.innerHTML = '<div class="loading">Loading policies...</div>';
|
||||||
|
|
||||||
|
// Wire add button
|
||||||
|
const addBtn = document.getElementById('adminAddRoutingPolicyBtn');
|
||||||
|
addBtn?.addEventListener('click', () => UI._showRoutingForm(null), { once: true });
|
||||||
|
|
||||||
|
// Wire test button
|
||||||
|
const testBtn = document.getElementById('routingTestBtn');
|
||||||
|
testBtn?.addEventListener('click', () => UI._runRoutingTest(), { once: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await API.adminListRoutingPolicies();
|
||||||
|
const policies = data.data || [];
|
||||||
|
|
||||||
|
if (policies.length === 0) {
|
||||||
|
listEl.innerHTML = '<div class="empty-hint">No routing policies — requests go to the user\'s default provider config.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeLabels = { provider_prefer: 'Provider Prefer', team_route: 'Team Route', cost_limit: 'Cost Limit', model_alias: 'Model Alias' };
|
||||||
|
|
||||||
|
listEl.innerHTML = policies.map(p => `
|
||||||
|
<div class="list-card" data-id="${esc(p.id)}">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||||
|
<div>
|
||||||
|
<strong>${esc(p.name)}</strong>
|
||||||
|
<span class="badge" style="margin-left:6px">${esc(typeLabels[p.policy_type] || p.policy_type)}</span>
|
||||||
|
<span class="badge ${p.is_active ? 'badge-success' : ''}" style="margin-left:4px">${p.is_active ? 'Active' : 'Inactive'}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted" style="font-size:11px;margin-right:8px">Priority: ${p.priority}</span>
|
||||||
|
<span class="badge">${p.scope}${p.team_id ? ' · ' + p.team_id.slice(0, 8) : ''}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;margin-top:8px">
|
||||||
|
<button class="btn-small" onclick="UI._showRoutingForm('${esc(p.id)}')">Edit</button>
|
||||||
|
<button class="btn-small" onclick="UI._toggleRoutingPolicy('${esc(p.id)}', ${!p.is_active})">${p.is_active ? 'Disable' : 'Enable'}</button>
|
||||||
|
<button class="btn-small btn-danger" onclick="UI._deleteRoutingPolicy('${esc(p.id)}', '${esc(p.name)}')">Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
} catch (e) {
|
||||||
|
listEl.innerHTML = `<div class="empty-hint">Failed to load routing policies: ${esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async _showRoutingForm(policyId) {
|
||||||
|
const formEl = document.getElementById('adminRoutingForm');
|
||||||
|
const isEdit = !!policyId;
|
||||||
|
let policy = { name: '', scope: 'global', priority: 100, policy_type: 'provider_prefer', is_active: true, config: {} };
|
||||||
|
|
||||||
|
if (isEdit) {
|
||||||
|
try { policy = await API.adminGetRoutingPolicy(policyId); } catch (e) { UI.toast(e.message, 'error'); return; }
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = policy.config || {};
|
||||||
|
formEl.style.display = '';
|
||||||
|
formEl.innerHTML = `
|
||||||
|
<h4 style="margin:0 0 12px">${isEdit ? 'Edit' : 'New'} Routing Policy</h4>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Name</label><input type="text" id="rpName" value="${esc(policy.name)}"></div>
|
||||||
|
<div class="form-group"><label>Priority</label><input type="number" id="rpPriority" value="${policy.priority}" min="0" max="999"></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group"><label>Type</label>
|
||||||
|
<select id="rpType" ${isEdit ? 'disabled' : ''}>
|
||||||
|
<option value="provider_prefer" ${policy.policy_type === 'provider_prefer' ? 'selected' : ''}>Provider Prefer</option>
|
||||||
|
<option value="team_route" ${policy.policy_type === 'team_route' ? 'selected' : ''}>Team Route</option>
|
||||||
|
<option value="cost_limit" ${policy.policy_type === 'cost_limit' ? 'selected' : ''}>Cost Limit</option>
|
||||||
|
<option value="model_alias" ${policy.policy_type === 'model_alias' ? 'selected' : ''}>Model Alias</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Scope</label>
|
||||||
|
<select id="rpScope">
|
||||||
|
<option value="global" ${policy.scope === 'global' ? 'selected' : ''}>Global</option>
|
||||||
|
<option value="team" ${policy.scope === 'team' ? 'selected' : ''}>Team</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="form-group" id="rpTeamGroup" style="display:${policy.scope === 'team' ? '' : 'none'}">
|
||||||
|
<label>Team ID</label><input type="text" id="rpTeamId" value="${esc(policy.team_id || '')}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group"><label>Config (JSON)</label>
|
||||||
|
<textarea id="rpConfig" rows="4" style="font-family:monospace;font-size:12px">${esc(JSON.stringify(cfg, null, 2))}</textarea>
|
||||||
|
</div>
|
||||||
|
<label class="checkbox-label"><input type="checkbox" id="rpActive" ${policy.is_active ? 'checked' : ''}> Active</label>
|
||||||
|
<div class="form-row" style="margin-top:12px">
|
||||||
|
<button class="btn-small btn-primary" id="rpSaveBtn">${isEdit ? 'Update' : 'Create'}</button>
|
||||||
|
<button class="btn-small" id="rpCancelBtn">Cancel</button>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
document.getElementById('rpScope').addEventListener('change', (e) => {
|
||||||
|
document.getElementById('rpTeamGroup').style.display = e.target.value === 'team' ? '' : 'none';
|
||||||
|
});
|
||||||
|
document.getElementById('rpCancelBtn').addEventListener('click', () => { formEl.style.display = 'none'; });
|
||||||
|
document.getElementById('rpSaveBtn').addEventListener('click', async () => {
|
||||||
|
let config;
|
||||||
|
try { config = JSON.parse(document.getElementById('rpConfig').value || '{}'); }
|
||||||
|
catch (e) { UI.toast('Invalid JSON in config field', 'error'); return; }
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name: document.getElementById('rpName').value,
|
||||||
|
priority: parseInt(document.getElementById('rpPriority').value) || 100,
|
||||||
|
policy_type: document.getElementById('rpType').value,
|
||||||
|
scope: document.getElementById('rpScope').value,
|
||||||
|
team_id: document.getElementById('rpScope').value === 'team' ? document.getElementById('rpTeamId').value : null,
|
||||||
|
is_active: document.getElementById('rpActive').checked,
|
||||||
|
config,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (isEdit) { await API.adminUpdateRoutingPolicy(policyId, payload); }
|
||||||
|
else { await API.adminCreateRoutingPolicy(payload); }
|
||||||
|
UI.toast(isEdit ? 'Policy updated' : 'Policy created', 'success');
|
||||||
|
formEl.style.display = 'none';
|
||||||
|
UI.loadAdminRouting();
|
||||||
|
} catch (e) { UI.toast(e.message, 'error'); }
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async _toggleRoutingPolicy(id, active) {
|
||||||
|
try {
|
||||||
|
const policy = await API.adminGetRoutingPolicy(id);
|
||||||
|
policy.is_active = active;
|
||||||
|
await API.adminUpdateRoutingPolicy(id, policy);
|
||||||
|
UI.toast(active ? 'Policy enabled' : 'Policy disabled', 'success');
|
||||||
|
UI.loadAdminRouting();
|
||||||
|
} catch (e) { UI.toast(e.message, 'error'); }
|
||||||
|
},
|
||||||
|
|
||||||
|
async _deleteRoutingPolicy(id, name) {
|
||||||
|
if (!await showConfirm(`Delete routing policy "${name}"?`)) return;
|
||||||
|
try {
|
||||||
|
await API.adminDeleteRoutingPolicy(id);
|
||||||
|
UI.toast('Policy deleted', 'success');
|
||||||
|
UI.loadAdminRouting();
|
||||||
|
} catch (e) { UI.toast(e.message, 'error'); }
|
||||||
|
},
|
||||||
|
|
||||||
|
async _runRoutingTest() {
|
||||||
|
const model = document.getElementById('routingTestModel').value;
|
||||||
|
const userId = document.getElementById('routingTestUser').value || API.user?.id;
|
||||||
|
const resultEl = document.getElementById('routingTestResult');
|
||||||
|
if (!model) { UI.toast('Model is required', 'warning'); return; }
|
||||||
|
|
||||||
|
resultEl.style.display = '';
|
||||||
|
resultEl.innerHTML = '<div class="loading">Evaluating...</div>';
|
||||||
|
try {
|
||||||
|
const data = await API.adminTestRouting(model, userId);
|
||||||
|
const dec = data.decision;
|
||||||
|
const candidates = data.candidates || [];
|
||||||
|
|
||||||
|
resultEl.innerHTML = `
|
||||||
|
<div style="margin-top:8px">
|
||||||
|
<strong>Decision:</strong> ${dec ? `${esc(dec.policy_name || 'no policy')} → ${esc(dec.selected?.slice(0, 8) || '—')}` : 'No routing applied'}
|
||||||
|
<span class="text-muted" style="margin-left:8px">(${candidates.length} candidates, ${data.policies || 0} policies evaluated)</span>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top:8px;font-size:12px">
|
||||||
|
${candidates.map((c, i) => `
|
||||||
|
<div style="padding:4px 0;border-bottom:1px solid var(--border)">
|
||||||
|
${i + 1}. <strong>${esc(c.provider_id)}</strong> / ${esc(c.config_id?.slice(0, 8) || '—')}
|
||||||
|
<span class="badge ${c.status === 'healthy' ? 'badge-success' : c.status === 'down' ? 'badge-danger' : ''}" style="margin-left:4px">${esc(c.status || 'unknown')}</span>
|
||||||
|
${c.reason ? `<span class="text-muted" style="margin-left:4px">${esc(c.reason)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>`;
|
||||||
|
} catch (e) {
|
||||||
|
resultEl.innerHTML = `<div class="empty-hint">Test failed: ${esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Capability Overrides (v0.22.3) ───────────
|
||||||
|
async loadAdminCapabilities() {
|
||||||
|
const el = document.getElementById('adminCapabilityList');
|
||||||
|
el.innerHTML = '<div class="loading">Loading overrides...</div>';
|
||||||
|
try {
|
||||||
|
const data = await API.adminListCapabilityOverrides();
|
||||||
|
const overrides = data.data || [];
|
||||||
|
|
||||||
|
if (overrides.length === 0) {
|
||||||
|
el.innerHTML = '<div class="empty-hint">No capability overrides — all models use auto-detected capabilities from catalog sync or heuristic inference.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.innerHTML = `<table class="admin-table" style="width:100%;font-size:13px">
|
||||||
|
<thead><tr><th>Model</th><th>Provider Config</th><th>Field</th><th>Value</th><th></th></tr></thead>
|
||||||
|
<tbody>${overrides.map(o => `<tr>
|
||||||
|
<td><code>${esc(o.model_id)}</code></td>
|
||||||
|
<td>${esc(o.provider_config_id?.slice(0, 8) || 'any')}</td>
|
||||||
|
<td>${esc(o.field)}</td>
|
||||||
|
<td><strong>${esc(String(o.value))}</strong></td>
|
||||||
|
<td><button class="btn-small btn-danger" onclick="UI._deleteCapOverride('${esc(o.model_id)}', '${esc(o.id)}')">×</button></td>
|
||||||
|
</tr>`).join('')}</tbody>
|
||||||
|
</table>`;
|
||||||
|
} catch (e) {
|
||||||
|
el.innerHTML = `<div class="empty-hint">Failed to load overrides: ${esc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async _deleteCapOverride(modelId, overrideId) {
|
||||||
|
try {
|
||||||
|
await API.adminDeleteModelCapability(modelId, overrideId);
|
||||||
|
UI.toast('Override deleted', 'success');
|
||||||
|
UI.loadAdminCapabilities();
|
||||||
|
} catch (e) { UI.toast(e.message, 'error'); }
|
||||||
|
},
|
||||||
|
|
||||||
async loadAdminSettings() {
|
async loadAdminSettings() {
|
||||||
try {
|
try {
|
||||||
const data = await API.adminGetSettings();
|
const data = await API.adminGetSettings();
|
||||||
|
|||||||
Reference in New Issue
Block a user