Refactor: TanStack-Query-Daten-Layer + useDialog (Phase 3b)

- @tanstack/react-query (v5) als zentraler Daten-Layer; QueryClientProvider
  in main.tsx (retry 1, kein refetchOnWindowFocus, staleTime 5s).
- lib/queries.ts: Domänen-Hooks (useHealth/useSystemStatus/useServices/
  useModels/useRouting/useJobs/useTokenStats/useAgentStatus/useUpdates/
  useDiscover/useConnect/useMemory) + zentrale Query-Keys (qk) + invalidate.
  Gleicher Key = eine Anfrage über alle Views (Dedup), einheitliches Polling.
- lib/useDialog.tsx: ein Hook für Alert/Confirm/Prompt statt 5x dupliziertem
  Dialog-State + showAlert/showConfirm.
- api.ts: TokenStats + ModelsResp typisiert.
- Migriert auf Hooks/useDialog: App, SystemView, AgentView, ConnectView,
  MemoryView (manuelles useEffect+setInterval entfernt; Mutationen
  invalidieren gezielt die Query-Keys).

Verifiziert: tsc grün, Build grün, alle migrierten Views ohne Konsolenfehler,
Live-Daten (CPU/RAM/Dienste) rendern korrekt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-26 14:46:26 +02:00
parent 74f64731ab
commit 0266dc9e92
14 changed files with 644 additions and 574 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -7,7 +7,7 @@
<link rel="manifest" href="/manifest.webmanifest" /> <link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Mission Control 2.0</title> <title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-L9oobut2.js"></script> <script type="module" crossorigin src="/assets/index-CSv49ImW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DiSNgbNY.css"> <link rel="stylesheet" crossorigin href="/assets/index-DiSNgbNY.css">
</head> </head>
<body> <body>
+27
View File
@@ -8,6 +8,7 @@
"name": "mission-control-2-frontend", "name": "mission-control-2-frontend",
"version": "2.0.0", "version": "2.0.0",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.101.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",
@@ -1810,6 +1811,32 @@
"vite": "^5.2.0 || ^6 || ^7 || ^8" "vite": "^5.2.0 || ^6 || ^7 || ^8"
} }
}, },
"node_modules/@tanstack/query-core": {
"version": "5.101.1",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz",
"integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.101.1",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz",
"integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.101.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+2 -1
View File
@@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.101.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",
@@ -18,9 +19,9 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@types/node": "^22.10.1",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@types/node": "^22.10.1",
"@vitejs/plugin-react": "^4.3.4", "@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "^5.6.3", "typescript": "^5.6.3",
+3 -16
View File
@@ -11,30 +11,17 @@ import { AgentView } from "@/views/AgentView"
import { GuideView } from "@/views/GuideView" import { GuideView } from "@/views/GuideView"
import { Placeholder } from "@/views/Placeholder" import { Placeholder } from "@/views/Placeholder"
import { SystemDrawer } from "@/components/SystemDrawer" import { SystemDrawer } from "@/components/SystemDrawer"
import { api, type Health, type SystemStatus } from "@/lib/api" import { useHealth, useSystemStatus } from "@/lib/queries"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export default function App() { export default function App() {
const [view, setView] = useState<ViewId>("dashboard") const [view, setView] = useState<ViewId>("dashboard")
const [health, setHealth] = useState<Health | null>(null)
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true") const [sidebarCollapsed, setSidebarCollapsed] = useState(() => localStorage.getItem("mc_sidebar_collapsed") === "true")
const [sysStatus, setSysStatus] = useState<SystemStatus | null>(null)
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance") const [drawerTab, setDrawerTab] = useState<"maintenance" | "logs">("maintenance")
useEffect(() => { const { data: health } = useHealth()
const load = () => api<Health>("/api/health").then(setHealth).catch(() => setHealth(null)) const { data: sysStatus } = useSystemStatus(20_000)
load()
const t = setInterval(load, 10000)
return () => clearInterval(t)
}, [])
useEffect(() => {
const loadSys = () => api<SystemStatus>("/api/system/status").then(setSysStatus).catch(() => {})
loadSys()
const t = setInterval(loadSys, 20000)
return () => clearInterval(t)
}, [])
useEffect(() => { useEffect(() => {
document.documentElement.classList.add("dark") document.documentElement.classList.add("dark")
+14
View File
@@ -243,3 +243,17 @@ export interface Health {
engine_reachable: boolean engine_reachable: boolean
gateway_reachable: boolean gateway_reachable: boolean
} }
export interface TokenStats {
prompt_tokens: number
completion_tokens: number
total_tokens: number
saved_usd: number
saved_eur: number
pricing?: Record<string, { in: number; out: number }>
}
export interface ModelsResp {
models: ModelInfo[]
running?: string[]
}
+96
View File
@@ -0,0 +1,96 @@
// Zentrale Daten-Hooks (TanStack Query). Kapseln lib/api.ts und liefern Caching,
// Dedup (gleicher queryKey = eine Anfrage über alle Views), Retry und Polling.
// Mutations invalidieren gezielt die betroffenen Keys, statt manuell neu zu laden.
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query"
import {
api,
type AgentStatus,
type ConnectResp,
type DiscoverResp,
type Health,
type Job,
type Memory,
type ModelsResp,
type RoutingResp,
type ServicesResp,
type SystemStatus,
type TokenStats,
type UpdatesResp,
} from "./api"
// Zentrale Query-Keys (eine Quelle der Wahrheit für invalidate).
export const qk = {
health: ["health"] as const,
systemStatus: ["system-status"] as const,
services: ["services"] as const,
models: ["models"] as const,
routing: ["routing"] as const,
jobs: ["jobs"] as const,
tokenStats: ["token-stats"] as const,
agentStatus: ["agent-status"] as const,
updates: ["updates"] as const,
discover: ["discover"] as const,
connect: (params?: string) => ["connect", params ?? ""] as const,
memory: (q?: string, category?: string) => ["memory", q ?? "", category ?? ""] as const,
}
export const useHealth = () =>
useQuery({ queryKey: qk.health, queryFn: () => api<Health>("/api/health"), refetchInterval: 10_000 })
export const useSystemStatus = (refetchInterval = 5_000) =>
useQuery({ queryKey: qk.systemStatus, queryFn: () => api<SystemStatus>("/api/system/status"), refetchInterval })
export const useServices = (refetchInterval = 3_000) =>
useQuery({ queryKey: qk.services, queryFn: () => api<ServicesResp>("/api/system/services"), refetchInterval })
export const useModels = (refetchInterval = 4_000) =>
useQuery({ queryKey: qk.models, queryFn: () => api<ModelsResp>("/api/models"), refetchInterval })
export const useRouting = (refetchInterval = 4_000) =>
useQuery({ queryKey: qk.routing, queryFn: () => api<RoutingResp>("/api/routing"), refetchInterval })
export const useJobs = (refetchInterval = 2_000) =>
useQuery({
queryKey: qk.jobs,
queryFn: () => api<{ jobs: Job[] }>("/api/jobs"),
refetchInterval,
select: (d) => d.jobs ?? [],
})
export const useTokenStats = (refetchInterval = 3_000) =>
useQuery({ queryKey: qk.tokenStats, queryFn: () => api<TokenStats>("/api/system/token-stats"), refetchInterval })
export const useAgentStatus = (refetchInterval = 5_000) =>
useQuery({ queryKey: qk.agentStatus, queryFn: () => api<AgentStatus>("/api/agent/status"), refetchInterval })
export const useUpdates = (refetchInterval?: number) =>
useQuery({ queryKey: qk.updates, queryFn: () => api<UpdatesResp>("/api/maintenance/updates"), refetchInterval })
export const useDiscover = () =>
useQuery({ queryKey: qk.discover, queryFn: () => api<DiscoverResp>("/api/discover") })
export const useConnect = (params?: string) =>
useQuery({
queryKey: qk.connect(params),
queryFn: () => api<ConnectResp>(params ? `/api/connect?${params}` : "/api/connect"),
})
export const useMemory = (opts?: { q?: string; category?: string; limit?: number }) =>
useQuery({
queryKey: qk.memory(opts?.q, opts?.category),
queryFn: () => {
const p = new URLSearchParams()
if (opts?.q) p.set("q", opts.q)
if (opts?.category) p.set("category", opts.category)
return api<Memory[]>(`/api/memory?${p}`)
},
select: (d) => (opts?.limit ? d.slice(0, opts.limit) : d),
})
/** Nach Mutationen die betroffenen Listen-Queries neu ziehen. */
export function invalidate(qc: QueryClient, ...keys: readonly unknown[][]) {
for (const key of keys) qc.invalidateQueries({ queryKey: key })
}
export { useQueryClient }
+55
View File
@@ -0,0 +1,55 @@
// Ein Hook für Alert/Confirm/Prompt-Dialoge — ersetzt die zuvor in jeder View
// duplizierte showAlert/showConfirm-Logik + den lokalen Dialog-State.
//
// Nutzung:
// const { showAlert, showConfirm, showPrompt, dialogElement } = useDialog()
// ...
// showConfirm("Titel", "Wirklich?", () => doIt())
// return (<>{dialogElement}...</>)
import { useCallback, useState } from "react"
import { CustomDialog } from "@/components/CustomDialog"
interface DialogState {
type: "alert" | "confirm" | "prompt"
title: string
message: string
defaultValue?: string
onConfirm: (val?: string) => void
onCancel?: () => void
}
export function useDialog() {
const [dialog, setDialog] = useState<DialogState | null>(null)
const close = useCallback(() => setDialog(null), [])
const showAlert = useCallback((title: string, message: string, onConfirm?: () => void) => {
setDialog({
type: "alert", title, message,
onConfirm: () => { setDialog(null); onConfirm?.() },
})
}, [])
const showConfirm = useCallback(
(title: string, message: string, onConfirm: () => void, onCancel?: () => void) => {
setDialog({
type: "confirm", title, message,
onConfirm: () => { setDialog(null); onConfirm() },
onCancel: () => { setDialog(null); onCancel?.() },
})
}, [])
const showPrompt = useCallback(
(title: string, message: string, defaultValue: string,
onConfirm: (val?: string) => void, onCancel?: () => void) => {
setDialog({
type: "prompt", title, message, defaultValue,
onConfirm: (val) => { setDialog(null); onConfirm(val) },
onCancel: () => { setDialog(null); onCancel?.() },
})
}, [])
const dialogElement = dialog ? <CustomDialog {...dialog} /> : null
return { showAlert, showConfirm, showPrompt, close, dialogElement }
}
+15 -1
View File
@@ -1,10 +1,24 @@
import React from "react" import React from "react"
import ReactDOM from "react-dom/client" import ReactDOM from "react-dom/client"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import App from "./App" import App from "./App"
import "./index.css" import "./index.css"
// Zentraler Daten-Layer: Caching, Dedup, Retry, Polling pro Query-Hook.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 5_000,
},
},
})
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<App /> <QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</React.StrictMode>, </React.StrictMode>,
) )
+17 -49
View File
@@ -1,8 +1,9 @@
import { useEffect, useState, useRef, useCallback } from "react" import { useState, useRef, useCallback, useMemo } from "react"
import { Bot, ExternalLink, Activity, Cpu, Wrench, Shield, Check, X } from "lucide-react" import { Bot, ExternalLink, Activity, Cpu, Wrench, Shield, Check, X } from "lucide-react"
import { api, type AgentStatus } from "@/lib/api" import { api } from "@/lib/api"
import { useAgentStatus, useModels, useQueryClient, qk } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn, resolveExternalUrl } from "@/lib/utils" import { cn, resolveExternalUrl } from "@/lib/utils"
import { CustomDialog } from "@/components/CustomDialog"
function Tile({ label, ok, detail, icon: Icon, onClick }: { label: string; ok: boolean; detail?: string; icon: any; onClick?: () => void }) { function Tile({ label, ok, detail, icon: Icon, onClick }: { label: string; ok: boolean; detail?: string; icon: any; onClick?: () => void }) {
@@ -49,26 +50,21 @@ function Tile({ label, ok, detail, icon: Icon, onClick }: { label: string; ok: b
} }
export function AgentView() { export function AgentView() {
const [s, setS] = useState<AgentStatus | null>(null) const { data: s, error: sErr } = useAgentStatus(5_000)
const [error, setError] = useState("") const { data: modelsData } = useModels()
const { showAlert, dialogElement } = useDialog()
const qc = useQueryClient()
const error = sErr ? String(sErr) : ""
const availableModels = useMemo(() => {
const names = (modelsData?.models ?? []).map(
(m) => m.name.split("/").pop()?.replace(".gguf", "") || m.name)
return ["auto", "fast", "heavy", ...names]
}, [modelsData])
// Graph UI state // Graph UI state
const [hoveredNode, setHoveredNode] = useState<string | null>(null) const [hoveredNode, setHoveredNode] = useState<string | null>(null)
const [showBrainSelect, setShowBrainSelect] = useState(false) const [showBrainSelect, setShowBrainSelect] = useState(false)
const [availableModels, setAvailableModels] = useState<string[]>([])
// Custom Dialog State
const [dialog, setDialog] = useState<{
type: "alert" | "confirm"
title: string
message: string
onConfirm?: () => void
onCancel?: () => void
} | null>(null)
function showAlert(title: string, message: string, onConfirm?: () => void) {
setDialog({ type: "alert", title, message, onConfirm })
}
// Canvas pixel tracking for pixel-perfect connection graph without non-uniform scaling // Canvas pixel tracking for pixel-perfect connection graph without non-uniform scaling
const [dimensions, setDimensions] = useState({ width: 800, height: 360 }) const [dimensions, setDimensions] = useState({ width: 800, height: 360 })
@@ -99,19 +95,6 @@ export function AgentView() {
return `M ${startX} ${startY} C ${midX} ${startY}, ${midX} ${endY}, ${endX} ${endY}` return `M ${startX} ${startY} C ${midX} ${startY}, ${midX} ${endY}, ${endX} ${endY}`
} }
function loadData() {
api<AgentStatus>("/api/agent/status").then(setS).catch((e) => setError(String(e)))
}
function loadModels() {
api<{ models: { name: string }[] }>("/api/models")
.then((res) => {
const names = res.models.map(m => m.name.split("/").pop()?.replace(".gguf", "") || m.name)
setAvailableModels(["auto", "fast", "heavy", ...names])
})
.catch((e) => console.error("Error loading models", e))
}
async function changeBrainModel(model: string) { async function changeBrainModel(model: string) {
try { try {
await api("/api/agent/brain", { await api("/api/agent/brain", {
@@ -119,20 +102,13 @@ export function AgentView() {
body: JSON.stringify({ model }) body: JSON.stringify({ model })
}) })
showAlert("Erfolgreich", `Hermes-Gehirn wurde auf '${model}' geändert. Der Gateway-Dienst wurde neu gestartet.`) showAlert("Erfolgreich", `Hermes-Gehirn wurde auf '${model}' geändert. Der Gateway-Dienst wurde neu gestartet.`)
loadData() qc.invalidateQueries({ queryKey: qk.agentStatus })
setShowBrainSelect(false) setShowBrainSelect(false)
} catch (e: any) { } catch (e: any) {
showAlert("Fehler", `Fehler beim Wechseln des Gehirns: ${e.message}`) showAlert("Fehler", `Fehler beim Wechseln des Gehirns: ${e.message}`)
} }
} }
useEffect(() => {
loadData()
loadModels()
const t = setInterval(loadData, 5000)
return () => clearInterval(t)
}, [])
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Styles inside AgentView for dash flow animations */} {/* Styles inside AgentView for dash flow animations */}
@@ -471,15 +447,7 @@ export function AgentView() {
</div> </div>
)} )}
{dialog && ( {dialogElement}
<CustomDialog
type={dialog.type}
title={dialog.title}
message={dialog.message}
onConfirm={() => dialog.onConfirm && dialog.onConfirm()}
onCancel={dialog.onCancel}
/>
)}
</div> </div>
) )
} }
+6 -12
View File
@@ -1,24 +1,18 @@
import { useEffect, useState } from "react" import { useState } from "react"
import { Check, Copy, Terminal, Info, Globe, FolderOpen } from "lucide-react" import { Check, Copy, Terminal, Info, Globe, FolderOpen } from "lucide-react"
import { api, type ConnectResp } from "@/lib/api" import { useConnect } from "@/lib/queries"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
export function ConnectView() { export function ConnectView() {
const [host, setHost] = useState(localStorage.getItem("mc_host") || "192.168.178.151") const [host, setHost] = useState(localStorage.getItem("mc_host") || "192.168.178.151")
const [mcpPath, setMcpPath] = useState(localStorage.getItem("mc_mcp_path") || "") const [mcpPath, setMcpPath] = useState(localStorage.getItem("mc_mcp_path") || "")
const [data, setData] = useState<ConnectResp | null>(null)
const [active, setActive] = useState("cline") const [active, setActive] = useState("cline")
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const [error, setError] = useState("")
useEffect(() => { const params = new URLSearchParams({ host })
const params = new URLSearchParams() if (mcpPath) params.set("mcp_path", mcpPath)
params.set("host", host) const { data, error: dataErr } = useConnect(params.toString())
if (mcpPath) params.set("mcp_path", mcpPath) const error = dataErr ? String(dataErr) : ""
api<ConnectResp>(`/api/connect?${params}`)
.then(setData)
.catch((e) => setError(String(e)))
}, [host, mcpPath])
function saveHost(v: string) { function saveHost(v: string) {
setHost(v) setHost(v)
+19 -67
View File
@@ -1,8 +1,9 @@
import { useEffect, useState } from "react" import { useState } from "react"
import { Trash2, Sparkles, User, Scroll, Shield, Tag, Clock, Search, Plus, BookOpen } from "lucide-react" import { Trash2, Sparkles, User, Scroll, Shield, Tag, Clock, Search, Plus, BookOpen } from "lucide-react"
import { api, type DedupeResult, type Memory } from "@/lib/api" import { api, type DedupeResult } from "@/lib/api"
import { useMemory, useQueryClient } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { CustomDialog } from "@/components/CustomDialog"
const CATEGORIES = ["user", "instruction", "stable", "versioned", "ephemeral"] const CATEGORIES = ["user", "instruction", "stable", "versioned", "ephemeral"]
@@ -26,73 +27,32 @@ const BORDER_CLASSES: Record<string, string> = {
} }
export function MemoryView() { export function MemoryView() {
const [items, setItems] = useState<Memory[]>([])
const [filter, setFilter] = useState("") const [filter, setFilter] = useState("")
const [q, setQ] = useState("") const [q, setQ] = useState("")
const [content, setContent] = useState("") const [content, setContent] = useState("")
const [category, setCategory] = useState("stable") const [category, setCategory] = useState("stable")
const [error, setError] = useState("")
const [deduping, setDeduping] = useState(false) const [deduping, setDeduping] = useState(false)
// Custom Dialog State const qc = useQueryClient()
const [dialog, setDialog] = useState<{ const { showAlert, showConfirm, dialogElement } = useDialog()
type: "alert" | "confirm" const { data: items = [], error: itemsErr } = useMemory({ q, category: filter })
title: string const error = itemsErr ? String(itemsErr) : ""
message: string
onConfirm: () => void
onCancel?: () => void
} | null>(null)
function showAlert(title: string, message: string, onConfirm?: () => void) { const reloadMemory = () => qc.invalidateQueries({ queryKey: ["memory"] })
setDialog({
type: "alert",
title,
message,
onConfirm: () => {
setDialog(null)
if (onConfirm) onConfirm()
}
})
}
function showConfirm(title: string, message: string, onConfirm: () => void) {
setDialog({
type: "confirm",
title,
message,
onConfirm: () => {
setDialog(null)
onConfirm()
},
onCancel: () => setDialog(null)
})
}
function load() {
const params = new URLSearchParams()
if (q) params.set("q", q)
if (filter) params.set("category", filter)
api<Memory[]>(`/api/memory?${params}`)
.then(setItems)
.catch((e) => setError(String(e)))
}
useEffect(load, [q, filter])
async function add() { async function add() {
if (!content.trim()) return if (!content.trim()) return
await api("/api/memory", { await api("/api/memory", {
method: "POST", method: "POST",
body: JSON.stringify({ content, category, source: "ui" }) body: JSON.stringify({ content, category, source: "ui" })
}) })
setContent("") setContent("")
load() reloadMemory()
} }
async function del(id: string) { async function del(id: string) {
await api(`/api/memory/${id}`, { method: "DELETE" }) await api(`/api/memory/${id}`, { method: "DELETE" })
load() reloadMemory()
} }
async function cleanup() { async function cleanup() {
@@ -111,11 +71,11 @@ export function MemoryView() {
`${dry.duplicate_count} Dublette(n) in ${dry.groups.length} Gruppe(n) gefunden. Entfernen?`, `${dry.duplicate_count} Dublette(n) in ${dry.groups.length} Gruppe(n) gefunden. Entfernen?`,
async () => { async () => {
try { try {
await api("/api/memory/dedupe", { await api("/api/memory/dedupe", {
method: "POST", method: "POST",
body: JSON.stringify({ apply: true }) body: JSON.stringify({ apply: true })
}) })
load() reloadMemory()
} catch (e: any) { } catch (e: any) {
showAlert("Fehler", `Fehler beim Löschen: ${e.message}`) showAlert("Fehler", `Fehler beim Löschen: ${e.message}`)
} }
@@ -285,15 +245,7 @@ export function MemoryView() {
)} )}
</div> </div>
{dialog && ( {dialogElement}
<CustomDialog
type={dialog.type}
title={dialog.title}
message={dialog.message}
onConfirm={dialog.onConfirm}
onCancel={dialog.onCancel}
/>
)}
</div> </div>
) )
} }
+9 -47
View File
@@ -1,9 +1,10 @@
import { useEffect, useState } from "react" import { useState } from "react"
import { ExternalLink, RefreshCw, Save, Cpu, HardDrive, Cpu as GpuIcon, Activity } from "lucide-react" import { ExternalLink, RefreshCw, Save, Cpu, HardDrive, Cpu as GpuIcon, Activity } from "lucide-react"
import { api, type ServicesResp, type SystemStatus } from "@/lib/api" import { api } from "@/lib/api"
import { useSystemStatus, useServices } from "@/lib/queries"
import { useDialog } from "@/lib/useDialog"
import { cn, resolveExternalUrl } from "@/lib/utils" import { cn, resolveExternalUrl } from "@/lib/utils"
import { gb } from "@/lib/format" import { gb } from "@/lib/format"
import { CustomDialog } from "@/components/CustomDialog"
function DiagnosticBar({ label, percent, detail, icon: Icon }: { label: string; percent: number; detail?: string; icon: any }) { function DiagnosticBar({ label, percent, detail, icon: Icon }: { label: string; percent: number; detail?: string; icon: any }) {
@@ -36,44 +37,13 @@ function DiagnosticBar({ label, percent, detail, icon: Icon }: { label: string;
} }
export function SystemView() { export function SystemView() {
const [s, setS] = useState<SystemStatus | null>(null) const { data: s, error: sErr } = useSystemStatus(3_000)
const [svc, setSvc] = useState<ServicesResp | null>(null) const { data: svc } = useServices(3_000)
const [error, setError] = useState("") const { showAlert, dialogElement } = useDialog()
const error = sErr ? String(sErr) : ""
const [backupMsg, setBackupMsg] = useState("") const [backupMsg, setBackupMsg] = useState("")
const [restartingServices, setRestartingServices] = useState<Record<string, boolean>>({}) const [restartingServices, setRestartingServices] = useState<Record<string, boolean>>({})
// Custom Dialog State
const [dialog, setDialog] = useState<{
type: "alert" | "confirm"
title: string
message: string
onConfirm: () => void
onCancel?: () => void
} | null>(null)
function showAlert(title: string, message: string, onConfirm?: () => void) {
setDialog({
type: "alert",
title,
message,
onConfirm: () => {
setDialog(null)
if (onConfirm) onConfirm()
}
})
}
function load() {
api<SystemStatus>("/api/system/status").then(setS).catch((e) => setError(String(e)))
api<ServicesResp>("/api/system/services").then(setSvc).catch(() => {})
}
useEffect(() => {
load()
const t = setInterval(load, 3000)
return () => clearInterval(t)
}, [])
async function doBackup() { async function doBackup() {
setBackupMsg("Backup snapshotted...") setBackupMsg("Backup snapshotted...")
try { try {
@@ -254,15 +224,7 @@ export function SystemView() {
</div> </div>
)} )}
{dialog && ( {dialogElement}
<CustomDialog
type={dialog.type}
title={dialog.title}
message={dialog.message}
onConfirm={dialog.onConfirm}
onCancel={dialog.onCancel}
/>
)}
</div> </div>
) )
} }