UI v3: Cockpit-Startseite + Modelle-Werkbank als Default, neue Box-Konsole

Kompletter Frontend-Umbau auf das abgenommene v3-Konzept, dazu zwei neue Funktionen.

Frontend (Neuordnung bestehender IST-Views, kein Backend-Umbau):
- Cockpit ist die neue Startseite: ruhige Bereichs-Kacheln (je 1 Live-Zahl +
  Status-Punkt), ehrliche Status-Zeile "Box gesund" + Speicher-Pille, "Braucht dich"
  (kritische Probleme mit 1-Klick-Reparatur + bereitliegende Updates).
- Modelle-Werkbank: Maschinenraum-Speicherleiste als Hero (Arbeitsspeicher-Balken,
  nach Rolle eingefaerbt + frei), darunter Master/Detail. Eingebettet als Haupt-Tab
  im Modell-Manager ("Werkbank"), "Modelle finden" + JobsBar bleiben.
- Sidebar/Nav neu strukturiert; alle Aktionen ueber die bestehenden /api-Endpoints.

Neue Features:
- Box-Konsole: zweites ttyd-Web-Terminal mit echter Login-Shell auf :7682 (direkter,
  SSH-artiger Box-Zugriff, kein Passwort — gleiches LAN-Trust-Modell wie hermes-terminal).
  Neuer Dienst deploy/box-console.service + agent_status-Felder box_console_url/-reachable.
- Box-Zugang: das Host-Sudo-Passwort laesst sich jetzt direkt in der Konsole-Seite
  setzen/aendern/loeschen (lokal im Browser), statt nur versteckt im System-Drawer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-04 20:41:19 +02:00
