From 7b2fefd13de0e291d3a365c364c2b74c866a92bf Mon Sep 17 00:00:00 2001 From: Devin AI Date: Fri, 22 May 2026 09:04:10 +0000 Subject: [PATCH 1/2] Fix 9 dashboard bugs: menu flicker, sync coverage, test gating, toast, ollama cloud models, key persistence, modal outside-click, validation, combo multi-select --- src/core/model/provider_catalog.json | 30 +++ src/core/model/sources/9router.json | 39 +++- src/server/api/mod.rs | 19 +- src/server/api/provider_validate.rs | 54 ++++- src/server/api/providers.rs | 25 ++- web/src/components/CombosPageClient.tsx | 62 +++++- web/src/components/DbBackupsPageClient.tsx | 198 +++++++++++------- web/src/components/EndpointPageClient.tsx | 60 ++++-- web/src/components/ProfilePageClient.tsx | 60 ------ .../providers/CompatibleModelsSection.tsx | 12 +- .../components/providers/ConnectionsCard.tsx | 43 +++- .../providers/PassthroughModelsSection.tsx | 4 +- .../providers/ProviderDetailPageClient.tsx | 118 +++++++++-- .../providers/ProvidersPageClient.tsx | 37 +++- .../components/usage/ProviderLimits/index.tsx | 31 ++- web/src/layouts/Layout.astro | 18 ++ web/src/pages/dashboard/profile/index.astro | 10 - .../shared/components/EditConnectionModal.tsx | 24 ++- web/src/shared/components/Modal.tsx | 2 +- .../shared/components/ModelSelectModal.tsx | 10 +- web/src/shared/components/PricingModal.tsx | 29 ++- web/src/shared/components/Sidebar.tsx | 24 +-- .../components/layouts/DashboardLayout.tsx | 32 ++- web/src/shared/components/styles/global.css | 50 +++++ 24 files changed, 717 insertions(+), 274 deletions(-) delete mode 100644 web/src/components/ProfilePageClient.tsx delete mode 100644 web/src/pages/dashboard/profile/index.astro diff --git a/src/core/model/provider_catalog.json b/src/core/model/provider_catalog.json index 4554af97..b7ee121f 100644 --- a/src/core/model/provider_catalog.json +++ b/src/core/model/provider_catalog.json @@ -2200,25 +2200,55 @@ "name": "Kimi K2.5", "kind": "llm" }, + { + "id": "kimi-k2.6:cloud", + "name": "Kimi K2.6 (Cloud)", + "kind": "llm" + }, { "id": "glm-5", "name": "GLM 5", "kind": "llm" }, + { + "id": "glm-5.1:cloud", + "name": "GLM 5.1 (Cloud)", + "kind": "llm" + }, { "id": "minimax-m2.5", "name": "MiniMax M2.5", "kind": "llm" }, + { + "id": "minimax-m2.7:cloud", + "name": "MiniMax M2.7 (Cloud)", + "kind": "llm" + }, { "id": "glm-4.7-flash", "name": "GLM 4.7 Flash", "kind": "llm" }, + { + "id": "deepseek-v4-flash:cloud", + "name": "DeepSeek V4 Flash (Cloud)", + "kind": "llm" + }, + { + "id": "deepseek-v4-pro:cloud", + "name": "DeepSeek V4 Pro (Cloud)", + "kind": "llm" + }, { "id": "qwen3.5", "name": "Qwen3.5", "kind": "llm" + }, + { + "id": "qwen3.5:cloud", + "name": "Qwen3.5 (Cloud)", + "kind": "llm" } ] }, diff --git a/src/core/model/sources/9router.json b/src/core/model/sources/9router.json index 94948ba5..ead40962 100644 --- a/src/core/model/sources/9router.json +++ b/src/core/model/sources/9router.json @@ -1,7 +1,7 @@ { "source": "9router", - "ref": "v0.4.55", - "generatedAt": "2026-05-19T14:31:36.624Z", + "ref": "v0.4.59-1-ge1b821d", + "generatedAt": "2026-05-21T15:40:41.710Z", "providerIdToAlias": { "claude": "cc", "gemini": "gemini", @@ -3209,25 +3209,55 @@ "name": "Kimi K2.5", "kind": "llm" }, + { + "id": "kimi-k2.6:cloud", + "name": "Kimi K2.6 (Cloud)", + "kind": "llm" + }, { "id": "glm-5", "name": "GLM 5", "kind": "llm" }, + { + "id": "glm-5.1:cloud", + "name": "GLM 5.1 (Cloud)", + "kind": "llm" + }, { "id": "minimax-m2.5", "name": "MiniMax M2.5", "kind": "llm" }, + { + "id": "minimax-m2.7:cloud", + "name": "MiniMax M2.7 (Cloud)", + "kind": "llm" + }, { "id": "glm-4.7-flash", "name": "GLM 4.7 Flash", "kind": "llm" }, + { + "id": "deepseek-v4-flash:cloud", + "name": "DeepSeek V4 Flash (Cloud)", + "kind": "llm" + }, + { + "id": "deepseek-v4-pro:cloud", + "name": "DeepSeek V4 Pro (Cloud)", + "kind": "llm" + }, { "id": "qwen3.5", "name": "Qwen3.5", "kind": "llm" + }, + { + "id": "qwen3.5:cloud", + "name": "Qwen3.5 (Cloud)", + "kind": "llm" } ] }, @@ -4330,6 +4360,11 @@ "id": "grok-3", "name": "Grok 3", "kind": "llm" + }, + { + "id": "grok-2-image-1212", + "name": "Grok 2 Image", + "kind": "image" } ] }, diff --git a/src/server/api/mod.rs b/src/server/api/mod.rs index b464474b..4d2fc38a 100644 --- a/src/server/api/mod.rs +++ b/src/server/api/mod.rs @@ -389,10 +389,25 @@ async fn list_providers_api(State(state): State, headers: HeaderMap) - } let snapshot = state.db.snapshot(); - let connections: Vec<_> = snapshot + let connections: Vec = snapshot .provider_connections .iter() - .map(redact_provider_connection) + .map(|c| { + // Stamp `hasApiKey` so the dashboard can render an "on file" + // pill without needing the secret itself. The key is still + // redacted out of the payload via redact_provider_connection. + let has_api_key = c.api_key.as_deref().is_some_and(|k| !k.is_empty()) + || c.provider_specific_data + .get("apiKey") + .and_then(|v| v.as_str()) + .is_some_and(|s| !s.is_empty()); + let mut value = serde_json::to_value(redact_provider_connection(c)) + .unwrap_or_else(|_| json!({})); + if let Some(obj) = value.as_object_mut() { + obj.insert("hasApiKey".into(), Value::Bool(has_api_key)); + } + value + }) .collect(); Json(json!({ "connections": connections })).into_response() } diff --git a/src/server/api/provider_validate.rs b/src/server/api/provider_validate.rs index 65656e2d..45888b47 100644 --- a/src/server/api/provider_validate.rs +++ b/src/server/api/provider_validate.rs @@ -272,9 +272,57 @@ async fn validate_provider( if base_url.ends_with("/messages") { base_url = base_url[..base_url.len()-9].to_string(); } - let url = if base_url.is_empty() { "https://api.anthropic.com/v1/messages".to_string() } else { format!("{}/messages", base_url) }; - match client.post(&url).header("x-api-key", &api_key).header("anthropic-version", "2023-06-01").header("Authorization", format!("Bearer {api_key}")).send().await { - Ok(resp) => (resp.status().is_success(), None), + // Fall back to the provider's well-known Anthropic-compatible + // endpoint so a user who hasn't configured a custom node still + // gets a real validation instead of being routed at Anthropic. + let url = if !base_url.is_empty() { + format!("{}/messages", base_url) + } else { + match p { + "glm" => "https://api.z.ai/api/anthropic/v1/messages".to_string(), + "kimi" => "https://api.kimi.com/coding/v1/messages".to_string(), + "minimax" => "https://api.minimax.io/anthropic/v1/messages".to_string(), + "minimax-cn" => "https://api.minimaxi.com/anthropic/v1/messages".to_string(), + _ => "https://api.anthropic.com/v1/messages".to_string(), + } + }; + // GLM/Kimi/MiniMax accept either x-api-key or Bearer; send a + // minimal `ping` so the upstream actually executes auth (a HEAD + // or empty POST tends to return 4xx that isn't auth-related). + let body = json!({ + "model": match p { + "glm" => "glm-4.5-flash", + "kimi" => "kimi-k2.5", + "minimax" | "minimax-cn" => "minimax-m2", + _ => "claude-3-5-haiku-20241022", + }, + "max_tokens": 1, + "messages": [{"role": "user", "content": "ping"}], + }); + match client.post(&url) + .header("x-api-key", &api_key) + .header("anthropic-version", "2023-06-01") + .header("Authorization", format!("Bearer {api_key}")) + .json(&body) + .send().await { + Ok(resp) => { + let status = resp.status(); + // Treat any non-auth 2xx/4xx as proof the key works: + // some upstreams 400 on the dummy body but still validate + // the key. Only 401/403 mean the key is wrong. + let code = status.as_u16(); + if code == 401 || code == 403 { (false, Some("Invalid API key".into())) } + else if status.is_success() || (400..500).contains(&code) && code != 429 { + let body_text = resp.text().await.unwrap_or_default(); + let body_lower = body_text.to_lowercase(); + let looks_auth = body_lower.contains("invalid api key") + || body_lower.contains("unauthorized") + || body_lower.contains("authentication failed"); + (!looks_auth, if looks_auth { Some("Invalid API key".into()) } else { None }) + } else { + (status.is_success(), None) + } + }, Err(e) => (false, Some(e.to_string())), } } diff --git a/src/server/api/providers.rs b/src/server/api/providers.rs index d03a0e2e..46e714c8 100644 --- a/src/server/api/providers.rs +++ b/src/server/api/providers.rs @@ -552,6 +552,7 @@ async fn test_model( .into_response(); } + // OpenAI-style response: { choices: [...] } let has_choices = parsed .as_ref() .and_then(|value| value.get("choices")) @@ -559,10 +560,30 @@ async fn test_model( .map(|choices| !choices.is_empty()) .unwrap_or(false); + // Claude / Anthropic-style response: { content: [...], stop_reason } + // forwarded through the proxy as-is. Treat a non-empty `content` + // array as a successful round-trip too (Bug 8). + let has_anthropic_content = parsed + .as_ref() + .and_then(|value| value.get("content")) + .and_then(Value::as_array) + .map(|content| !content.is_empty()) + .unwrap_or(false); + + // Gemini-style response: { candidates: [...] } + let has_gemini_candidates = parsed + .as_ref() + .and_then(|value| value.get("candidates")) + .and_then(Value::as_array) + .map(|c| !c.is_empty()) + .unwrap_or(false); + + let ok_completion = has_choices || has_anthropic_content || has_gemini_candidates; + Json(TestModelResponse { - ok: has_choices, + ok: ok_completion, latency_ms: Some(latency_ms), - error: (!has_choices) + error: (!ok_completion) .then(|| "Provider returned no completion choices for this model".to_string()), status: Some(status), }) diff --git a/web/src/components/CombosPageClient.tsx b/web/src/components/CombosPageClient.tsx index 8729c339..7794e712 100644 --- a/web/src/components/CombosPageClient.tsx +++ b/web/src/components/CombosPageClient.tsx @@ -2,6 +2,8 @@ import { useState, useEffect, useCallback } from "react"; import { Card, Button, Modal, Input, CardSkeleton, ModelSelectModal, Toggle } from "@/shared/components"; +import { ConfirmModal } from "@/shared/components/Modal"; +import { useNotificationStore } from "@/store/notificationStore"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; @@ -49,6 +51,9 @@ export default function CombosPage() { const [editingCombo, setEditingCombo] = useState(null); const [activeProviders, setActiveProviders] = useState([]); const [comboStrategies, setComboStrategies] = useState>({}); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleting, setDeleting] = useState(false); + const notify = useNotificationStore(); const { copied, copy } = useCopyToClipboard(); useEffect(() => { @@ -89,12 +94,14 @@ export default function CombosPage() { if (res.ok) { await fetchData(); setShowCreateModal(false); + notify.success(`Combo "${data.name}" created`); } else { const err = await res.json(); - alert(err.error || "Failed to create combo"); + notify.error(err.error || "Failed to create combo"); } } catch (error) { console.log("Error creating combo:", error); + notify.error("Failed to create combo"); } }; @@ -111,23 +118,34 @@ export default function CombosPage() { if (res.ok) { await fetchData(); setEditingCombo(null); + notify.success(`Combo "${data.name}" updated`); } else { const err = await res.json(); - alert(err.error || "Failed to update combo"); + notify.error(err.error || "Failed to update combo"); } } catch (error) { console.log("Error updating combo:", error); + notify.error("Failed to update combo"); } }; - const handleDelete = async (id: string) => { - if (!confirm("Delete this combo?")) return; + const handleDelete = (combo: Combo) => { + setDeleteTarget(combo); + }; + + const confirmDelete = async () => { + if (!deleteTarget) return; + setDeleting(true); try { - const res = await fetch(`/api/combos/${id}`, { method: "DELETE" }); + const res = await fetch(`/api/combos/${deleteTarget.id}`, { method: "DELETE" }); if (res.ok) { - setCombos(combos.filter(c => c.id !== id)); + setCombos(combos.filter(c => c.id !== deleteTarget.id)); + notify.success(`Combo "${deleteTarget.name}" deleted`); + } else { + notify.error("Failed to delete combo"); } } catch (error) { + notify.error("Failed to delete combo"); console.log("Error deleting combo:", error); } }; @@ -200,7 +218,7 @@ export default function CombosPage() { copied={copied} onCopy={copy} onEdit={() => setEditingCombo(combo)} - onDelete={() => handleDelete(combo.id)} + onDelete={() => handleDelete(combo)} roundRobinEnabled={comboStrategies[combo.name]?.fallbackStrategy === "round-robin"} onToggleRoundRobin={(enabled) => handleToggleRoundRobin(combo.name, enabled)} /> @@ -226,6 +244,21 @@ export default function CombosPage() { onSave={(data) => handleUpdate(editingCombo.id, data)} activeProviders={activeProviders} /> + + setDeleteTarget(null)} + onConfirm={async () => { + await confirmDelete(); + setDeleteTarget(null); + setDeleting(false); + }} + title="Delete combo" + message={deleteTarget ? <>Are you sure you want to delete combo {deleteTarget.name}? This cannot be undone. : null} + confirmText="Delete" + variant="danger" + loading={deleting} + /> ); } @@ -718,10 +751,14 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF else setNameError(""); }; + // Toggle in/out of combo so the modal can stay open while operators + // pick several models, including unpicking ones added by mistake. const handleAddModel = (model: { value: string }) => { - if (!models.includes(model.value)) { - setModels([...models, model.value]); - } + setModels((prev) => + prev.includes(model.value) + ? prev.filter((m) => m !== model.value) + : [...prev, model.value], + ); }; const handleRemoveModel = (index: number) => { @@ -756,6 +793,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF isOpen={isOpen} onClose={onClose} title={isEdit ? "Edit Combo" : "Create Combo"} + size="lg" >
{/* Name */} @@ -881,9 +919,11 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, kindF isOpen={showModelSelect} onClose={() => setShowModelSelect(false)} onSelect={handleAddModel} + selectedModel={models} + closeOnSelect={false} activeProviders={activeProviders} modelAliases={modelAliases} - title="Add Model to Combo" + title="Add Models to Combo" kindFilter={kindFilter} /> diff --git a/web/src/components/DbBackupsPageClient.tsx b/web/src/components/DbBackupsPageClient.tsx index 89eb11a6..d4b1b9d9 100644 --- a/web/src/components/DbBackupsPageClient.tsx +++ b/web/src/components/DbBackupsPageClient.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Button, Card } from "@/shared/components"; +import { ConfirmModal } from "@/shared/components/Modal"; // ────────────────────────────────────────────────────────────────────── // Types — mirror src/db/backups.rs and src/server/api/db_backups.rs. @@ -67,6 +68,9 @@ export default function DbBackupsPageClient() { const [busy, setBusy] = useState(false); const [status, setStatus] = useState(null); const [pendingRestore, setPendingRestore] = useState(null); + const [pendingDelete, setPendingDelete] = useState(null); + const [pendingCleanup, setPendingCleanup] = useState(false); + const [pendingImport, setPendingImport] = useState(null); const fileInputRef = useRef(null); const fetchList = useCallback(async () => { @@ -113,30 +117,37 @@ export default function DbBackupsPageClient() { } }, [fetchList]); - const handleDelete = useCallback( - async (id: string) => { - if (!confirm(`Delete backup ${id}?`)) return; - setBusy(true); - setStatus(null); - try { - const res = await fetch(`/api/db-backups/${encodeURIComponent(id)}`, { method: "DELETE" }); - if (!res.ok) throw new Error(`Server returned ${res.status}`); - setStatus({ type: "success", text: "Backup deleted." }); - await fetchList(); - } catch (err) { - setStatus({ - type: "error", - text: err instanceof Error ? `Failed to delete: ${err.message}` : "Failed to delete", - }); - } finally { - setBusy(false); - } - }, - [fetchList] - ); + const handleDelete = useCallback((backup: BackupInfo) => { + setPendingDelete(backup); + }, []); - const handleCleanup = useCallback(async () => { - if (!confirm("Prune backups using the current retention settings?")) return; + const confirmDelete = useCallback(async () => { + const target = pendingDelete; + if (!target) return; + setPendingDelete(null); + setBusy(true); + setStatus(null); + try { + const res = await fetch(`/api/db-backups/${encodeURIComponent(target.id)}`, { method: "DELETE" }); + if (!res.ok) throw new Error(`Server returned ${res.status}`); + setStatus({ type: "success", text: "Backup deleted." }); + await fetchList(); + } catch (err) { + setStatus({ + type: "error", + text: err instanceof Error ? `Failed to delete: ${err.message}` : "Failed to delete", + }); + } finally { + setBusy(false); + } + }, [pendingDelete, fetchList]); + + const handleCleanup = useCallback(() => { + setPendingCleanup(true); + }, []); + + const confirmCleanup = useCallback(async () => { + setPendingCleanup(false); setBusy(true); setStatus(null); try { @@ -211,43 +222,43 @@ export default function DbBackupsPageClient() { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; - if ( - !confirm( - `Import ${file.name}? This replaces the current database. A pre-import snapshot will be created automatically.` - ) - ) { - return; - } - setBusy(true); - setStatus(null); - try { - const form = new FormData(); - form.append("file", file); - const res = await fetch("/api/db-backups/import", { method: "POST", body: form }); - if (!res.ok) { - const text = await res.text(); - throw new Error(text || `Server returned ${res.status}`); - } - const json = await res.json(); - setStatus({ - type: "success", - text: `Imported ${file.name} — ${json?.providerCount ?? 0} providers, ${ - json?.comboCount ?? 0 - } combos, ${json?.apiKeyCount ?? 0} API keys.`, - }); - await fetchList(); - } catch (err) { - setStatus({ - type: "error", - text: err instanceof Error ? `Import failed: ${err.message}` : "Import failed", - }); - } finally { - setBusy(false); - } + setPendingImport(file); }, - [fetchList] + [] ); + const confirmImport = useCallback(async () => { + const file = pendingImport; + if (!file) return; + setPendingImport(null); + setBusy(true); + setStatus(null); + try { + const form = new FormData(); + form.append("file", file); + const res = await fetch("/api/db-backups/import", { method: "POST", body: form }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || `Server returned ${res.status}`); + } + const json = await res.json(); + setStatus({ + type: "success", + text: `Imported ${file.name} — ${json?.providerCount ?? 0} providers, ${ + json?.comboCount ?? 0 + } combos, ${json?.apiKeyCount ?? 0} API keys.`, + }); + await fetchList(); + } catch (err) { + setStatus({ + type: "error", + text: err instanceof Error ? `Import failed: ${err.message}` : "Import failed", + }); + } finally { + setBusy(false); + } + }, [pendingImport, fetchList]); + const backups = data?.backups ?? []; return ( @@ -357,7 +368,7 @@ export default function DbBackupsPageClient() {
- {pendingRestore && ( -
setPendingRestore(null)} - > -
e.stopPropagation()} - > -

Restore snapshot?

-

+ setPendingRestore(null)} + onConfirm={confirmRestore} + title="Restore snapshot?" + message={pendingRestore ? ( + <> +

This will replace the current database with the contents of{" "} {pendingRestore.id}. A pre-restore safety snapshot will be created first.

-

+

{pendingRestore.providerCount} providers · {pendingRestore.comboCount} combos ·{" "} {pendingRestore.apiKeyCount} API keys.

-
- - -
-
-
- )} + + ) : null} + confirmText="Restore" + variant="danger" + loading={busy} + /> + + setPendingDelete(null)} + onConfirm={confirmDelete} + title="Delete backup?" + message={pendingDelete ? <>Delete snapshot {pendingDelete.id}? This cannot be undone. : null} + confirmText="Delete" + variant="danger" + loading={busy} + /> + + setPendingCleanup(false)} + onConfirm={confirmCleanup} + title="Prune backups?" + message="Prune backups using the current retention settings? Snapshots beyond the retention window will be removed." + confirmText="Prune" + variant="danger" + loading={busy} + /> + + setPendingImport(null)} + onConfirm={confirmImport} + title="Import database?" + message={pendingImport ? <>Import {pendingImport.name}? This replaces the current database. A pre-import snapshot will be created automatically. : null} + confirmText="Import" + variant="danger" + loading={busy} + /> ); } diff --git a/web/src/components/EndpointPageClient.tsx b/web/src/components/EndpointPageClient.tsx index 47cf9938..192e2e57 100644 --- a/web/src/components/EndpointPageClient.tsx +++ b/web/src/components/EndpointPageClient.tsx @@ -3,6 +3,8 @@ import { useState, useEffect, useRef } from "react"; import type { ChangeEvent } from "react"; import { Card, Button, Input, Modal, CardSkeleton, Toggle } from "@/shared/components"; +import { ConfirmModal } from "@/shared/components/Modal"; +import { useNotificationStore } from "@/store/notificationStore"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; interface TunnelBenefit { @@ -124,6 +126,12 @@ export default function APIPageClient({ machineId }: APIPageClientProps) { // API key visibility toggle state const [visibleKeys, setVisibleKeys] = useState>(new Set()); + // Delete / pause confirmation targets (ConfirmModal replaces the old + // browser confirm() and matches the rest of the dashboard chrome). + const [deleteKeyTarget, setDeleteKeyTarget] = useState(null); + const [pauseKeyTarget, setPauseKeyTarget] = useState(null); + + const notify = useNotificationStore(); const { copied, copy } = useCopyToClipboard(); // Auto-scroll install log @@ -655,22 +663,24 @@ export default function APIPageClient({ machineId }: APIPageClientProps) { } }; - const handleDeleteKey = async (id: string): Promise => { - if (!confirm("Delete this API key?")) return; - + const deleteKey = async (id: string): Promise => { try { const res = await fetch(`/api/keys/${id}`, { method: "DELETE" }); if (res.ok) { - setKeys(keys.filter((k) => k.id !== id)); + setKeys((prev) => prev.filter((k) => k.id !== id)); // Clean up visibility state - setVisibleKeys(prev => { + setVisibleKeys((prev) => { const next = new Set(prev); next.delete(id); return next; }); + notify.success("API key deleted"); + } else { + notify.error("Failed to delete API key"); } } catch (error) { console.log("Error deleting key:", error); + notify.error("Failed to delete API key"); } }; @@ -887,12 +897,8 @@ export default function APIPageClient({ machineId }: APIPageClientProps) { message={ !requireLogin ? "Require login is disabled — anyone can access your dashboard via tunnel." - : "Dashboard uses the default password — change it in Profile settings." + : "Dashboard uses the default password — set a strong one in your config." } - action={{ - label: !requireLogin ? "Enable" : "Change password", - href: "/dashboard/profile", - }} /> )} @@ -1067,9 +1073,7 @@ export default function APIPageClient({ machineId }: APIPageClientProps) { checked={key.isActive ?? true} onChange={(checked: boolean) => { if (key.isActive && !checked) { - if (confirm(`Pause API key "${key.name}"?\n\nThis key will stop working immediately but can be resumed later.`)) { - handleToggleKey(key.id, checked); - } + setPauseKeyTarget(key); } else { handleToggleKey(key.id, checked); } @@ -1077,7 +1081,7 @@ export default function APIPageClient({ machineId }: APIPageClientProps) { title={key.isActive ? "Pause key" : "Resume key"} /> + {connection.hasApiKey && !formData.apiKey && ( + Key on file + )} {validationResult && ( {validationResult === "success" ? "Valid" : "Invalid"} )} + {testResult && !validationResult && ( + + {testResult === "success" ? "Saved key works" : "Saved key failed test"} + + )} )} diff --git a/web/src/shared/components/Modal.tsx b/web/src/shared/components/Modal.tsx index 1649f853..5b696bc7 100644 --- a/web/src/shared/components/Modal.tsx +++ b/web/src/shared/components/Modal.tsx @@ -24,7 +24,7 @@ export default function Modal({ children, footer, size = "md", - closeOnOverlay = true, + closeOnOverlay = false, showCloseButton = true, showTrafficLights = true, className, diff --git a/web/src/shared/components/ModelSelectModal.tsx b/web/src/shared/components/ModelSelectModal.tsx index ed3c7218..c1fc2b68 100644 --- a/web/src/shared/components/ModelSelectModal.tsx +++ b/web/src/shared/components/ModelSelectModal.tsx @@ -62,7 +62,7 @@ interface ModelSelectModalProps { isOpen: boolean; onClose: () => void; onSelect: (model: Model) => void; - selectedModel?: string; + selectedModel?: string | string[]; activeProviders?: ActiveProvider[]; title?: string; modelAliases?: Record; @@ -420,7 +420,9 @@ export default function ModelSelectModal({
{filteredCombos.map((combo) => { - const isSelected = selectedModel === combo.name; + const isSelected = Array.isArray(selectedModel) + ? selectedModel.includes(combo.name) + : selectedModel === combo.name; return (
+ setResetOpen(false)} + onConfirm={confirmReset} + title="Reset pricing?" + message="Reset all pricing to defaults? This cannot be undone." + confirmText="Reset" + variant="danger" + /> ); } diff --git a/web/src/shared/components/Sidebar.tsx b/web/src/shared/components/Sidebar.tsx index baa917cd..583a0ba2 100644 --- a/web/src/shared/components/Sidebar.tsx +++ b/web/src/shared/components/Sidebar.tsx @@ -8,6 +8,7 @@ import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import Button from "./Button"; import AnthropicSpike from "./AnthropicSpike"; import { ConfirmModal } from "./Modal"; +import { useNotificationStore } from "@/store/notificationStore"; import React from "react"; /** @@ -96,6 +97,7 @@ export default function Sidebar({ onClose }: SidebarProps) { const [updateInfo, setUpdateInfo] = useState<{ latestVersion: string } | null>(null); const [showUpdateModal, setShowUpdateModal] = useState(false); const [isUpdating, setIsUpdating] = useState(false); + const notify = useNotificationStore(); const [updateStatus, setUpdateStatus] = useState(null); const [enableTranslator, setEnableTranslator] = useState(false); const { copied, copy } = useCopyToClipboard(2000); @@ -132,7 +134,7 @@ export default function Sidebar({ onClose }: SidebarProps) { const res = await fetch("/api/version/update", { method: "POST" }); if (!res.ok) { const data = await res.json().catch(() => ({})); - alert(data.message || "Update failed. Please run the install command manually."); + notify.error(data.message || "Update failed. Please run the install command manually."); setIsUpdating(false); return; } @@ -352,26 +354,6 @@ export default function Sidebar({ onClose }: SidebarProps) { ) : null; })} - - {/* Settings */} - - - settings - - Settings - diff --git a/web/src/shared/components/layouts/DashboardLayout.tsx b/web/src/shared/components/layouts/DashboardLayout.tsx index f0fb6532..515841c6 100644 --- a/web/src/shared/components/layouts/DashboardLayout.tsx +++ b/web/src/shared/components/layouts/DashboardLayout.tsx @@ -13,24 +13,32 @@ interface DashboardLayoutProps { function getToastStyle(type: string) { if (type === "success") { return { - wrapper: "border-success-text/30 bg-success-bg text-success-text", + wrapper: "bg-surface border border-hairline before:bg-success-text", + iconColor: "text-success-text", + titleColor: "text-ink", icon: "check_circle", }; } if (type === "error") { return { - wrapper: "border-[color:var(--color-danger)]/30 bg-[color:var(--color-danger)]/10 text-[color:var(--color-danger)]", + wrapper: "bg-surface border border-hairline before:bg-brand-coral", + iconColor: "text-brand-coral", + titleColor: "text-ink", icon: "error", }; } if (type === "warning") { return { - wrapper: "border-amber-500/30 bg-amber-500/10 text-amber-600 dark:text-amber-400", + wrapper: "bg-surface border border-hairline before:bg-accent-amber", + iconColor: "text-accent-amber", + titleColor: "text-ink", icon: "warning", }; } return { - wrapper: "border-brand-blue-deep/30 bg-brand-blue-200 text-brand-blue-deep", + wrapper: "bg-surface border border-hairline before:bg-brand-blue-deep", + iconColor: "text-brand-blue-deep", + titleColor: "text-ink", icon: "info", }; } @@ -43,6 +51,10 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) { useEffect(() => { setMounted(true); setPathname(window.location.pathname); + document.body.classList.add("dashboard-ready"); + return () => { + document.body.classList.remove("dashboard-ready"); + }; }, []); const notifications = useNotificationStore((state) => state.notifications); @@ -56,19 +68,19 @@ export default function DashboardLayout({ children }: DashboardLayoutProps) { return (
-
- {style.icon} +
+ {style.icon}
- {n.title ?

{n.title}

: null} -

{n.message}

+ {n.title ?

{n.title}

: null} +

{n.message}

{n.dismissible ? (