diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..575d0ad99 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +pnpm-lock.yaml +резюме/ \ No newline at end of file diff --git a/configs/config.example.yaml b/configs/config.example.yaml index 02b97aa2f..138c46b91 100644 --- a/configs/config.example.yaml +++ b/configs/config.example.yaml @@ -325,6 +325,30 @@ token_saver: audit: enabled: true # env: TOKEN_SAVER_AUDIT_ENABLED +# ========= RESPONSE HEADERS ========= +# Additional debug headers added to gateway responses +response_headers: + # Enable adding X-Actual-Provider / X-Actual-Model / X-Requested-Model / X-Fallback-Chain headers + enabled: false # env: RESPONSE_HEADERS_ENABLED + # Add headers when a fallback was used during request execution + include_fallback: true # env: RESPONSE_HEADERS_INCLUDE_FALLBACK + # Add headers when no fallback was used (normal request execution) + include_non_fallback: true # env: RESPONSE_HEADERS_INCLUDE_NON_FALLBACK + # Emit the X-Actual-Provider header (the provider that actually served the response) + actual_provider_header: true # env: RESPONSE_HEADERS_ACTUAL_PROVIDER + # Emit the X-Actual-Model header (the model that actually produced the response) + actual_model_header: true # env: RESPONSE_HEADERS_ACTUAL_MODEL + # Emit the X-Requested-Model header (the originally requested model selector) + requested_model_header: true # env: RESPONSE_HEADERS_REQUESTED_MODEL + # Emit the X-Fallback-Chain header (comma-separated models attempted before a success) + fallback_chain_header: true # env: RESPONSE_HEADERS_FALLBACK_CHAIN + # Custom response headers. Values support placeholders: {actual_provider}, + # {actual_model}, {requested_model}, {fallback_chain}, {request_id} + custom_headers: + - name: X-Custom-Header + value: "{actual_model} via {actual_provider}" + enabled: true + # ========= WORKFLOWS ========= # Workflow execution and refresh workflows: diff --git a/configs/dashboard-overrides.yaml b/configs/dashboard-overrides.yaml index d24d4d27c..ed3eb90f4 100644 --- a/configs/dashboard-overrides.yaml +++ b/configs/dashboard-overrides.yaml @@ -100,3 +100,15 @@ token_saver: on_error: allow emit_headers: false audit_enabled: false +response_headers: + enabled: false + include_fallback: true + include_non_fallback: true + actual_provider_header: true + actual_model_header: true + requested_model_header: true + fallback_chain_header: true + custom_headers: + - name: X-Custom-Header + value: "{actual_model} via {actual_provider}" + enabled: true diff --git a/configs/fallback.example.json b/configs/fallback.example.json index 0115baf1a..228ed913e 100644 --- a/configs/fallback.example.json +++ b/configs/fallback.example.json @@ -1,10 +1,18 @@ -{ - "gpt-4o": [ - "azure/gpt-4o", - "gemini/gemini-2.5-pro" - ], - "claude-sonnet-4": [ - "openai/gpt-5-mini", - "gemini/gemini-2.5-pro" - ] -} +[ + { + "source": "gpt-4o", + "targets": [ + "azure/gpt-4o", + "gemini/gemini-2.5-pro" + ], + "enabled": true + }, + { + "source": "claude-sonnet-4", + "targets": [ + "openai/gpt-5-mini", + "gemini/gemini-2.5-pro" + ], + "enabled": true + } +] diff --git a/configuration/config.go b/configuration/config.go index b9b4fa32a..da5cb0586 100644 --- a/configuration/config.go +++ b/configuration/config.go @@ -30,23 +30,24 @@ var bodySizeLimitRegex = regexp.MustCompile(`(?i)^(\d+)([KMG])?B?$`) // Config holds the application configuration. type Config struct { - Server ServerConfig `yaml:"server"` - Models ModelsConfig `yaml:"models"` - Cache CacheConfig `yaml:"cache"` - Storage StorageConfig `yaml:"storage"` - Logging LogConfig `yaml:"logging"` - Usage UsageConfig `yaml:"usage"` - Metrics MetricsConfig `yaml:"metrics"` - HTTP HTTPConfig `yaml:"http"` - Admin AdminConfig `yaml:"admin"` - Guardrails GuardrailsConfig `yaml:"guardrails"` - Fallback FallbackConfig `yaml:"fallback"` - Combos CombosConfig `yaml:"combos"` - CLITools CLIToolsConfig `yaml:"cli_tools"` - Workflows WorkflowsConfig `yaml:"workflows"` - Resilience ResilienceConfig `yaml:"resilience"` - TokenSaver TokenSaverConfig `yaml:"token_saver"` - Edition EditionConfig `yaml:"edition"` + Server ServerConfig `yaml:"server"` + Models ModelsConfig `yaml:"models"` + Cache CacheConfig `yaml:"cache"` + Storage StorageConfig `yaml:"storage"` + Logging LogConfig `yaml:"logging"` + Usage UsageConfig `yaml:"usage"` + Metrics MetricsConfig `yaml:"metrics"` + HTTP HTTPConfig `yaml:"http"` + Admin AdminConfig `yaml:"admin"` + Guardrails GuardrailsConfig `yaml:"guardrails"` + Fallback FallbackConfig `yaml:"fallback"` + Combos CombosConfig `yaml:"combos"` + CLITools CLIToolsConfig `yaml:"cli_tools"` + Workflows WorkflowsConfig `yaml:"workflows"` + Resilience ResilienceConfig `yaml:"resilience"` + TokenSaver TokenSaverConfig `yaml:"token_saver"` + Edition EditionConfig `yaml:"edition"` + ResponseHeaders ResponseHeadersConfig `yaml:"response_headers"` } // LoadResult is returned by Load and bundles the application config with the raw @@ -68,6 +69,14 @@ type RawProviderConfig struct { APIVersion string `yaml:"api_version"` Models []RawProviderModel `yaml:"models"` Resilience *RawResilienceConfig `yaml:"resilience"` + // BindIP optionally sets the local outbound IP address used when this + // provider's HTTP client connects to the upstream. Useful when the host + // has multiple public IPs and the upstream rate-limits per source IP. + BindIP string `yaml:"bind_ip"` + // PoolOnly, when true, hides this provider's models from the public model + // list. The provider is then only reachable through a pool that lists it as + // a member, keeping the /v1/models list uncluttered. + PoolOnly bool `yaml:"pool_only"` } // RawPoolConfig is the YAML-sourced provider pool definition. Pools group @@ -222,8 +231,6 @@ type AdminConfig struct { // a warning is logged and UI is forced to false. // Default: true UIEnabled bool `yaml:"ui_enabled" env:"ADMIN_UI_ENABLED"` - - } // GuardrailsConfig holds configuration for the request guardrails pipeline. @@ -380,6 +387,65 @@ type HTTPProxyConfig struct { CACertPEM string `yaml:"ca_cert_pem"` } +// ResponseHeadersConfig controls the additional response headers added to +// gateway responses for debugging and monitoring purposes. +type ResponseHeadersConfig struct { + // Enabled controls whether the additional response headers are added. + // Default: false. + Enabled bool `yaml:"enabled" env:"RESPONSE_HEADERS_ENABLED"` + + // Mode controls when headers are added. + // Values: "success" (only 2xx), "error" (only 4xx/5xx), "always" (all responses). + // Default: "success". + Mode string `yaml:"mode" env:"RESPONSE_HEADERS_MODE"` + + // IncludeFallback controls whether headers are added when a fallback + // was used during request execution. + // Default: true. + IncludeFallback bool `yaml:"include_fallback" env:"RESPONSE_HEADERS_INCLUDE_FALLBACK"` + + // IncludeNonFallback controls whether headers are added when no fallback + // was used (normal request execution). + // Default: true. + IncludeNonFallback bool `yaml:"include_non_fallback" env:"RESPONSE_HEADERS_INCLUDE_NON_FALLBACK"` + + // ActualProviderHeader controls whether the X-Actual-Provider header + // (the provider that actually served the response) is added. + // Default: true. + ActualProviderHeader bool `yaml:"actual_provider_header" env:"RESPONSE_HEADERS_ACTUAL_PROVIDER"` + + // ActualModelHeader controls whether the X-Actual-Model header + // (the model that actually produced the response) is added. + // Default: true. + ActualModelHeader bool `yaml:"actual_model_header" env:"RESPONSE_HEADERS_ACTUAL_MODEL"` + + // RequestedModelHeader controls whether the X-Requested-Model header + // (the originally requested model selector) is added. + // Default: true. + RequestedModelHeader bool `yaml:"requested_model_header" env:"RESPONSE_HEADERS_REQUESTED_MODEL"` + + // FallbackChainHeader controls whether the X-Fallback-Chain header + // (comma-separated models attempted before a success) is added. + // Default: true. + FallbackChainHeader bool `yaml:"fallback_chain_header" env:"RESPONSE_HEADERS_FALLBACK_CHAIN"` + + // CustomHeaders are additional headers added verbatim to responses. + // Values support the same placeholders as the built-in headers. + // Default: none. + CustomHeaders []CustomResponseHeaderConfig `yaml:"custom_headers"` +} + +// CustomResponseHeaderConfig defines a user-provided response header. +type CustomResponseHeaderConfig struct { + // Name is the HTTP header name (e.g. "X-Custom-Header"). + Name string `yaml:"name"` + // Value is the header value. Supported placeholders: {actual_provider}, + // {actual_model}, {requested_model}, {fallback_chain}, {request_id}. + Value string `yaml:"value"` + // Enabled controls whether this header is emitted. + Enabled bool `yaml:"enabled"` +} + // WorkflowsConfig holds runtime refresh behavior for persisted workflows. type WorkflowsConfig struct { // RefreshInterval controls how often the in-memory workflow snapshot @@ -603,8 +669,6 @@ type MetricsConfig struct { Endpoint string `yaml:"endpoint" env:"METRICS_ENDPOINT"` } - - // RetryConfig holds resolved retry settings for an LLM client. // This is the canonical type shared between config and llmclient. type RetryConfig struct { @@ -657,7 +721,7 @@ func buildDefaultConfig() *Config { BasePath: "/", SwaggerEnabled: false, PprofEnabled: false, - DisableRequestLogging: true, + DisableRequestLogging: true, EnablePassthroughRoutes: true, EnableAnthropicIngress: false, AllowPassthroughV1Alias: true, @@ -742,6 +806,16 @@ func buildDefaultConfig() *Config { Admin: AdminConfig{EndpointsEnabled: false, UIEnabled: false}, Guardrails: GuardrailsConfig{}, TokenSaver: defaultTokenSaverConfig(), + ResponseHeaders: ResponseHeadersConfig{ + Enabled: false, + Mode: "success", + IncludeFallback: true, + IncludeNonFallback: true, + ActualProviderHeader: true, + ActualModelHeader: true, + RequestedModelHeader: true, + FallbackChainHeader: true, + }, Edition: EditionConfig{ Name: EditionOSS, }, @@ -841,12 +915,6 @@ func applyYAML(cfg *Config) (map[string]RawProviderConfig, map[string]RawPoolCon rawProviders := make(map[string]RawProviderConfig) rawPools := make(map[string]RawPoolConfig) - if data == nil { - return rawProviders, rawPools, nil - } - - expanded := expandString(string(data)) - // yamlTarget is a local struct that mirrors Config for YAML unmarshaling, // using RawProviderConfig for providers so nullable resilience overrides are preserved. type yamlTarget struct { @@ -855,16 +923,20 @@ func applyYAML(cfg *Config) (map[string]RawProviderConfig, map[string]RawPoolCon RawPools map[string]RawPoolConfig `yaml:"pools"` } - target := yamlTarget{Config: cfg} - if err := yaml.Unmarshal([]byte(expanded), &target); err != nil { - return nil, nil, fmt.Errorf("failed to parse %s: %w", configPath, err) - } + if data != nil { + expanded := expandString(string(data)) - if target.RawProviders != nil { - rawProviders = target.RawProviders - } - if target.RawPools != nil { - rawPools = target.RawPools + target := yamlTarget{Config: cfg} + if err := yaml.Unmarshal([]byte(expanded), &target); err != nil { + return nil, nil, fmt.Errorf("failed to parse %s: %w", configPath, err) + } + + if target.RawProviders != nil { + rawProviders = target.RawProviders + } + if target.RawPools != nil { + rawPools = target.RawPools + } } for _, overlayPath := range dashboardOverridePaths(customPath) { @@ -911,8 +983,6 @@ func dashboardOverridePaths(customPath string) []string { } } - - func validatePublicHTTPURL(raw, field string) error { u, err := url.Parse(strings.TrimSpace(raw)) if err != nil || u.Scheme == "" || u.Host == "" { diff --git a/configuration/fallback.go b/configuration/fallback.go index fd5bc4ee2..69d323461 100644 --- a/configuration/fallback.go +++ b/configuration/fallback.go @@ -4,7 +4,6 @@ import ( "bytes" "encoding/json" "fmt" - "io" "os" "strings" ) @@ -108,62 +107,74 @@ func loadFallbackConfig(cfg *FallbackConfig) error { } expanded := expandString(string(raw)) - decoded := make(map[string][]string) - decoder := json.NewDecoder(strings.NewReader(expanded)) - - token, err := decoder.Token() - if err != nil { - return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: %w", path, err) - } - delim, ok := token.(json.Delim) - if !ok || delim != '{' { - return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: top-level JSON value must be an object", path) + trimmed := []byte(strings.TrimSpace(expanded)) + if len(trimmed) == 0 { + cfg.Manual = nil + return nil } - seenKeys := make(map[string]struct{}) - for decoder.More() { - token, err := decoder.Token() - if err != nil { - return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: %w", path, err) - } - key, ok := token.(string) - if !ok { - return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: object key must be a string", path) - } - if _, exists := seenKeys[key]; exists { - return fmt.Errorf("fallback.manual_rules_path: duplicate JSON key %q in %q", key, path) - } - seenKeys[key] = struct{}{} + decoded := make(map[string][]string) - var rawModels json.RawMessage - if err := decoder.Decode(&rawModels); err != nil { - return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: %w", path, err) - } - if bytes.Equal(bytes.TrimSpace(rawModels), []byte("null")) { - return fmt.Errorf("fallback.manual_rules_path: null not allowed for %q in %q", key, path) + // New format: JSON array of {source, targets, enabled} objects. + if trimmed[0] == '[' { + type arrayEntry struct { + Source string `json:"source"` + Targets json.RawMessage `json:"targets"` + Enabled *bool `json:"enabled"` } - var models []string - if err := json.Unmarshal(rawModels, &models); err != nil { + var entries []arrayEntry + if err := json.Unmarshal(trimmed, &entries); err != nil { return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: %w", path, err) } - decoded[key] = models + for _, entry := range entries { + key := strings.TrimSpace(entry.Source) + if key == "" { + return fmt.Errorf("fallback.manual_rules_path: model key cannot be empty") + } + if _, exists := decoded[key]; exists { + return fmt.Errorf("fallback.manual_rules_path: duplicate manual rule key after trimming: %q", key) + } + enabled := true + if entry.Enabled != nil { + enabled = *entry.Enabled + } + if !enabled { + continue + } + models, _, err := DecodeManualRuleValue(entry.Targets) + if err != nil { + return fmt.Errorf("fallback.manual_rules_path: failed to parse targets for %q in %q: %w", key, path, err) + } + decoded[key] = normalizeModels(models) + } + cfg.Manual = decoded + return nil } - token, err = decoder.Token() - if err != nil { + // Legacy format: JSON object {"source": {"targets": [...], "enabled": bool}}. + var data map[string]json.RawMessage + if err := json.Unmarshal(trimmed, &data); err != nil { return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: %w", path, err) } - delim, ok = token.(json.Delim) - if !ok || delim != '}' { - return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: top-level JSON value must be an object", path) - } - - var trailing json.RawMessage - if err := decoder.Decode(&trailing); err != io.EOF { + for key, rawValue := range data { + key = strings.TrimSpace(key) + if key == "" { + return fmt.Errorf("fallback.manual_rules_path: model key cannot be empty") + } + if _, exists := decoded[key]; exists { + return fmt.Errorf("fallback.manual_rules_path: duplicate manual rule key after trimming: %q", key) + } + if bytes.Equal(bytes.TrimSpace(rawValue), []byte("null")) { + return fmt.Errorf("fallback.manual_rules_path: null not allowed for %q in %q", key, path) + } + models, enabled, err := DecodeManualRuleValue(rawValue) if err != nil { - return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: %w", path, err) + return fmt.Errorf("fallback.manual_rules_path: failed to parse %q in %q: %w", key, path, err) } - return fmt.Errorf("fallback.manual_rules_path: failed to parse %q: unexpected trailing JSON content", path) + if !enabled { + continue + } + decoded[key] = normalizeModels(models) } manual := make(map[string][]string, len(decoded)) @@ -175,16 +186,57 @@ func loadFallbackConfig(cfg *FallbackConfig) error { if _, exists := manual[key]; exists { return fmt.Errorf("fallback.manual_rules_path: duplicate manual rule key after trimming: %q", key) } - normalized := make([]string, 0, len(models)) - for _, model := range models { - model = strings.TrimSpace(model) - if model == "" { - continue - } - normalized = append(normalized, model) - } - manual[key] = normalized + manual[key] = models } cfg.Manual = manual return nil } + +func normalizeModels(models []string) []string { + out := make([]string, 0, len(models)) + for _, m := range models { + m = strings.TrimSpace(m) + if m != "" { + out = append(out, m) + } + } + return out +} + +// ReloadFallbackManualRules re-reads the manual rules file into cfg.Manual at +// runtime. Used for live reload when the dashboard rewrites fallback.json, so +// rule edits apply without a process restart. +func ReloadFallbackManualRules(cfg *FallbackConfig) error { + if cfg == nil { + return nil + } + return loadFallbackConfig(cfg) +} + +// DecodeManualRuleValue parses a single manual rule value from the rules file. +// Both the legacy array form (["t1", "t2"], always enabled) and the object form +// ({"targets": [...], "enabled": true}) are supported. It returns the target +// list and whether the rule is enabled. +func DecodeManualRuleValue(raw json.RawMessage) ([]string, bool, error) { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) > 0 && trimmed[0] == '[' { + var models []string + if err := json.Unmarshal(trimmed, &models); err != nil { + return nil, false, err + } + return models, true, nil + } + + var obj struct { + Targets []string `json:"targets"` + Enabled *bool `json:"enabled"` + } + if err := json.Unmarshal(trimmed, &obj); err != nil { + return nil, false, err + } + enabled := true + if obj.Enabled != nil { + enabled = *obj.Enabled + } + return obj.Targets, enabled, nil +} diff --git a/configuration/overlay_without_base_test.go b/configuration/overlay_without_base_test.go new file mode 100644 index 000000000..12c20efe9 --- /dev/null +++ b/configuration/overlay_without_base_test.go @@ -0,0 +1,54 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestApplyYAML_OverlayWithoutBaseConfig(t *testing.T) { + tmp := t.TempDir() + overlayDir := filepath.Join(tmp, "configs") + if err := os.MkdirAll(overlayDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + overlayPath := filepath.Join(overlayDir, "dashboard-overrides.yaml") + content := "response_headers:\n enabled: true\n actual_provider_header: true\n custom_headers:\n - name: X-Custom\n value: \"{actual_model}\"\n enabled: true\n" + if err := os.WriteFile(overlayPath, []byte(content), 0o644); err != nil { + t.Fatalf("write overlay: %v", err) + } + + wd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + defer func() { _ = os.Chdir(wd) }() + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir: %v", err) + } + + cfg := &Config{} + providers, pools, err := applyYAML(cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if providers == nil { + t.Error("expected non-nil providers map") + } + if pools == nil { + t.Error("expected non-nil pools map") + } + + if !cfg.ResponseHeaders.Enabled { + t.Error("expected response_headers.enabled to be true from overlay without base config") + } + if !cfg.ResponseHeaders.ActualProviderHeader { + t.Error("expected actual_provider_header to be true from overlay") + } + if len(cfg.ResponseHeaders.CustomHeaders) != 1 { + t.Fatalf("expected 1 custom header, got %d", len(cfg.ResponseHeaders.CustomHeaders)) + } + if cfg.ResponseHeaders.CustomHeaders[0].Name != "X-Custom" { + t.Errorf("expected custom header name X-Custom, got %q", cfg.ResponseHeaders.CustomHeaders[0].Name) + } +} \ No newline at end of file diff --git a/dashboard-ui/index.html b/dashboard-ui/index.html index d700f8e93..4896cbabd 100644 --- a/dashboard-ui/index.html +++ b/dashboard-ui/index.html @@ -5,18 +5,12 @@ - - - + - Aurora Gateway · Admin + + Aurora Gateway diff --git a/dashboard-ui/package.json b/dashboard-ui/package.json index 1df7d6833..55f7dbf12 100644 --- a/dashboard-ui/package.json +++ b/dashboard-ui/package.json @@ -19,9 +19,6 @@ "test:watch": "vitest" }, "dependencies": { - "@fontsource/dm-sans": "^5.2.8", - "@fontsource/dm-serif-display": "^5.2.8", - "@fontsource/inter": "5.1.0", "@radix-ui/react-dialog": "1.1.2", "@radix-ui/react-dropdown-menu": "2.1.2", "@radix-ui/react-label": "2.1.0", diff --git a/dashboard-ui/public/navbar-logo.svg b/dashboard-ui/public/navbar-logo.svg index efb4733ae..8800c732f 100644 --- a/dashboard-ui/public/navbar-logo.svg +++ b/dashboard-ui/public/navbar-logo.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/dashboard-ui/src/components/charts/CostByModelChart.tsx b/dashboard-ui/src/components/charts/CostByModelChart.tsx index b7b868a1a..886203abf 100644 --- a/dashboard-ui/src/components/charts/CostByModelChart.tsx +++ b/dashboard-ui/src/components/charts/CostByModelChart.tsx @@ -33,7 +33,7 @@ export function CostByModelChart({ models, isLoading }: CostByModelChartProps): return (
-

