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
+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 { 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 { CustomDialog } from "@/components/CustomDialog"
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() {
const [s, setS] = useState<AgentStatus | null>(null)
const [error, setError] = useState("")
const { data: s, error: sErr } = useAgentStatus(5_000)
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
const [hoveredNode, setHoveredNode] = useState<string | null>(null)
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
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}`
}
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) {
try {
await api("/api/agent/brain", {
@@ -119,20 +102,13 @@ export function AgentView() {
body: JSON.stringify({ model })
})
showAlert("Erfolgreich", `Hermes-Gehirn wurde auf '${model}' geändert. Der Gateway-Dienst wurde neu gestartet.`)
loadData()
qc.invalidateQueries({ queryKey: qk.agentStatus })
setShowBrainSelect(false)
} catch (e: any) {
showAlert("Fehler", `Fehler beim Wechseln des Gehirns: ${e.message}`)
}
}
useEffect(() => {
loadData()
loadModels()
const t = setInterval(loadData, 5000)
return () => clearInterval(t)
}, [])
return (
<div className="space-y-6">
{/* Styles inside AgentView for dash flow animations */}
@@ -471,15 +447,7 @@ export function AgentView() {
</div>
)}
{dialog && (
<CustomDialog
type={dialog.type}
title={dialog.title}
message={dialog.message}
onConfirm={() => dialog.onConfirm && dialog.onConfirm()}
onCancel={dialog.onCancel}
/>
)}
{dialogElement}
</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 { api, type ConnectResp } from "@/lib/api"
import { useConnect } from "@/lib/queries"
import { cn } from "@/lib/utils"
export function ConnectView() {
const [host, setHost] = useState(localStorage.getItem("mc_host") || "192.168.178.151")
const [mcpPath, setMcpPath] = useState(localStorage.getItem("mc_mcp_path") || "")
const [data, setData] = useState<ConnectResp | null>(null)
const [active, setActive] = useState("cline")
const [copied, setCopied] = useState(false)
const [error, setError] = useState("")
useEffect(() => {
const params = new URLSearchParams()
params.set("host", host)
if (mcpPath) params.set("mcp_path", mcpPath)
api<ConnectResp>(`/api/connect?${params}`)
.then(setData)
.catch((e) => setError(String(e)))
}, [host, mcpPath])
const params = new URLSearchParams({ host })
if (mcpPath) params.set("mcp_path", mcpPath)
const { data, error: dataErr } = useConnect(params.toString())
const error = dataErr ? String(dataErr) : ""
function saveHost(v: string) {
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 { 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 { CustomDialog } from "@/components/CustomDialog"
const CATEGORIES = ["user", "instruction", "stable", "versioned", "ephemeral"]
@@ -26,73 +27,32 @@ const BORDER_CLASSES: Record<string, string> = {
}
export function MemoryView() {
const [items, setItems] = useState<Memory[]>([])
const [filter, setFilter] = useState("")
const [q, setQ] = useState("")
const [content, setContent] = useState("")
const [category, setCategory] = useState("stable")
const [error, setError] = useState("")
const [deduping, setDeduping] = useState(false)
// Custom Dialog State
const [dialog, setDialog] = useState<{
type: "alert" | "confirm"
title: string
message: string
onConfirm: () => void
onCancel?: () => void
} | null>(null)
const qc = useQueryClient()
const { showAlert, showConfirm, dialogElement } = useDialog()
const { data: items = [], error: itemsErr } = useMemory({ q, category: filter })
const error = itemsErr ? String(itemsErr) : ""
function showAlert(title: string, message: string, onConfirm?: () => void) {
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])
const reloadMemory = () => qc.invalidateQueries({ queryKey: ["memory"] })
async function add() {
if (!content.trim()) return
await api("/api/memory", {
method: "POST",
body: JSON.stringify({ content, category, source: "ui" })
await api("/api/memory", {
method: "POST",
body: JSON.stringify({ content, category, source: "ui" })
})
setContent("")
load()
reloadMemory()
}
async function del(id: string) {
await api(`/api/memory/${id}`, { method: "DELETE" })
load()
reloadMemory()
}
async function cleanup() {
@@ -111,11 +71,11 @@ export function MemoryView() {
`${dry.duplicate_count} Dublette(n) in ${dry.groups.length} Gruppe(n) gefunden. Entfernen?`,
async () => {
try {
await api("/api/memory/dedupe", {
method: "POST",
body: JSON.stringify({ apply: true })
await api("/api/memory/dedupe", {
method: "POST",
body: JSON.stringify({ apply: true })
})
load()
reloadMemory()
} catch (e: any) {
showAlert("Fehler", `Fehler beim Löschen: ${e.message}`)
}
@@ -285,15 +245,7 @@ export function MemoryView() {
)}
</div>
{dialog && (
<CustomDialog
type={dialog.type}
title={dialog.title}
message={dialog.message}
onConfirm={dialog.onConfirm}
onCancel={dialog.onCancel}
/>
)}
{dialogElement}
</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 { 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 { gb } from "@/lib/format"
import { CustomDialog } from "@/components/CustomDialog"
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() {
const [s, setS] = useState<SystemStatus | null>(null)
const [svc, setSvc] = useState<ServicesResp | null>(null)
const [error, setError] = useState("")
const { data: s, error: sErr } = useSystemStatus(3_000)
const { data: svc } = useServices(3_000)
const { showAlert, dialogElement } = useDialog()
const error = sErr ? String(sErr) : ""
const [backupMsg, setBackupMsg] = useState("")
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() {
setBackupMsg("Backup snapshotted...")
try {
@@ -254,15 +224,7 @@ export function SystemView() {
</div>
)}
{dialog && (
<CustomDialog
type={dialog.type}
title={dialog.title}
message={dialog.message}
onConfirm={dialog.onConfirm}
onCancel={dialog.onCancel}
/>
)}
{dialogElement}
</div>
)
}