feat: simplify Discover view into socket cards & backend upgrades
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react"
|
||||
import { Bot, Cpu, ExternalLink, Brain, Layers, Plus, ShieldAlert } from "lucide-react"
|
||||
import { api, type SystemStatus, type AgentStatus, type ModelInfo, type Memory, type UpdatesResp } from "@/lib/api"
|
||||
import { Bot, Cpu, ExternalLink, Brain, Layers, Plus, ShieldAlert, X, Power, Shield, Download, RefreshCw } from "lucide-react"
|
||||
import { api, type SystemStatus, type AgentStatus, type ModelInfo, type Memory, type UpdatesResp, type Job } from "@/lib/api"
|
||||
import { cn, resolveExternalUrl } from "@/lib/utils"
|
||||
|
||||
function gb(b: number) {
|
||||
@@ -40,6 +40,20 @@ export function DashboardView() {
|
||||
const [running, setRunning] = useState<string[]>([])
|
||||
const [memories, setMemories] = useState<Memory[]>([])
|
||||
const [updates, setUpdates] = useState<UpdatesResp | null>(null)
|
||||
const [jobs, setJobs] = useState<Job[]>([])
|
||||
|
||||
// Sudo & Action states
|
||||
const [msg, setMsg] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sudoPassword, setSudoPassword] = useState("")
|
||||
const [sudoLoading, setSudoLoading] = useState(false)
|
||||
const [sudoModal, setSudoModal] = useState<{
|
||||
open: boolean
|
||||
actionPath: string
|
||||
actionLabel: string
|
||||
payload?: any
|
||||
error?: string
|
||||
}>({ open: false, actionPath: "", actionLabel: "" })
|
||||
|
||||
// Quick Memory Form State
|
||||
const [memContent, setMemContent] = useState("")
|
||||
@@ -57,6 +71,7 @@ export function DashboardView() {
|
||||
.catch(() => {})
|
||||
api<Memory[]>("/api/memory?category=").then((d) => setMemories(d.slice(0, 3))).catch(() => {})
|
||||
api<UpdatesResp>("/api/maintenance/updates").then(setUpdates).catch(() => {})
|
||||
api<{ jobs: Job[] }>("/api/jobs").then((d) => setJobs(d.jobs || [])).catch(() => {})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -65,6 +80,96 @@ export function DashboardView() {
|
||||
return () => clearInterval(t)
|
||||
}, [])
|
||||
|
||||
async function postAction(path: string, label: string, payload?: any, password?: string) {
|
||||
setMsg(`${label} wird ausgeführt...`)
|
||||
setLoading(true)
|
||||
try {
|
||||
const body: any = { ...payload }
|
||||
if (password) {
|
||||
body.sudo_password = password
|
||||
}
|
||||
const r = await api<{ job_id?: string; ok?: boolean; status?: string; err?: string }>(path, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (r.status === "password_required" || r.status === "incorrect_password") {
|
||||
setSudoModal({
|
||||
open: true,
|
||||
actionPath: path,
|
||||
actionLabel: label,
|
||||
payload,
|
||||
error: r.status === "incorrect_password" ? "Falsches Sudo-Passwort. Bitte erneut versuchen." : undefined
|
||||
})
|
||||
setMsg("")
|
||||
return
|
||||
}
|
||||
|
||||
if (r.job_id) {
|
||||
setMsg(`${label} gestartet (Job-ID: ${r.job_id})`)
|
||||
} else if (r.ok) {
|
||||
setMsg(`${label} erfolgreich ausgeführt.`)
|
||||
} else {
|
||||
setMsg(`Fehler: ${r.err || "Unbekannter Fehler"}`)
|
||||
}
|
||||
loadData()
|
||||
} catch (e: any) {
|
||||
setMsg(`Fehler bei ${label}: ${e.message}`)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSudoSubmit() {
|
||||
setSudoLoading(true)
|
||||
try {
|
||||
const body: any = { ...sudoModal.payload, sudo_password: sudoPassword }
|
||||
const r = await api<{ job_id?: string; ok?: boolean; status?: string; err?: string }>(sudoModal.actionPath, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
if (r.status === "password_required" || r.status === "incorrect_password") {
|
||||
setSudoModal(prev => ({
|
||||
...prev,
|
||||
error: "Falsches Sudo-Passwort. Bitte erneut versuchen."
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (r.job_id) {
|
||||
setMsg(`${sudoModal.actionLabel} gestartet (Job-ID: ${r.job_id})`)
|
||||
} else if (r.ok) {
|
||||
setMsg(`${sudoModal.actionLabel} erfolgreich ausgeführt.`)
|
||||
} else {
|
||||
setMsg(`Fehler: ${r.err || "Unbekannter Fehler"}`)
|
||||
}
|
||||
setSudoModal({ open: false, actionPath: "", actionLabel: "" })
|
||||
setSudoPassword("")
|
||||
loadData()
|
||||
} catch (e: any) {
|
||||
setMsg(`Fehler: ${e.message}`)
|
||||
setSudoModal({ open: false, actionPath: "", actionLabel: "" })
|
||||
setSudoPassword("")
|
||||
} finally {
|
||||
setSudoLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function upgradeModel(repo: string, role: string) {
|
||||
setMsg(`Upgrade für ${repo} wird gestartet...`)
|
||||
try {
|
||||
await api("/api/models/install", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ repo, role, quant: "Q4_K_M", jinja: true })
|
||||
})
|
||||
setMsg(`Upgrade-Download gestartet.`)
|
||||
loadData()
|
||||
} catch (e: any) {
|
||||
setMsg(`Upgrade fehlgeschlagen: ${e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveQuickMemory() {
|
||||
if (!memContent.trim() || savingMem) return
|
||||
setSavingMem(true)
|
||||
@@ -84,8 +189,70 @@ export function DashboardView() {
|
||||
|
||||
const activeModels = models.filter((m) => m.role)
|
||||
|
||||
// Active updates check
|
||||
const activeOsJob = jobs.find(j => j.label.includes("OS-Update") && (j.state === "running" || j.state === "queued"))
|
||||
const activeEngineJob = jobs.find(j => j.label.includes("Engine-Update") && (j.state === "running" || j.state === "queued"))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Sudo Password Dialog Modal */}
|
||||
{sudoModal.open && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
|
||||
<div className="w-full max-w-sm rounded-2xl border border-border/60 bg-card/90 backdrop-blur-xl p-5 shadow-2xl space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-border/20 pb-2">
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-primary font-space">Sudo-Passwort erforderlich</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSudoModal({ open: false, actionPath: "", actionLabel: "" })
|
||||
setSudoPassword("")
|
||||
}}
|
||||
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
Für die Aktion <strong>{sudoModal.actionLabel}</strong> wird das Administrator-Passwort (Sudo) auf der Box benötigt.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="password"
|
||||
value={sudoPassword}
|
||||
onChange={(e) => setSudoPassword(e.target.value)}
|
||||
placeholder="Sudo-Passwort eingeben..."
|
||||
className="w-full h-9 rounded-lg border border-border/60 bg-background/40 px-3 text-xs outline-none focus:ring-1 focus:ring-primary/50 text-foreground"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSudoSubmit()}
|
||||
autoFocus
|
||||
/>
|
||||
{sudoModal.error && (
|
||||
<div className="text-[10px] font-semibold text-red-400">{sudoModal.error}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
setSudoModal({ open: false, actionPath: "", actionLabel: "" })
|
||||
setSudoPassword("")
|
||||
}}
|
||||
className="h-8 px-3 rounded-lg border border-border/60 bg-background/25 text-[10px] font-bold uppercase hover:bg-accent transition-all cursor-pointer"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSudoSubmit}
|
||||
disabled={!sudoPassword || sudoLoading}
|
||||
className="h-8 px-4 rounded-lg bg-primary text-primary-foreground text-[10px] font-bold uppercase hover:opacity-90 transition-all disabled:opacity-50 cursor-pointer flex items-center justify-center gap-1.5"
|
||||
>
|
||||
{sudoLoading ? "Prüfe..." : "Ausführen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Title */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-space font-bold tracking-tight bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text text-transparent">
|
||||
@@ -105,7 +272,7 @@ export function DashboardView() {
|
||||
</div>
|
||||
{sys ? (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<RadialGauge value={sys.cpu.percent} label="CPU" detail={`${sys.cpu.cores} Cores`} />
|
||||
<RadialGauge value={sys.cpu.percent} label="CPU" detail={sys.cpu.cores ? `${sys.cpu.cores} Cores` : undefined} />
|
||||
<RadialGauge value={sys.ram.percent} label="RAM" detail={`${gb(sys.ram.used)} / ${gb(sys.ram.total)} GB`} />
|
||||
{sys.gpu && sys.gpu.busy_percent != null && sys.gpu.gtt_used != null && sys.gpu.gtt_total != null && (
|
||||
<RadialGauge
|
||||
@@ -132,14 +299,14 @@ export function DashboardView() {
|
||||
|
||||
{/* Card 2: Updates & Wartung (1 column wide) */}
|
||||
<div className="rounded-2xl border border-border/60 bg-card/45 backdrop-blur-md p-5 shadow-lg shadow-black/15 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="h-4.5 w-4.5 text-primary" />
|
||||
<h2 className="text-sm font-semibold tracking-wide uppercase text-foreground">Updates & Pflege</h2>
|
||||
</div>
|
||||
|
||||
{updates ? (
|
||||
<div className="space-y-2.5">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<div className={cn(
|
||||
"flex items-center justify-between px-3 py-1.5 rounded-lg border text-xs",
|
||||
@@ -172,13 +339,63 @@ export function DashboardView() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Inline Action Buttons */}
|
||||
<div className="grid grid-cols-2 gap-2 border-t border-border/20 pt-3">
|
||||
<button
|
||||
onClick={() => postAction("/api/maintenance/os-update", "OS-Update")}
|
||||
disabled={loading || !!activeOsJob}
|
||||
className="h-8 px-2 rounded-lg border border-border/60 bg-background/20 text-[10px] font-bold uppercase hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer disabled:opacity-50 flex items-center justify-center gap-1"
|
||||
>
|
||||
{activeOsJob ? (
|
||||
<>
|
||||
<RefreshCw className="h-3 w-3 animate-spin text-primary" />
|
||||
<span>Aktiv ({activeOsJob.progress ?? 0}%)</span>
|
||||
</>
|
||||
) : (
|
||||
<span>OS Update</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => postAction("/api/maintenance/engine-update", "Engine-Update")}
|
||||
disabled={loading || !!activeEngineJob}
|
||||
className="h-8 px-2 rounded-lg border border-border/60 bg-background/20 text-[10px] font-bold uppercase hover:border-primary/50 hover:bg-background/40 transition-all cursor-pointer disabled:opacity-50 flex items-center justify-center gap-1"
|
||||
>
|
||||
{activeEngineJob ? (
|
||||
<>
|
||||
<RefreshCw className="h-3 w-3 animate-spin text-primary" />
|
||||
<span>Aktiv ({activeEngineJob.progress ?? 0}%)</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Engine Update</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => { if (confirm("Bist du sicher, dass du das Host-System neu starten willst?")) postAction("/api/maintenance/reboot", "Reboot") }}
|
||||
disabled={loading}
|
||||
className="w-full h-8 flex items-center justify-center gap-1.5 rounded-lg border border-red-500/30 bg-red-500/5 text-red-400 text-[10px] font-bold uppercase hover:bg-red-500/10 transition-all cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
<Power className="h-3.5 w-3.5" />
|
||||
<span>Host Reboot</span>
|
||||
</button>
|
||||
|
||||
{/* Model Upgrades list */}
|
||||
{updates.model_list.length > 0 && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="text-[9px] uppercase font-bold text-muted-foreground/60 tracking-wider">Upgrades:</div>
|
||||
<div className="max-h-20 overflow-y-auto space-y-1 pr-1 scrollbar-thin">
|
||||
<div className="space-y-1.5 border-t border-border/20 pt-3">
|
||||
<div className="text-[9px] uppercase font-bold text-muted-foreground/60 tracking-wider">Verfügbare Modell-Upgrades:</div>
|
||||
<div className="max-h-24 overflow-y-auto space-y-1.5 pr-1 scrollbar-thin">
|
||||
{updates.model_list.map((m) => (
|
||||
<div key={m.repo} className="text-[9px] bg-background/20 border border-border/20 rounded p-1.5 font-mono text-muted-foreground truncate" title={`${m.role}: ${m.repo}`}>
|
||||
<span className="text-primary font-semibold uppercase">{m.role}</span>: {m.repo.split("/").pop()}
|
||||
<div key={m.repo} className="flex items-center justify-between p-2 rounded bg-background/25 border border-border/30 text-[10px] font-mono text-muted-foreground">
|
||||
<span className="truncate flex-1 mr-1.5" title={`${m.role}: ${m.repo}`}>
|
||||
<span className="text-primary font-bold uppercase">{m.role}</span>: {m.repo.split("/").pop()}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => upgradeModel(m.repo, m.role)}
|
||||
className="px-2 py-0.5 text-[8px] font-bold uppercase rounded bg-primary text-primary-foreground hover:opacity-90 transition-all cursor-pointer flex items-center gap-0.5"
|
||||
>
|
||||
<Download className="h-2.5 w-2.5" /> Laden
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -188,6 +405,19 @@ export function DashboardView() {
|
||||
) : (
|
||||
<div className="h-24 flex items-center justify-center text-xs text-muted-foreground">Lade Updates...</div>
|
||||
)}
|
||||
|
||||
{/* Status messages display */}
|
||||
{msg && (
|
||||
<div className="text-[10px] font-mono text-primary font-medium bg-primary/5 p-2 rounded-lg border border-primary/20 break-all leading-normal">
|
||||
{msg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sudoers explanation text */}
|
||||
<div className="text-[9px] text-muted-foreground/60 leading-normal border-t border-border/10 pt-2.5 flex items-start gap-1">
|
||||
<Shield className="h-3 w-3 shrink-0 text-muted-foreground/50 mt-0.5" />
|
||||
<span>OS-Update & Reboot benötigen NOPASSWD in <code>/etc/sudoers</code> (z.B. <code>hitonabi ALL=(root) NOPASSWD:...</code>) oder ein gültiges Sudo-Passwort per Pop-up.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t border-border/30 pt-3 shrink-0">
|
||||
|
||||
Reference in New Issue
Block a user