Cost by model

+

Cost by model

{isLoading ? ( Loading… ) : null} diff --git a/dashboard-ui/src/components/charts/LogVolumeChart.tsx b/dashboard-ui/src/components/charts/LogVolumeChart.tsx index 9d1d86b0e..0f324ae53 100644 --- a/dashboard-ui/src/components/charts/LogVolumeChart.tsx +++ b/dashboard-ui/src/components/charts/LogVolumeChart.tsx @@ -82,7 +82,7 @@ export function LogVolumeChart({ entries, isConnected }: LogVolumeChartProps) {
-

+

Request Volume

diff --git a/dashboard-ui/src/components/charts/ThroughputChart.tsx b/dashboard-ui/src/components/charts/ThroughputChart.tsx index 680b4bd1d..c0c50629e 100644 --- a/dashboard-ui/src/components/charts/ThroughputChart.tsx +++ b/dashboard-ui/src/components/charts/ThroughputChart.tsx @@ -75,7 +75,7 @@ export function ThroughputChart({
-

Throughput

+

Throughput

Requests per day across the selected range.

{isLoading ? ( diff --git a/dashboard-ui/src/components/charts/TokenUsageChart.tsx b/dashboard-ui/src/components/charts/TokenUsageChart.tsx index b9c650d0f..2e2059c8f 100644 --- a/dashboard-ui/src/components/charts/TokenUsageChart.tsx +++ b/dashboard-ui/src/components/charts/TokenUsageChart.tsx @@ -89,7 +89,7 @@ export function TokenUsageChart({
-

Token usage

+

Token usage

Input, output, and locally served cache tokens.

{isLoading ? ( diff --git a/dashboard-ui/src/components/cli-tools/CLIModelFieldGrid.tsx b/dashboard-ui/src/components/cli-tools/CLIModelFieldGrid.tsx index f4f606b90..4edfa6663 100644 --- a/dashboard-ui/src/components/cli-tools/CLIModelFieldGrid.tsx +++ b/dashboard-ui/src/components/cli-tools/CLIModelFieldGrid.tsx @@ -1,5 +1,6 @@ import { useMemo, useState } from "react"; import { Search, Check } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; import type { CLIModelField } from "@/lib/api/cli-tools"; import { modelDisplayName, type ModelInventoryItem } from "@/lib/api/models-types"; @@ -68,11 +69,11 @@ function MultiModelSelect({ key={model} className={`flex min-w-0 cursor-pointer items-center gap-2 border-b border-border/30 px-3 py-2 text-xs transition last:border-b-0 hover:bg-background/40 ${checked ? "bg-accent/5" : ""}`} > - toggleModel(model)} - className="shrink-0 accent-accent" + size="sm" + onCheckedChange={() => toggleModel(model)} + aria-label={model} /> {model} {checked ? : null} diff --git a/dashboard-ui/src/components/logs/LogSidebarFilters.tsx b/dashboard-ui/src/components/logs/LogSidebarFilters.tsx index 6892dc582..ac0fc3799 100644 --- a/dashboard-ui/src/components/logs/LogSidebarFilters.tsx +++ b/dashboard-ui/src/components/logs/LogSidebarFilters.tsx @@ -1,6 +1,7 @@ -import { SearchIcon, FilterXIcon } from "lucide-react"; +import { FilterXIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { SearchInput } from "@/components/ui/search-input"; export interface LogFilterValues { search: string; @@ -35,15 +36,12 @@ export function LogSidebarFilters({ filters, onChange, onClear, entryCount }: Lo )}
-
- - { set("search", e.target.value); }} - className="h-8 pl-8 text-xs bg-background" - /> -
+ { set("search", e.target.value); }} + className="h-9 text-xs bg-background" + />
set("method", v)} options={["", "GET", "POST", "PUT", "PATCH", "DELETE"]} displayLabels={["All methods", "GET", "POST", "PUT", "PATCH", "DELETE"]} /> @@ -77,7 +75,7 @@ function FilterSelect({ label, value, onChange, options, displayLabels }: {
) : null} diff --git a/dashboard-ui/src/components/settings/CachingTab.tsx b/dashboard-ui/src/components/settings/CachingTab.tsx index edc0264f5..4baa68a15 100644 --- a/dashboard-ui/src/components/settings/CachingTab.tsx +++ b/dashboard-ui/src/components/settings/CachingTab.tsx @@ -1,6 +1,7 @@ import { Surface, SectionHeader } from "@/components/ui/surface"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { ToggleField } from "@/components/ui/toggle-field"; import { RuntimeStatusBadge, useSettings, StatusChip } from "./SettingsContext"; import { DatabaseIcon, SaveIcon } from "lucide-react"; @@ -84,15 +85,12 @@ export function CachingTab(): JSX.Element {

Exact Cache

-
- setDashboardSettings({ ...dashboardSettings, caching: { ...dashboardSettings.caching, exact_cache_enabled: e.target.checked } })} - className="h-4 w-4 rounded border-border text-accent focus:ring-accent" - /> - Enabled -
+ setDashboardSettings({ ...dashboardSettings, caching: { ...dashboardSettings.caching, exact_cache_enabled: checked } })} + aria-label="Enable exact cache" + />
TTL
@@ -121,15 +119,12 @@ export function CachingTab(): JSX.Element {

Semantic Cache

-
- setDashboardSettings({ ...dashboardSettings, caching: { ...dashboardSettings.caching, semantic_cache_enabled: e.target.checked } })} - className="h-4 w-4 rounded border-border text-accent focus:ring-accent" - /> - Enabled -
+ setDashboardSettings({ ...dashboardSettings, caching: { ...dashboardSettings.caching, semantic_cache_enabled: checked } })} + aria-label="Enable semantic cache" + />
Similarity threshold
@@ -177,15 +172,12 @@ export function CachingTab(): JSX.Element { />
-
- setDashboardSettings({ ...dashboardSettings, caching: { ...dashboardSettings.caching, semantic_exclude_system_prompt: e.target.checked } })} - className="h-4 w-4 rounded border-border text-accent focus:ring-accent" - /> - Exclude system prompt -
+ setDashboardSettings({ ...dashboardSettings, caching: { ...dashboardSettings.caching, semantic_exclude_system_prompt: checked } })} + aria-label="Exclude system prompt from semantic cache" + />
Embedder provider
@@ -254,40 +246,31 @@ export function CachingTab(): JSX.Element {
Auto injects breakpoints; Manual uses only explicit request cache_control
-
- setDashboardSettings({ ...dashboardSettings, caching: { ...dashboardSettings.caching, prompt_cache_system_prompt: e.target.checked } })} - className="h-4 w-4 rounded border-border text-accent focus:ring-accent" - /> - System prompt cache -
-
Mark system prompt content for caching (auto mode)
+ setDashboardSettings({ ...dashboardSettings, caching: { ...dashboardSettings.caching, prompt_cache_system_prompt: checked } })} + aria-label="Enable system prompt cache" + />
-
- setDashboardSettings({ ...dashboardSettings, caching: { ...dashboardSettings.caching, prompt_cache_first_message: e.target.checked } })} - className="h-4 w-4 rounded border-border text-accent focus:ring-accent" - /> - First message cache -
-
Mark first user message for caching (auto mode)
+ setDashboardSettings({ ...dashboardSettings, caching: { ...dashboardSettings.caching, prompt_cache_first_message: checked } })} + aria-label="Enable first message cache" + />
-
- setDashboardSettings({ ...dashboardSettings, caching: { ...dashboardSettings.caching, prompt_cache_tools: e.target.checked } })} - className="h-4 w-4 rounded border-border text-accent focus:ring-accent" - /> - Tools cache -
-
Mark tool definitions for caching (auto mode)
+ setDashboardSettings({ ...dashboardSettings, caching: { ...dashboardSettings.caching, prompt_cache_tools: checked } })} + aria-label="Enable tools cache" + />
Min tokens before cache
diff --git a/dashboard-ui/src/components/settings/GeneralTab.tsx b/dashboard-ui/src/components/settings/GeneralTab.tsx index 1af46953b..12fde357c 100644 --- a/dashboard-ui/src/components/settings/GeneralTab.tsx +++ b/dashboard-ui/src/components/settings/GeneralTab.tsx @@ -1,6 +1,8 @@ import { Surface, SectionHeader } from "@/components/ui/surface"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; +import { ToggleField } from "@/components/ui/toggle-field"; import { useModels } from "@/lib/api/useModels"; import { useProviderStatus } from "@/lib/api/useProviders"; import { modelDisplayName } from "@/lib/api/models-types"; @@ -118,24 +120,30 @@ export function GeneralTab(): JSX.Element {
Anthropic ingress
-
- setDashboardSettings({ ...dashboardSettings, client: { ...dashboardSettings.client, enable_anthropic_ingress: e.target.checked } })} className="h-4 w-4 rounded border-border text-accent focus:ring-accent" /> - Enable Anthropic ingress -
-
Expose /v1/messages endpoint for Anthropic-format chat completions. Allows Anthropic SDK clients and Claude Code CLI to route through the gateway. Requires restart.
+ setDashboardSettings({ ...dashboardSettings, client: { ...dashboardSettings.client, enable_anthropic_ingress: checked } })} + aria-label="Enable Anthropic ingress" + />
Admin API
-
- handleAdminEndpointToggle(e.target.checked)} className="h-4 w-4 rounded border-border text-accent focus:ring-accent" /> - Enable admin API endpoints -
+ handleAdminEndpointToggle(checked)} + aria-label="Enable admin API endpoints" + />
Admin UI
-
- setDashboardSettings({ ...dashboardSettings, client: { ...dashboardSettings.client, admin_ui_enabled: e.target.checked } })} className="h-4 w-4 rounded border-border text-accent focus:ring-accent" /> - Enable admin dashboard UI -
-
Requires restart.
+ setDashboardSettings({ ...dashboardSettings, client: { ...dashboardSettings.client, admin_ui_enabled: checked } })} + aria-label="Enable admin dashboard UI" + />
Server port & base path
@@ -153,15 +161,19 @@ export function GeneralTab(): JSX.Element {
Debug endpoints
-
- setDashboardSettings({ ...dashboardSettings, client: { ...dashboardSettings.client, swagger_enabled: e.target.checked } })} className="h-4 w-4 rounded border-border text-accent focus:ring-accent" /> - Swagger / OpenAPI docs -
-
- setDashboardSettings({ ...dashboardSettings, client: { ...dashboardSettings.client, pprof_enabled: e.target.checked } })} className="h-4 w-4 rounded border-border text-accent focus:ring-accent" /> - pprof profiling -
-
Requires restart.
+ setDashboardSettings({ ...dashboardSettings, client: { ...dashboardSettings.client, swagger_enabled: checked } })} + aria-label="Enable Swagger / OpenAPI docs" + /> + setDashboardSettings({ ...dashboardSettings, client: { ...dashboardSettings.client, pprof_enabled: checked } })} + aria-label="Enable pprof profiling" + />
@@ -202,22 +214,26 @@ export function GeneralTab(): JSX.Element {
Controls how configured provider model lists are applied to discovery.
-
-
Alias projection
-
- setDashboardSettings({ ...dashboardSettings, client: { ...dashboardSettings.client, keep_only_aliases_at_models_endpoint: !e.target.checked } })} className="h-4 w-4 rounded border-border text-accent focus:ring-accent" /> - Include concrete models +
+
Alias projection
+ setDashboardSettings({ ...dashboardSettings, client: { ...dashboardSettings.client, keep_only_aliases_at_models_endpoint: !checked } })} + aria-label="Include concrete models" + />
-
Whether /v1/models keeps provider catalog entries in addition to aliases.
-
-
-
OpenAI passthrough alias
-
- setDashboardSettings({ ...dashboardSettings, client: { ...dashboardSettings.client, allow_passthrough_v1_alias: e.target.checked } })} className="h-4 w-4 rounded border-border text-accent focus:ring-accent" /> - Allow /p/{"{provider}"}/v1 +
+
OpenAI passthrough alias
+ setDashboardSettings({ ...dashboardSettings, client: { ...dashboardSettings.client, allow_passthrough_v1_alias: checked } })} + aria-label="Allow /p/{provider}/v1" + />
-
Lets /p/{"{provider}"}/v1/... behave like the canonical /p/{"{provider}"}/... path.
-
Compatibility status
Handled by the gateway
@@ -254,28 +270,22 @@ export function GeneralTab(): JSX.Element {
-
- setDashboardSettings({ ...dashboardSettings, token_saver: { ...tokenSaver, enabled: e.target.checked } })} - className="h-4 w-4 rounded border-border text-accent focus:ring-accent" - /> - Enable Token Saver -
-
Applies deterministic compression before provider dispatch when the request matches this policy.
+ setDashboardSettings({ ...dashboardSettings, token_saver: { ...tokenSaver, enabled: checked } })} + aria-label="Enable Token Saver" + />
-
- setDashboardSettings({ ...dashboardSettings, token_saver: { ...tokenSaver, apply_streaming: e.target.checked } })} - className="h-4 w-4 rounded border-border text-accent focus:ring-accent" - /> - Apply to streaming requests -
-
When disabled, streaming chat completions bypass compression.
+ setDashboardSettings({ ...dashboardSettings, token_saver: { ...tokenSaver, apply_streaming: checked } })} + aria-label="Apply to streaming requests" + />
On error
@@ -285,16 +295,13 @@ export function GeneralTab(): JSX.Element {
-
- setDashboardSettings({ ...dashboardSettings, token_saver: { ...tokenSaver, output_enabled: e.target.checked } })} - className="h-4 w-4 rounded border-border text-accent focus:ring-accent" - /> - Concise output profile -
-
Adds a safe concise-response instruction except when JSON response format is requested.
+ setDashboardSettings({ ...dashboardSettings, token_saver: { ...tokenSaver, output_enabled: checked } })} + aria-label="Enable concise output profile" + />
Output level
@@ -307,28 +314,22 @@ export function GeneralTab(): JSX.Element {
Controls response profile verbosity. Full is the balanced default; ultra and wenyan are more aggressive.
-
- setDashboardSettings({ ...dashboardSettings, token_saver: { ...tokenSaver, emit_headers: e.target.checked } })} - className="h-4 w-4 rounded border-border text-accent focus:ring-accent" - /> - Emit observability headers -
-
Headers include only status and character counts, never prompt content.
+ setDashboardSettings({ ...dashboardSettings, token_saver: { ...tokenSaver, emit_headers: checked } })} + aria-label="Emit observability headers" + />
-
- setDashboardSettings({ ...dashboardSettings, token_saver: { ...tokenSaver, audit_enabled: e.target.checked } })} - className="h-4 w-4 rounded border-border text-accent focus:ring-accent" - /> - Audit logging -
-
Logs token saver decisions to the audit trail. Disabled by default; enable for compliance visibility.
+ setDashboardSettings({ ...dashboardSettings, token_saver: { ...tokenSaver, audit_enabled: checked } })} + aria-label="Enable audit logging" + />
Endpoint scope
@@ -354,8 +355,18 @@ export function GeneralTab(): JSX.Element { ))} @@ -380,8 +391,18 @@ export function GeneralTab(): JSX.Element { ))} @@ -435,6 +456,183 @@ export function GeneralTab(): JSX.Element {
+ + +
+
+
+ +
+ +
+ + Enable response headers} + description="Master switch for all response debug headers." + checked={dashboardSettings.response_headers?.enabled ?? false} + onCheckedChange={(checked) => setDashboardSettings({ ...dashboardSettings, response_headers: { ...dashboardSettings.response_headers, enabled: checked } })} + aria-label="Enable response headers" + /> + +
+ + + Control when debug headers are injected into responses. +
+ +
+
+ setDashboardSettings({ ...dashboardSettings, response_headers: { ...dashboardSettings.response_headers, include_fallback: checked } })} + aria-label="Include headers on fallback requests" + /> +
+
+ setDashboardSettings({ ...dashboardSettings, response_headers: { ...dashboardSettings.response_headers, include_non_fallback: checked } })} + aria-label="Include headers on non-fallback requests" + /> +
+
+ +
+
Built-in headers
+
+
+ X-Actual-Provider} + description="The provider that actually served the response." + checked={dashboardSettings.response_headers?.actual_provider_header ?? true} + onCheckedChange={(checked) => setDashboardSettings({ ...dashboardSettings, response_headers: { ...dashboardSettings.response_headers, actual_provider_header: checked } })} + aria-label="X-Actual-Provider header" + /> +
+
+ X-Actual-Model} + description="The model that actually produced the response." + checked={dashboardSettings.response_headers?.actual_model_header ?? true} + onCheckedChange={(checked) => setDashboardSettings({ ...dashboardSettings, response_headers: { ...dashboardSettings.response_headers, actual_model_header: checked } })} + aria-label="X-Actual-Model header" + /> +
+
+ X-Requested-Model} + description="The originally requested model selector." + checked={dashboardSettings.response_headers?.requested_model_header ?? true} + onCheckedChange={(checked) => setDashboardSettings({ ...dashboardSettings, response_headers: { ...dashboardSettings.response_headers, requested_model_header: checked } })} + aria-label="X-Requested-Model header" + /> +
+
+ X-Fallback-Chain} + description="Comma-separated models attempted before a success." + checked={dashboardSettings.response_headers?.fallback_chain_header ?? true} + onCheckedChange={(checked) => setDashboardSettings({ ...dashboardSettings, response_headers: { ...dashboardSettings.response_headers, fallback_chain_header: checked } })} + aria-label="X-Fallback-Chain header" + /> +
+
+
+ +
+
+
Custom headers
+ +
+ Values support placeholders: {"{actual_provider}, {actual_model}, {requested_model}, {fallback_chain}, {request_id}"} + {(dashboardSettings.response_headers?.custom_headers ?? []).length === 0 ? ( +
No custom headers configured. Add one to emit an additional response header.
+ ) : ( +
+ {(dashboardSettings.response_headers?.custom_headers ?? []).map((header, index) => ( +
+
+ setDashboardSettings({ + ...dashboardSettings, + response_headers: { + ...dashboardSettings.response_headers, + custom_headers: (dashboardSettings.response_headers?.custom_headers ?? []).map((item, i) => i === index ? { ...item, enabled: checked } : item), + }, + })} + aria-label={`Enable custom header ${index + 1}`} + /> +
+ setDashboardSettings({ + ...dashboardSettings, + response_headers: { + ...dashboardSettings.response_headers, + custom_headers: (dashboardSettings.response_headers?.custom_headers ?? []).map((item, i) => i === index ? { ...item, name: e.target.value } : item), + }, + })} + /> + setDashboardSettings({ + ...dashboardSettings, + response_headers: { + ...dashboardSettings.response_headers, + custom_headers: (dashboardSettings.response_headers?.custom_headers ?? []).map((item, i) => i === index ? { ...item, value: e.target.value } : item), + }, + })} + /> + +
+ ))} +
+ )} +
+
+ + {mutations.saveDashboardSettingsMutation.isSuccess && Saved} + {mutations.saveDashboardSettingsMutation.isError && {mutations.saveDashboardSettingsMutation?.error?.message}} +
+
+
); } diff --git a/dashboard-ui/src/components/settings/NetworkingTab.tsx b/dashboard-ui/src/components/settings/NetworkingTab.tsx index ec65da367..ecf5a15f5 100644 --- a/dashboard-ui/src/components/settings/NetworkingTab.tsx +++ b/dashboard-ui/src/components/settings/NetworkingTab.tsx @@ -1,6 +1,7 @@ import { Surface, SectionHeader } from "@/components/ui/surface"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { ToggleField } from "@/components/ui/toggle-field"; import { RuntimeStatusBadge, useSettings } from "./SettingsContext"; import { ActivityIcon, GaugeIcon, SaveIcon } from "lucide-react"; @@ -96,16 +97,13 @@ export function NetworkingTab(): JSX.Element {
Comma-separated hosts to bypass the proxy.
-
- setDashboardSettings({ ...dashboardSettings, proxy: { ...dashboardSettings.proxy, proxy_auth_enabled: e.target.checked } })} - className="h-4 w-4 rounded border-border text-accent focus:ring-accent" - /> - Proxy auth required -
-
Toggle if your proxy requires username/password authentication.
+ setDashboardSettings({ ...dashboardSettings, proxy: { ...dashboardSettings.proxy, proxy_auth_enabled: checked } })} + aria-label="Enable proxy auth" + />
CA certificate (PEM)
diff --git a/dashboard-ui/src/components/settings/ProvidersTab.tsx b/dashboard-ui/src/components/settings/ProvidersTab.tsx index 422205966..2ad9aa7de 100644 --- a/dashboard-ui/src/components/settings/ProvidersTab.tsx +++ b/dashboard-ui/src/components/settings/ProvidersTab.tsx @@ -1,10 +1,11 @@ -import { Surface, SectionHeader } from "@/components/ui/surface"; +import { Surface, SectionHeader, Pill } from "@/components/ui/surface"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Switch } from "@/components/ui/switch"; import { RuntimeStatusBadge, useSettings, StatusChip } from "./SettingsContext"; import { ServerIcon, RefreshCwIcon, PlusIcon, Edit3Icon, Trash2Icon, SaveIcon, XIcon } from "lucide-react"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { fetchProviderStatus, createProvider, updateProvider, deleteProvider, type ProviderFormData } from "@/lib/api/providers"; +import { fetchProviderStatus, createProvider, updateProvider, deleteProvider, setProviderEnabled, type ProviderFormData, type ProviderStatusResponse } from "@/lib/api/providers"; import { withBasePath } from "@/lib/basepath"; import { useState } from "react"; @@ -94,13 +95,13 @@ function ConfigSourceBadge({ source }: { source: string | undefined }): JSX.Elem interface ProviderModalProps { mode: "add" | "edit"; - initial: (ProviderFormData & { originalName?: string }) | undefined; + initial: (ProviderFormData & { originalName?: string; apiKeySet?: boolean }) | undefined; onClose: () => void; onSaved: () => void; } function ProviderModal({ mode, initial, onClose, onSaved }: ProviderModalProps): JSX.Element { - const [form, setForm] = useState(initial ?? { name: "", type: "", base_url: "", api_version: "", api_key: "", models: "" }); + const [form, setForm] = useState(initial ?? { name: "", type: "", base_url: "", api_version: "", api_key: "", models: "", bind_ip: "", pool_only: false }); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); @@ -111,7 +112,12 @@ function ProviderModal({ mode, initial, onClose, onSaved }: ProviderModalProps): if (mode === "add") { await createProvider(form); } else { - await updateProvider(initial?.originalName ?? form.name, form); + const originalName = initial?.originalName ?? form.name; + if (originalName && form.name !== originalName) { + await updateProvider(originalName, { ...form, new_name: form.name }); + } else { + await updateProvider(originalName, form); + } } onSaved(); onClose(); @@ -122,7 +128,7 @@ function ProviderModal({ mode, initial, onClose, onSaved }: ProviderModalProps): } }; - const providerTypes = ["openai", "anthropic", "google", "azure", "aws-bedrock", "mistral", "cohere", "jina", "togetherai", "openrouter", "deepseek", "xai", "custom"]; + const providerTypes = ["openai", "anthropic", "gemini", "azure", "deepseek", "groq", "minimax", "ollama", "vllm", "openrouter", "oracle", "xai", "zai", "reranker", "custom"]; return (
@@ -135,8 +141,10 @@ function ProviderModal({ mode, initial, onClose, onSaved }: ProviderModalProps):
setForm({ ...form, name: e.target.value })} - disabled={mode === "edit"} className={mode === "edit" ? "opacity-60" : ""} /> + onChange={(e) => setForm({ ...form, name: e.target.value })} /> + {mode === "edit" && ( +
Rename by editing this value. Pools that referenced the old name are updated automatically.
+ )}
@@ -158,8 +166,11 @@ function ProviderModal({ mode, initial, onClose, onSaved }: ProviderModalProps):
- setForm({ ...form, api_key: e.target.value })} /> + {mode === "edit" && initial?.apiKeySet && ( +
A key is already set for this provider. Leave empty to keep it.
+ )}
@@ -167,6 +178,24 @@ function ProviderModal({ mode, initial, onClose, onSaved }: ProviderModalProps): onChange={(e) => setForm({ ...form, models: e.target.value })} />
Comma separated model IDs
+
+ + setForm({ ...form, bind_ip: e.target.value })} /> +
Optional local outbound IP for upstream requests (use when the provider rate-limits per source IP)
+
+
+
+ +
Hide this provider's models from the model list; reachable only through a pool that lists it as a member.
+
+ setForm({ ...form, pool_only: v })} + aria-label="Pool only" + /> +
{error &&
{error}
}
@@ -192,7 +221,7 @@ export function ProvidersTab(): JSX.Element { }); const [modalOpen, setModalOpen] = useState<"add" | "edit" | null>(null); - const [editingProvider, setEditingProvider] = useState(null); + const [editingProvider, setEditingProvider] = useState(null); const [deleteConfirm, setDeleteConfirm] = useState(null); const deleteMutation = useMutation({ @@ -203,6 +232,32 @@ export function ProvidersTab(): JSX.Element { }, }); + const toggleMutation = useMutation({ + mutationFn: ({ name, enabled }: { name: string; enabled: boolean }) => setProviderEnabled(name, enabled), + onMutate: async ({ name, enabled }) => { + await queryClient.cancelQueries({ queryKey: ["provider-status"] }); + const previous = queryClient.getQueryData(["provider-status"]); + queryClient.setQueryData(["provider-status"], (old: ProviderStatusResponse | undefined) => { + if (!old) return old; + return { + ...old, + providers: old.providers.map((p) => + p.name === name ? { ...p, config: { ...p.config, enabled } } : p + ), + }; + }); + return previous ? { previous } : {}; + }, + onError: (_err: Error, _variables, context: { previous?: ProviderStatusResponse } | undefined) => { + if (context?.previous) { + queryClient.setQueryData(["provider-status"], context.previous); + } + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["provider-status"] }); + }, + }); + const providers = providerStatus?.providers ?? []; const summary = providerStatus?.summary; @@ -228,7 +283,7 @@ export function ProvidersTab(): JSX.Element {
{summary && ( -
+
{summary.total}
Total
@@ -245,19 +300,23 @@ export function ProvidersTab(): JSX.Element {
{summary.unhealthy}
Unhealthy
+
+
{summary.disabled ?? 0}
+
Disabled
+
)}
-
{providers.length > 0 ? (
{providers.map((provider) => ( -
+
@@ -265,12 +324,22 @@ export function ProvidersTab(): JSX.Element {
{provider.name} + {provider.config?.enabled === false && disabled} + {provider.config?.pool_only && pool-only}
{provider.type || provider.config?.type || "custom"}
- + + + + Aurora +
diff --git a/dashboard-ui/src/components/shell/AuthDialog.tsx b/dashboard-ui/src/components/shell/AuthDialog.tsx index 1d71e9469..dd3eb67d4 100644 --- a/dashboard-ui/src/components/shell/AuthDialog.tsx +++ b/dashboard-ui/src/components/shell/AuthDialog.tsx @@ -84,7 +84,7 @@ export function AuthDialog({ return ( - + {title} @@ -96,13 +96,13 @@ export function AuthDialog({ )} @@ -121,7 +121,7 @@ export function AuthDialog({
setValue(event.target.value)} - className="pl-9" + className="pl-10 min-h-[48px] text-base" />
{error ? ( -

+

{error}

) : null} - diff --git a/dashboard-ui/src/components/shell/BottomNav.tsx b/dashboard-ui/src/components/shell/BottomNav.tsx new file mode 100644 index 000000000..a8dcae8a0 --- /dev/null +++ b/dashboard-ui/src/components/shell/BottomNav.tsx @@ -0,0 +1,39 @@ +import { Link, useRouterState } from "@tanstack/react-router"; +import { LayoutDashboard, Box, Terminal, Settings, Workflow } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const TABS = [ + { to: "/admin/dashboard/overview", label: "Home", Icon: LayoutDashboard }, + { to: "/admin/dashboard/models", label: "Models", Icon: Box }, + { to: "/admin/dashboard/fallback", label: "Fallback", Icon: Workflow }, + { to: "/admin/dashboard/console", label: "Console", Icon: Terminal }, + { to: "/admin/dashboard/settings", label: "Settings", Icon: Settings }, +]; + +export function BottomNav(): JSX.Element { + const { location } = useRouterState(); + const path = location.pathname; + + return ( + + ); +} diff --git a/dashboard-ui/src/components/shell/ForbiddenPage.tsx b/dashboard-ui/src/components/shell/ForbiddenPage.tsx index 7be405331..a2caea6a4 100644 --- a/dashboard-ui/src/components/shell/ForbiddenPage.tsx +++ b/dashboard-ui/src/components/shell/ForbiddenPage.tsx @@ -2,7 +2,7 @@ export function ForbiddenPage(): JSX.Element { return (
-

Access denied

+

Access denied

Your current session does not have permission to view this admin page.

diff --git a/dashboard-ui/src/components/shell/PageStub.tsx b/dashboard-ui/src/components/shell/PageStub.tsx index 2a6f5cc93..da299fbd2 100644 --- a/dashboard-ui/src/components/shell/PageStub.tsx +++ b/dashboard-ui/src/components/shell/PageStub.tsx @@ -18,7 +18,7 @@ export function PageStub({ title, phase, source }: PageStubProps): JSX.Element {
-

{title}

+

{title}

React port pending

diff --git a/dashboard-ui/src/components/shell/Sidebar.tsx b/dashboard-ui/src/components/shell/Sidebar.tsx index ba36a3c76..7b95c644e 100644 --- a/dashboard-ui/src/components/shell/Sidebar.tsx +++ b/dashboard-ui/src/components/shell/Sidebar.tsx @@ -14,14 +14,13 @@ import { Monitor, Moon, Network, - PanelLeftClose, - PanelLeftOpen, Settings, ShieldCheck, Sun, Terminal, Layers, Workflow, + X, } from "lucide-react"; import { cn } from "@/lib/utils"; import { @@ -29,7 +28,7 @@ import { hasCapability, type DashboardConfigResponse, } from "@/lib/api/dashboard-config"; -import { setTheme, toggleTheme, useTheme, type Theme } from "@/lib/theme/theme"; +import { setTheme, useTheme, type Theme } from "@/lib/theme/theme"; import { useApiKeyState } from "@/lib/auth/useApiKey"; import { useSession } from "@/lib/auth/useSession"; import { clearSession, isLoggedIn, fetchMe } from "@/lib/auth/session"; @@ -54,6 +53,7 @@ const NAV: readonly NavEntry[] = [ { to: "/admin/dashboard/playground", label: "Playground", Icon: MessageSquareText }, { to: "/admin/dashboard/models", label: "Models", Icon: Box, requiredResource: "admin/models" }, { to: "/admin/dashboard/combos", label: "Combos", Icon: Layers, requiredResource: "admin/models" }, + { to: "/admin/dashboard/fallback", label: "Fallback", Icon: Workflow, requiredResource: "admin/models" }, { to: "/admin/dashboard/audit-logs", label: "Audit Logs", Icon: History, requiredResource: "admin/audit" }, { to: "/admin/dashboard/console", label: "Live Console", Icon: Terminal, requiredResource: "admin/audit" }, { to: "/admin/dashboard/usage", label: "Usage", Icon: ChartColumn, requiredResource: "admin/usage" }, @@ -80,11 +80,15 @@ const NAV: readonly NavEntry[] = [ export interface SidebarProps { config: DashboardConfigResponse | undefined; onOpenAuthDialog: () => void; + mobileOpen: boolean; + onCloseMobile: () => void; } export function Sidebar({ config, onOpenAuthDialog, + mobileOpen, + onCloseMobile, }: SidebarProps): JSX.Element { const theme = useTheme(); const apiKey = useApiKeyState(); @@ -93,10 +97,6 @@ export function Sidebar({ const path = location.pathname; const { loggedIn, user } = useSession(); - const [isCollapsed, setIsCollapsed] = React.useState(() => { - return localStorage.getItem("aurora_sidebar_collapsed") === "true"; - }); - // Fetch permissions from /auth/me after identity login. React.useEffect(() => { if (loggedIn && user?.user.id && user.permissions.length === 0) { @@ -141,34 +141,31 @@ export function Sidebar({ navigate({ to: "/admin/dashboard/login" }); }; - const toggleCollapse = () => { - const next = !isCollapsed; - setIsCollapsed(next); - localStorage.setItem("aurora_sidebar_collapsed", String(next)); - }; - return ( @@ -275,66 +252,12 @@ export function Sidebar({ function SidebarLogo(): JSX.Element { return ( - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + ); } -interface SidebarCollapseButtonProps { - isCollapsed: boolean; - onToggle: () => void; -} - -function SidebarCollapseButton({ isCollapsed, onToggle }: SidebarCollapseButtonProps): JSX.Element { - return ( - - ); -} interface ThemeToggleProps { theme: Theme; @@ -377,25 +300,23 @@ function ThemeToggle({ theme }: ThemeToggleProps): JSX.Element { } function MobileThemeButton({ theme }: ThemeToggleProps): JSX.Element { - const Icon = theme === "light" ? Sun : theme === "dark" ? Moon : Monitor; - const label = - theme === "light" - ? "Light theme" - : theme === "dark" - ? "Dark theme" - : "System theme"; + const choices: ReadonlyArray<{ value: Theme; Icon: IconType; label: string }> = [ + { value: "light", Icon: Sun, label: "Light" }, + { value: "system", Icon: Monitor, label: "System" }, + { value: "dark", Icon: Moon, label: "Dark" }, + ]; return ( - +
+ {choices.map(({ value, Icon, label }) => { + const active = theme === value; + return ( + + ); + })} +
); } diff --git a/dashboard-ui/src/components/ui/button.tsx b/dashboard-ui/src/components/ui/button.tsx index 692a27931..375a1a8e5 100644 --- a/dashboard-ui/src/components/ui/button.tsx +++ b/dashboard-ui/src/components/ui/button.tsx @@ -9,13 +9,13 @@ import { cn } from "@/lib/utils"; * legacy dashboard already uses (primary CTA, ghost icon button, danger). */ const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap text-[12px] font-bold uppercase tracking-widest transition-all duration-200 ease-[var(--ease-ios)] " + + "inline-flex items-center justify-center gap-2 whitespace-nowrap text-[12px] font-bold uppercase tracking-widest transition-all duration-200 ease-[var(--ease-ios)] rounded-lg " + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background " + - "disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 active:scale-[0.97]", + "disabled:pointer-events-none disabled:opacity-50 [&_svg]:size-4 [&_svg]:shrink-0 active:scale-[0.97] touch-manipulation min-h-[44px] md:min-h-0", { variants: { variant: { - default: "bg-accent text-accent-foreground hover:bg-accent-hover", + default: "bg-accent text-accent-foreground hover:bg-accent-hover shadow-sm", secondary: "border border-border/50 bg-surface text-foreground hover:bg-surface-hover/60", ghost: "text-foreground hover:bg-surface-hover/50", @@ -23,13 +23,13 @@ const buttonVariants = cva( "border border-border/50 bg-transparent text-foreground hover:bg-surface-hover/60 hover:border-border", destructive: "bg-destructive text-destructive-foreground hover:opacity-90", - link: "text-accent underline-offset-4 hover:underline", + link: "text-accent underline-offset-4 hover:underline min-h-0", }, size: { - default: "h-9 px-4 py-2", - sm: "h-8 px-3 text-[10px]", - lg: "h-10 px-5", - icon: "h-9 w-9", + default: "h-11 md:h-9 px-4 py-2", + sm: "h-10 md:h-8 px-3 text-[10px]", + lg: "h-12 md:h-10 px-5", + icon: "h-11 w-11 md:h-9 md:w-9 min-h-[44px] md:min-h-0", }, }, defaultVariants: { variant: "default", size: "default" }, diff --git a/dashboard-ui/src/components/ui/data-table.tsx b/dashboard-ui/src/components/ui/data-table.tsx index bc0f16ee8..87e214907 100644 --- a/dashboard-ui/src/components/ui/data-table.tsx +++ b/dashboard-ui/src/components/ui/data-table.tsx @@ -11,11 +11,11 @@ export function TableWrap({ return (
-
{children}
+
{children}
); } @@ -26,7 +26,7 @@ export function DataTable({ }: React.TableHTMLAttributes): JSX.Element { return ( {children}
@@ -40,7 +40,7 @@ export function Th({ return ( @@ -56,7 +56,7 @@ export function Td({ }: React.TdHTMLAttributes): JSX.Element { return ( {children} diff --git a/dashboard-ui/src/components/ui/dialog.tsx b/dashboard-ui/src/components/ui/dialog.tsx index 4fdbd6618..8323fad13 100644 --- a/dashboard-ui/src/components/ui/dialog.tsx +++ b/dashboard-ui/src/components/ui/dialog.tsx @@ -40,11 +40,12 @@ export const DialogContent = React.forwardRef< - + )} @@ -86,7 +88,7 @@ export function DialogFooter({ return (
); diff --git a/dashboard-ui/src/components/ui/input.tsx b/dashboard-ui/src/components/ui/input.tsx index 2f2c1fb4a..913471135 100644 --- a/dashboard-ui/src/components/ui/input.tsx +++ b/dashboard-ui/src/components/ui/input.tsx @@ -10,11 +10,12 @@ export const Input = React.forwardRef( type={type} ref={ref} className={cn( - "flex h-9 w-full border border-border/50 bg-background/40 px-3 py-2 text-[13px] transition-all duration-200 ease-[var(--ease-ios)]", + "flex h-11 md:h-9 w-full rounded-lg border border-border/50 bg-background/40 px-4 md:px-3 py-2 text-base md:text-[13px] transition-all duration-200 ease-[var(--ease-ios)]", "placeholder:text-muted-foreground/70", "focus-visible:outline-none focus-visible:border-accent/70 focus-visible:ring-2 focus-visible:ring-accent/15", "disabled:cursor-not-allowed disabled:opacity-50 hover:bg-background/60", "file:border-0 file:bg-transparent file:text-sm file:font-medium", + "touch-manipulation", className, )} {...props} diff --git a/dashboard-ui/src/components/ui/page-header.tsx b/dashboard-ui/src/components/ui/page-header.tsx index 21ca60582..050413a3a 100644 --- a/dashboard-ui/src/components/ui/page-header.tsx +++ b/dashboard-ui/src/components/ui/page-header.tsx @@ -19,24 +19,24 @@ export function PageHeader({ return (
{kicker && ( -
+
[ {kicker} ]
)} -

+

{title}

{subtitle ? ( -

{subtitle}

+

{subtitle}

) : null}
- {actions ?
{actions}
: null} + {actions ?
{actions}
: null}
); } diff --git a/dashboard-ui/src/components/ui/search-input.tsx b/dashboard-ui/src/components/ui/search-input.tsx new file mode 100644 index 000000000..fcdc62d5b --- /dev/null +++ b/dashboard-ui/src/components/ui/search-input.tsx @@ -0,0 +1,34 @@ +import { forwardRef } from "react"; +import { SearchIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +interface SearchInputProps extends React.InputHTMLAttributes { + containerClassName?: string; +} + +/** + * Iconed search field. Uses a plain with left padding large enough to + * clear the absolute-positioned search icon, so the placeholder text never + * sits on top of the icon (unlike stacking padding overrides on the shared + * Input component, where md:px-3 can clobber pl-* on desktop). + */ +export const SearchInput = forwardRef( + function SearchInput({ className, containerClassName, ...props }, ref) { + return ( +
+ + +
+ ); + }, +); \ No newline at end of file diff --git a/dashboard-ui/src/components/ui/surface.tsx b/dashboard-ui/src/components/ui/surface.tsx index 76932c26e..9af54843d 100644 --- a/dashboard-ui/src/components/ui/surface.tsx +++ b/dashboard-ui/src/components/ui/surface.tsx @@ -13,10 +13,10 @@ export function Surface({ return (
-

+

{title}

{subtitle ? ( @@ -70,13 +70,13 @@ export function EmptyState({ return (
-
{title}
+
{title}
{description ? ( -
+
{description}
) : null} @@ -102,7 +102,7 @@ export function Pill({ return ( {(title || subtitle || action) && ( -
+
{title && ( -

+

{title}

)} {subtitle && ( -

{subtitle}

+

{subtitle}

)}
{action &&
{action}
} @@ -185,7 +185,7 @@ export function FlushCell({ return (
void; + disabled?: boolean; + size?: "sm" | "md"; + "aria-label"?: string; + title?: string; +} + +/** + * iOS-style toggle switch — pill track, white knob with shadow. + * Uses Catppuccin semantic colors: --success for on, --border/--bg-surface-hover for off. + * Fixed compact size on every screen (same as desktop), so toggles stay small + * and pleasant on mobile too. min-h-0 keeps the global mobile + * button{min-height:44px} rule from stretching the pill. + * + * Geometry (borderless, symmetric 2px margins): + * sm: track 46×22, knob 18 + * md: track 50×24, knob 20 + */ +export function Switch({ + checked, + onCheckedChange, + disabled, + size = "md", + "aria-label": ariaLabel, + title, +}: SwitchProps): JSX.Element { + const track = + size === "sm" + ? "h-[22px] w-[46px]" + : "h-6 w-[50px]"; + const knob = + size === "sm" + ? "h-[18px] w-[18px]" + : "h-[20px] w-[20px]"; + const translate = + size === "sm" + ? "translate-x-[26px]" + : "translate-x-[28px]"; + + return ( + + ); +} \ No newline at end of file diff --git a/dashboard-ui/src/components/ui/toggle-field.tsx b/dashboard-ui/src/components/ui/toggle-field.tsx new file mode 100644 index 000000000..8496e3db1 --- /dev/null +++ b/dashboard-ui/src/components/ui/toggle-field.tsx @@ -0,0 +1,58 @@ +import { type ReactNode } from "react"; +import { cn } from "@/lib/utils"; +import { Switch } from "@/components/ui/switch"; + +interface ToggleFieldProps { + checked: boolean; + onCheckedChange: (checked: boolean) => void; + label: ReactNode; + description?: ReactNode; + disabled?: boolean; + size?: "sm" | "md"; + "aria-label"?: string; + className?: string; +} + +/** + * iOS-style toggle row: label + optional description on the left, switch pinned + * to the right. The whole row is tappable and keeps a comfortable 44px touch + * target on mobile, while the label area truncates instead of pushing the + * switch off-screen. + */ +export function ToggleField({ + checked, + onCheckedChange, + label, + description, + disabled, + size = "md", + "aria-label": ariaLabel, + className, +}: ToggleFieldProps): JSX.Element { + return ( + + ); +} diff --git a/dashboard-ui/src/lib/api/fallback.ts b/dashboard-ui/src/lib/api/fallback.ts new file mode 100644 index 000000000..41f5aabac --- /dev/null +++ b/dashboard-ui/src/lib/api/fallback.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; +import { apiFetch } from "./client"; + +const FallbackRuleSchema = z.object({ + source: z.string(), + targets: z.array(z.string()), + enabled: z.boolean().optional(), +}); + +const FallbackConfigSchema = z.object({ + rules: z.array(FallbackRuleSchema), +}); + +export type FallbackRule = z.infer; +export type FallbackConfig = z.infer; + +export async function fetchFallback(): Promise { + const data = await apiFetch("/admin/api/v1/fallback", { + schema: FallbackConfigSchema, + }); + return data.rules; +} + +export async function updateFallback(rules: FallbackRule[]): Promise { + const data = await apiFetch("/admin/api/v1/fallback", { + method: "PUT", + json: { rules }, + schema: FallbackConfigSchema, + }); + return data.rules; +} diff --git a/dashboard-ui/src/lib/api/pools-types.ts b/dashboard-ui/src/lib/api/pools-types.ts index 5979e5e57..0cc01feb4 100644 --- a/dashboard-ui/src/lib/api/pools-types.ts +++ b/dashboard-ui/src/lib/api/pools-types.ts @@ -16,6 +16,8 @@ export const PoolSnapshotSchema = z.object({ name: z.string(), strategy: z.string(), members: z.array(PoolMemberSnapshotSchema), + health_aware: z.boolean().optional().default(true), + source: z.string().optional().default("config"), }); export type PoolSnapshot = z.infer; @@ -31,3 +33,35 @@ export const PoolsResponseSchema = z.object({ pools: z.array(PoolSnapshotSchema), }); export type PoolsResponse = z.infer; + +export const PoolOptionsSchema = z.object({ + providers: z.array( + z.object({ + name: z.string(), + type: z.string(), + }), + ), +}); +export type PoolOptions = z.infer; + +export const PoolModifyResponseSchema = z.object({ + message: z.string(), + pool: z.string(), + runtime_applied: z.boolean(), + requires_runtime_refresh: z.boolean(), + runtime_refresh_error: z.string().optional(), +}); +export type PoolModifyResponse = z.infer; + +// Supported load-balancing strategies shown in the UI. +export const POOL_STRATEGIES = ["round_robin", "weighted"] as const; +export type PoolStrategy = (typeof POOL_STRATEGIES)[number]; + +// Payload sent to create/update a pool. +export interface PoolConfigPayload { + name: string; + members: string[]; + strategy: string; + weights?: Record; + health_aware?: boolean; +} diff --git a/dashboard-ui/src/lib/api/pools.ts b/dashboard-ui/src/lib/api/pools.ts index 6175ad42f..9d00b45d3 100644 --- a/dashboard-ui/src/lib/api/pools.ts +++ b/dashboard-ui/src/lib/api/pools.ts @@ -1,8 +1,48 @@ import { apiFetch } from "./client"; -import { PoolsResponseSchema, type PoolsResponse } from "./pools-types"; +import { + PoolsResponseSchema, + PoolOptionsSchema, + PoolModifyResponseSchema, + type PoolsResponse, + type PoolOptions, + type PoolConfigPayload, + type PoolModifyResponse, +} from "./pools-types"; export function fetchPools(): Promise { return apiFetch("/admin/api/v1/pools", { schema: PoolsResponseSchema, }) as Promise; } + +export function fetchPoolOptions(): Promise { + return apiFetch("/admin/api/v1/pools/options", { + schema: PoolOptionsSchema, + }) as Promise; +} + +export function createPool(payload: PoolConfigPayload): Promise { + return apiFetch("/admin/api/v1/pools", { + method: "POST", + json: payload, + schema: PoolModifyResponseSchema, + }) as Promise; +} + +export function updatePool( + name: string, + payload: PoolConfigPayload, +): Promise { + return apiFetch(`/admin/api/v1/pools/${encodeURIComponent(name)}`, { + method: "PUT", + json: payload, + schema: PoolModifyResponseSchema, + }) as Promise; +} + +export function deletePool(name: string): Promise { + return apiFetch(`/admin/api/v1/pools/${encodeURIComponent(name)}`, { + method: "DELETE", + schema: PoolModifyResponseSchema, + }) as Promise; +} diff --git a/dashboard-ui/src/lib/api/providers-types.ts b/dashboard-ui/src/lib/api/providers-types.ts index 04aa14706..c850cfcb1 100644 --- a/dashboard-ui/src/lib/api/providers-types.ts +++ b/dashboard-ui/src/lib/api/providers-types.ts @@ -39,6 +39,11 @@ export const SanitizedProviderConfigSchema = z.object({ base_url: z.string().optional(), api_version: z.string().optional(), models: z.array(z.string()).optional(), + bind_ip: z.string().optional(), + enabled: z.boolean().optional(), + api_key: z.string().optional(), + api_key_set: z.boolean().optional(), + pool_only: z.boolean().optional(), resilience: SanitizedResilienceConfigSchema.optional(), }); export type SanitizedProviderConfig = z.infer; @@ -59,12 +64,12 @@ export const ProviderRuntimeSnapshotSchema = z.object({ }); export type ProviderRuntimeSnapshot = z.infer; -export type ProviderStatusKind = "healthy" | "degraded" | "unhealthy"; +export type ProviderStatusKind = "healthy" | "degraded" | "unhealthy" | "disabled"; export const ProviderStatusItemSchema = z.object({ name: z.string(), type: z.string(), - status: z.enum(["healthy", "degraded", "unhealthy"]), + status: z.enum(["healthy", "degraded", "unhealthy", "disabled"]), status_label: z.string(), status_reason: z.string(), last_error: z.string().optional(), @@ -79,6 +84,7 @@ export const ProviderStatusSummarySchema = z.object({ healthy: z.number().int(), degraded: z.number().int(), unhealthy: z.number().int(), + disabled: z.number().int().optional(), overall_status: z.enum(["healthy", "degraded", "unhealthy"]), }); export type ProviderStatusSummary = z.infer; diff --git a/dashboard-ui/src/lib/api/providers.ts b/dashboard-ui/src/lib/api/providers.ts index 45b8d8a70..5d7e48e10 100644 --- a/dashboard-ui/src/lib/api/providers.ts +++ b/dashboard-ui/src/lib/api/providers.ts @@ -6,6 +6,8 @@ import { type RuntimeRefreshReport, } from "./providers-types"; +export type { ProviderStatusResponse, RuntimeRefreshReport }; + export function fetchProviderStatus(): Promise { return apiFetch("/admin/api/v1/providers/status", { schema: ProviderStatusResponseSchema, @@ -26,6 +28,11 @@ export interface ProviderFormData { api_version: string; api_key: string; models: string; + enabled?: boolean; + bind_ip?: string; + pool_only?: boolean; + /** When present on an update, renames the provider to this value. */ + new_name?: string; } export function createProvider(data: ProviderFormData): Promise<{ message: string; provider: string }> { @@ -42,6 +49,10 @@ export function updateProvider(name: string, data: Partial): P }) as Promise<{ message: string; provider: string }>; } +export function setProviderEnabled(name: string, enabled: boolean): Promise<{ message: string; provider: string }> { + return updateProvider(name, { enabled }); +} + export function deleteProvider(name: string): Promise<{ message: string; provider: string }> { return apiFetch(`/admin/api/v1/providers/${encodeURIComponent(name)}`, { method: "DELETE", diff --git a/dashboard-ui/src/router.tsx b/dashboard-ui/src/router.tsx index 79d7c1255..92c1238f7 100644 --- a/dashboard-ui/src/router.tsx +++ b/dashboard-ui/src/router.tsx @@ -24,6 +24,7 @@ import { PoolsPage } from "@/routes/pools"; import { ConsolePage } from "@/routes/console"; import { CombosPage } from "@/routes/combos"; +import { FallbackPage } from "@/routes/fallback"; import { getBasePath } from "@/lib/basepath"; @@ -115,6 +116,12 @@ const combosRoute = createRoute({ component: protectedComponent(CombosPage, "admin/models"), }); +const fallbackRoute = createRoute({ + getParentRoute: () => dashboardLayout, + path: "fallback", + component: protectedComponent(FallbackPage, "admin/models"), +}); + const cliToolsRoute = createRoute({ getParentRoute: () => dashboardLayout, path: "cli-tools", @@ -181,6 +188,7 @@ const routeTree = rootRoute.addChildren([ playgroundRoute, modelsRoute, combosRoute, + fallbackRoute, auditLogsRoute, consoleRoute, cliToolsRoute, diff --git a/dashboard-ui/src/routes/audit-logs.tsx b/dashboard-ui/src/routes/audit-logs.tsx index 02817c7d7..ed52b03c7 100644 --- a/dashboard-ui/src/routes/audit-logs.tsx +++ b/dashboard-ui/src/routes/audit-logs.tsx @@ -173,7 +173,7 @@ export function AuditLogsPage(): JSX.Element {
-

Audit Logs

+

Audit Logs

View detailed request logs and conversations.

@@ -213,7 +213,7 @@ export function AuditLogsPage(): JSX.Element {
- { setSort(e.target.value); setOffset(0); }}> diff --git a/dashboard-ui/src/routes/auth-keys.tsx b/dashboard-ui/src/routes/auth-keys.tsx index 94af4a399..9545acd92 100644 --- a/dashboard-ui/src/routes/auth-keys.tsx +++ b/dashboard-ui/src/routes/auth-keys.tsx @@ -1,6 +1,7 @@ import React, { useMemo, useState } from "react"; import { PlusIcon, CopyIcon, CheckIcon, ShieldAlertIcon, BarChart3Icon, ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Surface, EmptyState } from "@/components/ui/surface"; import { PageHeader } from "@/components/ui/page-header"; @@ -446,10 +447,11 @@ export function AuthKeysPage(): JSX.Element {
{providerOptions.length > 0 ? providerOptions.map((provider) => ( @@ -460,10 +462,11 @@ export function AuthKeysPage(): JSX.Element { Allowed models