Welle 1 · Live-Zahlen je Dienst auf den Kacheln, AdGuard-Zugang in den Einstellungen
Jede Gerätekachel (Homelab-Cockpit und Übersicht) zeigt eine Live-Zahl, wo es eine gibt: Proxmox-Host „8 von 8 Gästen an“, PBS „letzte Sicherung vor 13 h“, NPMplus „10 Zertifikate gültig“ (aus der Zertifikats-Wache), Arcane „11 von 12 Containern an“ (Arcane-API mit dem vorhandenen Schlüssel) und AdGuard „12 % geblockt“. Für AdGuard gibt es unter Einstellungen → Homelab einen Zugang, der vor dem Speichern gegen AdGuard geprüft wird und nur im Homelab-Teil liegt (0600, das Passwort steht in keiner Antwort). Arcane und AdGuard werden höchstens alle zwei Minuten gefragt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
53ea0cd9b6
commit
ad8dbbc1b3
@@ -65,11 +65,13 @@ const ZUSTAND_TEXT: Record<LampenZustand, string> = {
|
||||
}
|
||||
|
||||
/** Eine Kachel im Lagekopf: Bild mit Zustandspunkt, Name, darunter der Wert in der Farbe des Zustands. */
|
||||
export function Lagekachel({ bild, name, wert, zustand, onClick }: {
|
||||
export function Lagekachel({ bild, name, wert, zustand, zahl, onClick }: {
|
||||
bild: Geraetebild
|
||||
name: string
|
||||
wert: string
|
||||
zustand: LampenZustand
|
||||
/** Eine Live-Zahl darunter (seit 25.09.2026), etwa „12 % geblockt“; stufe färbt sie. */
|
||||
zahl?: { text: string; stufe?: "gelb" | "rot" } | null
|
||||
onClick?: () => void
|
||||
}) {
|
||||
const inhalt = (
|
||||
@@ -78,6 +80,12 @@ export function Lagekachel({ bild, name, wert, zustand, onClick }: {
|
||||
<span className="flex min-w-0 flex-col gap-0.5">
|
||||
<span className="truncate text-[14.5px] leading-snug font-medium">{name}</span>
|
||||
<span className={cn("truncate text-[12.5px] leading-snug", WERT[zustand])}>{wert}</span>
|
||||
{zahl && (
|
||||
<span className={cn("truncate text-[12px] leading-snug",
|
||||
zahl.stufe === "rot" ? "text-rot-text" : zahl.stufe === "gelb" ? "text-bernstein" : "text-text-3")}>
|
||||
{zahl.text}
|
||||
</span>
|
||||
)}
|
||||
<span className="sr-only">{ZUSTAND_TEXT[zustand]}</span>
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Lagekachel, Lagekopf } from "@/components/cockpit/Lagekopf"
|
||||
import { geraetebild } from "@/lib/geraetebild"
|
||||
import { type Lage, kurzname, lampeFuer } from "@/lib/homelab"
|
||||
import { kennzahlText, type Lage, kurzname, lampeFuer } from "@/lib/homelab"
|
||||
import type { ZielStand } from "@/lib/typen"
|
||||
|
||||
/**
|
||||
@@ -20,8 +20,10 @@ export function Lagebild({ lage, ziele, imUpdate, onAnsehen, onGeraet }: {
|
||||
onAnsehen={onAnsehen} label="Lage im Homelab">
|
||||
{ziele.map((z) => {
|
||||
const l = lampeFuer(z, imUpdate.has(z.id))
|
||||
const zahl = kennzahlText(z.kennzahl)
|
||||
return (
|
||||
<Lagekachel key={z.id} bild={geraetebild(z)} name={kurzname(z)} wert={l.wert} zustand={l.zustand}
|
||||
zahl={zahl ? { text: zahl, stufe: z.kennzahl?.stufe } : null}
|
||||
onClick={() => onGeraet(z, l.zustand === "info")} />
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -551,6 +551,20 @@ export const useArcaneSchluessel = () =>
|
||||
},
|
||||
)
|
||||
|
||||
/** AdGuard-Zugang für „% geblockt“ auf der Kachel (seit 25.09.2026): null entfernt ihn. Vor dem Speichern fragt der
|
||||
* Homelab-Teil AdGuard einmal damit. */
|
||||
export const useAdguardZugang = () =>
|
||||
useAktion(
|
||||
(zugang: { benutzer: string; passwort: string } | null) =>
|
||||
zugang === null
|
||||
? api<AktionsErgebnis>("/api/homelab/einstellungen/adguard-zugang", { method: "DELETE" })
|
||||
: post<AktionsErgebnis>("/api/homelab/einstellungen/adguard-zugang", zugang),
|
||||
{
|
||||
erfolg: (v, e) => (e as AktionsErgebnis).text ?? (v === null ? "AdGuard-Zugang entfernt." : "AdGuard-Zugang gespeichert."),
|
||||
neuLaden: ["homelab-einstellungen", "homelab"],
|
||||
},
|
||||
)
|
||||
|
||||
export const useArcaneEcht = () =>
|
||||
useAktion((an: boolean) => post<AktionsErgebnis>("/api/homelab/einstellungen/arcane-echt", { an }), {
|
||||
erfolg: (an) => (an ? "Docker-Updates über Arcane laufen jetzt echt." : "Docker-Updates über Arcane sind wieder nur ein Probelauf."),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lage, lampeFuer, listenAlter, offeneUpdates, rueckwegText } from "@/lib/homelab"
|
||||
import { kennzahlText, lage, lampeFuer, listenAlter, offeneUpdates, rueckwegText } from "@/lib/homelab"
|
||||
import type { BausteinStand, Hinweis, HomelabStand, ZielStand } from "@/lib/typen"
|
||||
|
||||
const baustein = (b: Partial<BausteinStand> & Pick<BausteinStand, "id" | "zustand">): BausteinStand => ({
|
||||
@@ -100,3 +100,13 @@ describe("listenAlter", () => {
|
||||
expect(listenAlter(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("kennzahlText (seit 25.09.2026)", () => {
|
||||
it("setzt „vor …“ hinter Zahlen mit Zeitpunkt", () => {
|
||||
const jetzt = Math.floor(Date.now() / 1000)
|
||||
expect(kennzahlText({ text: "12 % geblockt" })).toBe("12 % geblockt")
|
||||
expect(kennzahlText({ text: "letzte Sicherung", zeit: jetzt - 13 * 3600 })).toBe("letzte Sicherung vor 13 h")
|
||||
expect(kennzahlText(null)).toBeNull()
|
||||
expect(kennzahlText(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import type { BausteinStand, Hinweis, HomelabStand, Lampe, ZielStand } from "@/lib/typen"
|
||||
import type { BausteinStand, Hinweis, HomelabStand, Kennzahl, Lampe, ZielStand } from "@/lib/typen"
|
||||
import { vor } from "@/lib/zeit"
|
||||
|
||||
/** Die Live-Zahl einer Kachel in Worten (seit 25.09.2026): „12 % geblockt“, „letzte Sicherung vor 13 h“. */
|
||||
export function kennzahlText(k: Kennzahl | null | undefined): string | null {
|
||||
if (!k?.text) return null
|
||||
return k.zeit ? `${k.text} ${vor(k.zeit)}` : k.text
|
||||
}
|
||||
|
||||
// Die Homelab-Seite trennt, was vorher auf jeder Gerätekarte zusammenstand: die Lage auf einen Blick,
|
||||
// was zu tun ist (Updates), was wo läuft (Geräte) und was passiert ist (Verlauf). Hier liegt die
|
||||
|
||||
@@ -320,10 +320,18 @@ export interface ZielStand {
|
||||
rueckweg_art?: "snapshot" | "sicherung" | "keiner" | null
|
||||
/** Belegung der Platte in Bytes, wo bekannt (laufende Container). */
|
||||
platte?: { belegt: number; gesamt: number } | null
|
||||
/** Eine Live-Zahl für die Kachel (seit 25.09.2026): „12 % geblockt“; mit zeit „letzte Sicherung vor 13 h“. */
|
||||
kennzahl?: Kennzahl | null
|
||||
zaehlung: Partial<Record<BausteinZustand, number>>
|
||||
offen: number
|
||||
}
|
||||
|
||||
export interface Kennzahl {
|
||||
text: string
|
||||
zeit?: number
|
||||
stufe?: "gelb" | "rot"
|
||||
}
|
||||
|
||||
/** GET /api/homelab/ziele */
|
||||
export interface HomelabStand {
|
||||
ziele: ZielStand[]
|
||||
@@ -515,6 +523,8 @@ export interface HomelabEinstellungen {
|
||||
echt: boolean
|
||||
echt_quelle: "einstellung" | "umgebung" | "standard"
|
||||
}
|
||||
/** Zugang für die Statistik von AdGuard („% geblockt“ auf der Kachel, seit 25.09.2026). */
|
||||
adguard?: { url: string; zugang: "hinterlegt" | "fehlt"; benutzer: string | null }
|
||||
karenz_h: number
|
||||
ausfuehrer: { verbunden: boolean; zuletzt: number | null; nur_lesen: boolean; sicherung_speicher: string | null }
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Bestaetigen } from "@/components/cockpit/Bestaetigen"
|
||||
import { Chip } from "@/components/cockpit/Chip"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
useArcaneEcht, useArcaneSchluessel, useEinstellungenStand, useHomelabEinstellungen, useInstanz, usePartner,
|
||||
useAdguardZugang, useArcaneEcht, useArcaneSchluessel, useEinstellungenStand, useHomelabEinstellungen, useInstanz, usePartner,
|
||||
useRadarSchalten, useSoftwareStand, useTelegramTest, useUpdateFensterSetzen,
|
||||
} from "@/lib/abfragen"
|
||||
import { api, post } from "@/lib/api"
|
||||
@@ -513,6 +513,69 @@ function ArcaneSchluessel({ h }: { h: HomelabEinstellungen }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** AdGuard-Zugang (seit 25.09.2026): nur für die Statistik, damit die Kachel „% geblockt“ zeigen kann. */
|
||||
function AdguardZugang({ h }: { h: HomelabEinstellungen }) {
|
||||
const speichern = useAdguardZugang()
|
||||
const [benutzer, setBenutzer] = useState("")
|
||||
const [passwort, setPasswort] = useState("")
|
||||
const [loeschFrage, setLoeschFrage] = useState(false)
|
||||
const g = h.adguard
|
||||
if (!g) return null
|
||||
const feld = "h-11 w-full rounded-xl border border-white/12 bg-white/[0.04] px-3 text-[15px] outline-none focus:border-lab/60 focus:bg-white/[0.06]"
|
||||
return (
|
||||
<Abschnitt titel="AdGuard Home">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-text-2">
|
||||
Zugang {g.zugang === "hinterlegt"
|
||||
? <Chip art="gruen">hinterlegt{g.benutzer ? ` · ${g.benutzer}` : ""}</Chip>
|
||||
: <Chip art="grau">keiner</Chip>}
|
||||
{g.url && (
|
||||
<a href={g.url} target="_blank" rel="noopener" className="ml-auto inline-flex items-center gap-1 text-cyan hover:underline">
|
||||
AdGuard öffnen <ExternalLink className="size-3.5" aria-hidden />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-text-2">
|
||||
Mit einem Zugang zeigt die Kachel von AdGuard, wie viele Anfragen es blockt. Er wird nur zum Lesen der Statistik
|
||||
benutzt — am besten ein eigener Benutzer dafür.
|
||||
</p>
|
||||
<form
|
||||
className="flex flex-wrap gap-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (benutzer.trim() && passwort) {
|
||||
speichern.mutate({ benutzer: benutzer.trim(), passwort }, { onSuccess: () => { setPasswort(""); setBenutzer("") } })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<label htmlFor="adguard-benutzer" className="sr-only">AdGuard-Benutzer</label>
|
||||
<input id="adguard-benutzer" autoComplete="off" value={benutzer} onChange={(e) => setBenutzer(e.target.value)}
|
||||
placeholder="Benutzer" className={cn(feld, "min-w-0 flex-1 basis-40")} />
|
||||
<label htmlFor="adguard-passwort" className="sr-only">AdGuard-Passwort</label>
|
||||
<input id="adguard-passwort" type="password" autoComplete="new-password" value={passwort}
|
||||
onChange={(e) => setPasswort(e.target.value)} placeholder="Passwort" className={cn(feld, "min-w-0 flex-1 basis-40")} />
|
||||
<Button type="submit" disabled={!benutzer.trim() || !passwort || speichern.isPending}>
|
||||
{speichern.isPending ? "Prüft …" : "Prüfen und speichern"}
|
||||
</Button>
|
||||
</form>
|
||||
<p className="text-xs text-text-3">
|
||||
Vor dem Speichern fragt der Homelab-Teil AdGuard einmal damit. Das Passwort wird nie wieder angezeigt und liegt nur
|
||||
im Homelab-Teil.
|
||||
</p>
|
||||
{g.zugang === "hinterlegt" && (
|
||||
loeschFrage ? (
|
||||
<span className="flex flex-wrap gap-2">
|
||||
<Button variant="gefahr" size="sm" disabled={speichern.isPending}
|
||||
onClick={() => { speichern.mutate(null); setLoeschFrage(false) }}>Wirklich entfernen?</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setLoeschFrage(false)}>Nein</Button>
|
||||
</span>
|
||||
) : (
|
||||
<Button variant="ghost" size="sm" className="self-start" onClick={() => setLoeschFrage(true)}>Zugang entfernen</Button>
|
||||
)
|
||||
)}
|
||||
</Abschnitt>
|
||||
)
|
||||
}
|
||||
|
||||
function HomelabBetrieb({ h }: { h: HomelabEinstellungen }) {
|
||||
const f = h.ausfuehrer
|
||||
return (
|
||||
@@ -544,9 +607,9 @@ function HomelabKarte() {
|
||||
let inhalt: ReactNode
|
||||
if (!aktiv) inhalt = <p className="text-sm text-text-2">Der Homelab-Teil antwortet gerade nicht.</p>
|
||||
else if (!h.data) inhalt = <Unlesbar laedt={h.isPending} fehler={h.error?.message} />
|
||||
else inhalt = <><ArcaneSchluessel h={h.data} /><HomelabBetrieb h={h.data} /></>
|
||||
else inhalt = <><ArcaneSchluessel h={h.data} /><AdguardZugang h={h.data} /><HomelabBetrieb h={h.data} /></>
|
||||
return (
|
||||
<Karte titel="Homelab" farbe="homelab" text="Arcane und Docker, der Ausführer auf dem Proxmox-Host, Rückwege.">
|
||||
<Karte titel="Homelab" farbe="homelab" text="Arcane und Docker, AdGuard, der Ausführer auf dem Proxmox-Host, Rückwege.">
|
||||
{inhalt}
|
||||
</Karte>
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
} from "@/lib/abfragen"
|
||||
import { rolleVon } from "@/lib/anzeige"
|
||||
import { boxbild, type Geraetebild, geraetebild, PUNKT, type Punktzustand } from "@/lib/geraetebild"
|
||||
import { artText, kurzname } from "@/lib/homelab"
|
||||
import { artText, kennzahlText, kurzname } from "@/lib/homelab"
|
||||
import { laufzeit, zustandBei } from "@/lib/messung"
|
||||
import { type Bereich, useBereichStand, type Verbindung } from "@/lib/navigation"
|
||||
import { knappeVorhersage, type VorhersageStufe } from "@/lib/prognose"
|
||||
@@ -483,7 +483,8 @@ function HomelabKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||
const punkt: Punktzustand = z.erreichbar === false ? "rot" : z.erreichbar ? "ok" : "aus"
|
||||
return (
|
||||
<li key={z.id}>
|
||||
<Kachel to={k ? "/homelab/updates" : "/homelab"} bild={geraetebild(z)} name={kurzname(z)} unter={artText(z)}
|
||||
<Kachel to={k ? "/homelab/updates" : "/homelab"} bild={geraetebild(z)} name={kurzname(z)}
|
||||
unter={kennzahlText(z.kennzahl) ?? artText(z)}
|
||||
punkt={punkt} zustandText={z.erreichbar === false ? "antwortet nicht" : z.erreichbar ? "erreichbar" : "kein Stand"}
|
||||
updates={k?.teile ?? []} knopf={k?.knopf ?? false} />
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user