parent 854393ce5f
commit 18afa37412
20 changed files with 1692 additions and 679 deletions
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useState } from "react"
import { SquareTerminal, ExternalLink, AlertTriangle, Shield, Eye, EyeOff, Check, Trash2 } from "lucide-react"
import { useAgentStatus } from "@/lib/queries"
import { resolveExternalUrl, cn } from "@/lib/utils"
// Konsole = direkter, SSH-artiger Box-Zugriff (ttyd → Login-Shell auf :7682), eingebettet
// per iframe — Schwester der Hermes-Terminal-Seite. Darunter „Box-Zugang": das Host-Sudo-
// Passwort, das MC2 für System-Operationen (Neustarts, apt, Reboot) braucht, hier direkt
// änder-/löschbar (nur lokal im Browser gespeichert, wie in den Einstellungen).
export function KonsoleView() {
const { data: agent } = useAgentStatus(5_000)
const url = agent?.box_console_url ? resolveExternalUrl(agent.box_console_url) : undefined
const ok = agent?.box_console_reachable
return (
<div className="flex h-full flex-col gap-4">
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="bg-gradient-to-r from-foreground via-foreground to-primary bg-clip-text font-space text-2xl font-bold tracking-tight text-transparent">
Konsole
</h1>
<p className="flex items-center gap-2 text-sm text-muted-foreground">
Direkte Shell auf der Box wie ein SSH-Fenster, mitten im Browser.
</p>
</div>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1.5 text-[11px] font-medium text-muted-foreground">
<span className={cn("h-2 w-2 rounded-full", ok ? "bg-emerald-500 animate-pulse" : "bg-amber-500")} />
{ok ? "online" : "offline"}
</span>
{url && (
<a href={url} target="_blank" rel="noopener"
className="flex h-8 items-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-all hover:border-primary/50 hover:text-foreground">
<ExternalLink className="h-3.5 w-3.5" /> In neuem Tab
</a>
)}
</div>
</div>
{url ? (
<div className="relative min-h-[58vh] flex-1 overflow-hidden rounded-2xl border border-border/60 bg-black/50 shadow-lg shadow-black/25">
{ok === false && (
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center gap-3 bg-black/70 text-center">
<AlertTriangle className="h-8 w-8 text-amber-400" />
<div className="text-sm font-semibold text-amber-300">Konsole nicht erreichbar</div>
<p className="max-w-sm text-[11px] leading-normal text-muted-foreground">
Der <code className="font-mono text-primary">box-console</code>-Dienst (ttyd) läuft nicht.
Auf der Box: <code className="font-mono">systemctl --user restart box-console</code>.
</p>
</div>
)}
<iframe src={url} title="Box-Konsole" className="h-full w-full border-0" style={{ minHeight: "58vh" }} />
</div>
) : (
<div className="flex min-h-[58vh] flex-1 items-center justify-center rounded-2xl border border-border/60 bg-background/20 text-xs text-muted-foreground">
<SquareTerminal className="mr-2 h-4 w-4" /> Lade Konsole
</div>
)}
<BoxAccessCard />
</div>
)
}
// „Box-Zugang" — das Host-Sudo-Passwort direkt setzen/ändern/löschen. Reine Frontend-
// Verwaltung des lokal gespeicherten Werts (localStorage `mc_sudo_password`), den api.ts
// bei Bedarf mitschickt. KEINE Änderung des echten Linux-Kontopassworts.
function BoxAccessCard() {
const [pw, setPw] = useState("")
const [stored, setStored] = useState(false)
const [show, setShow] = useState(false)
const [saved, setSaved] = useState(false)
useEffect(() => {
const v = localStorage.getItem("mc_sudo_password") || ""
setPw(v); setStored(!!v)
}, [])
function save() {
if (pw) localStorage.setItem("mc_sudo_password", pw)
else localStorage.removeItem("mc_sudo_password")
setStored(!!pw)
setSaved(true); setTimeout(() => setSaved(false), 1800)
}
function clear() {
localStorage.removeItem("mc_sudo_password"); setPw(""); setStored(false)
setSaved(true); setTimeout(() => setSaved(false), 1800)
}
return (
<div className="rounded-2xl border border-border/60 bg-card/45 p-5 shadow-lg shadow-black/15 backdrop-blur-md">
<div className="mb-1 flex items-center gap-2">
<Shield className="h-4.5 w-4.5 text-amber-400" />
<h2 className="text-sm font-semibold uppercase tracking-wide text-foreground">Box-Zugang</h2>
<span className={cn("ml-1 rounded-full border px-2 py-0.5 text-[10px] font-semibold",
stored ? "border-emerald-500/30 bg-emerald-500/5 text-emerald-400" : "border-border/40 bg-background/30 text-muted-foreground")}>
{stored ? "gesetzt" : "nicht gesetzt"}
</span>
</div>
<p className="mb-3 text-[11px] leading-normal text-muted-foreground">
Das Box-Passwort (Sudo) braucht MC2 für System-Aktionen wie Dienst-Neustarts, OS-Updates und Neustart der Box.
Es wird nur lokal in deinem Browser gespeichert. Hier kannst du es setzen, ändern oder löschen.
</p>
<div className="flex flex-col gap-2 sm:flex-row">
<div className="relative flex-1">
<input
type={show ? "text" : "password"} value={pw} onChange={(e) => setPw(e.target.value)}
placeholder="Box-Passwort" autoComplete="off" spellCheck={false} name="box-password"
className="h-10 w-full rounded-lg border border-border/60 bg-background/40 pl-3 pr-10 text-xs text-foreground outline-none transition-colors focus:border-primary"
/>
<button type="button" onClick={() => setShow(!show)}
className="absolute inset-y-0 right-0 flex items-center pr-3 text-muted-foreground transition-colors hover:text-foreground"
aria-label={show ? "Passwort verbergen" : "Passwort anzeigen"}>
{show ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<button onClick={save}
className="flex h-10 items-center justify-center gap-1.5 rounded-lg bg-primary px-4 text-xs font-semibold text-primary-foreground shadow shadow-primary/10 transition-colors hover:bg-primary/90 cursor-pointer">
{saved ? <><Check className="h-4 w-4" /> gespeichert</> : "Speichern"}
</button>
<button onClick={clear} disabled={!stored && !pw}
className="flex h-10 items-center justify-center gap-1.5 rounded-lg border border-border/60 bg-background/20 px-3 text-xs font-semibold text-muted-foreground transition-colors hover:bg-accent cursor-pointer disabled:opacity-40">
<Trash2 className="h-3.5 w-3.5" /> Löschen
</button>
</div>
</div>
)
}