homelab: eigener Live-Strom (keine Getrennt-Anzeige mehr), Hinweise des Homelab-Waechters auf der Seite, Verbindungsanzeige je Instanz
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
83c33584e4
commit
160381d8b3
@@ -166,6 +166,7 @@ if ROLLE == "box":
|
||||
else:
|
||||
from routers import homelab
|
||||
app.include_router(homelab.router) # Proxmox-Host, Container, Arcane (Phase 3/4)
|
||||
app.include_router(homelab.strom_router) # Live-Strom der Homelab-Instanz
|
||||
|
||||
|
||||
# Prod: gebautes Frontend ausliefern (falls vorhanden). SPA-Fallback auf index.html.
|
||||
|
||||
@@ -7,21 +7,29 @@
|
||||
POST /api/homelab/ziele/{id}/docker Docker über Arcane (zuerst Probelauf)
|
||||
POST /api/homelab/ziele/pve/neustart Proxmox-Host neu starten (eigener Knopf)
|
||||
GET /api/homelab/laeufe Die letzten Update-Läufe mit ihren Schritten
|
||||
GET /api/homelab/hinweise Hinweise des Wächters dieser Instanz
|
||||
GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers
|
||||
GET /api/homelab/ausfuehrer/auftrag ┐
|
||||
POST /api/homelab/ausfuehrer/bericht ├ nur für den Ausführer (Kopfzeile X-MC2-Ausfuehrer)
|
||||
POST /api/homelab/ausfuehrer/ergebnis/{id} ┘
|
||||
|
||||
GET /api/stream Live-Strom dieser Instanz (Anstöße, kein Messpunkt)
|
||||
|
||||
Dünn: die Logik liegt in services/homelab/.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||
from fastapi.responses import StreamingResponse
|
||||
from kern.einstellungen import einstellungen
|
||||
from pydantic import BaseModel
|
||||
from services.homelab import inventar, kanal, updates
|
||||
|
||||
router = APIRouter(prefix="/api/homelab", tags=["homelab"])
|
||||
strom_router = APIRouter(prefix="/api", tags=["homelab"])
|
||||
|
||||
|
||||
def _nur_ausfuehrer(x_mc2_ausfuehrer: str | None = Header(default=None)) -> None:
|
||||
@@ -70,6 +78,13 @@ def suchen(ziel_id: str) -> dict:
|
||||
return {"ok": True, "auftrag": kanal.anlegen("suchen", {"vmid": gast["vmid"]})}
|
||||
|
||||
|
||||
@router.get("/hinweise")
|
||||
def hinweise() -> dict:
|
||||
"""Was der Wächter dieser Instanz gerade sieht (Ausführer schweigt, Gast antwortet nicht …)."""
|
||||
from services import waechter
|
||||
return waechter.lese_stand()
|
||||
|
||||
|
||||
@router.get("/laeufe")
|
||||
def laeufe() -> dict:
|
||||
return {"laeufe": updates.laeufe()}
|
||||
@@ -101,3 +116,42 @@ class Ergebnis(BaseModel):
|
||||
@router.post("/ausfuehrer/ergebnis/{auftrag_id}", dependencies=[Depends(_nur_ausfuehrer)])
|
||||
def ergebnis(auftrag_id: str, e: Ergebnis) -> dict:
|
||||
return {"ok": kanal.ergebnis(auftrag_id, e.code, e.text)}
|
||||
|
||||
|
||||
# --- Live-Strom der Homelab-Instanz ---------------------------------------------------------------
|
||||
# Die Oberfläche hört auf /api/stream der Instanz, die sie ausliefert. Im Homelab gibt es keine
|
||||
# Messpunkte der KI-Box; der Strom stößt nur neu laden an, wenn sich Bericht, Läufe oder Hinweise ändern,
|
||||
# und meldet sich alle 10 s mit einem leeren Anstoß (sonst hielte die Oberfläche die Leitung für tot).
|
||||
_DATEIEN = (("homelab", "pve-bericht.json"), ("homelab-laeufe", "homelab-laeufe.json"),
|
||||
("homelab-hinweise", "mc2-waechter.json"))
|
||||
|
||||
|
||||
def _abdruecke() -> dict[str, float]:
|
||||
ordner = einstellungen().daten_dir
|
||||
abdruck = {}
|
||||
for schluessel, name in _DATEIEN:
|
||||
try:
|
||||
abdruck[schluessel] = (ordner / name).stat().st_mtime
|
||||
except OSError:
|
||||
abdruck[schluessel] = 0.0
|
||||
return abdruck
|
||||
|
||||
|
||||
async def _strom(request: Request, takt_s: float = 2.0, lebenszeichen_takte: int = 5):
|
||||
alt = _abdruecke()
|
||||
yield ": verbunden\n\n"
|
||||
takte = 0
|
||||
while not await request.is_disconnected():
|
||||
await asyncio.sleep(takt_s)
|
||||
takte += 1
|
||||
neu = _abdruecke()
|
||||
schluessel = [k for k, v in neu.items() if v != alt.get(k)]
|
||||
alt = neu
|
||||
if schluessel or takte % lebenszeichen_takte == 0:
|
||||
yield f"event: invalidate\ndata: {json.dumps({'keys': schluessel})}\n\n"
|
||||
|
||||
|
||||
@strom_router.get("/stream")
|
||||
async def stream(request: Request) -> StreamingResponse:
|
||||
return StreamingResponse(_strom(request), media_type="text/event-stream",
|
||||
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
|
||||
|
||||
@@ -297,3 +297,40 @@ def test_eigener_container_erscheint_nicht(daten):
|
||||
"etiketten": ["mc2"], "erlaubt": False, "ip": "192.168.178.31"})
|
||||
kanal.bericht_speichern(bericht)
|
||||
assert "ct-107" not in {z["id"] for z in inventar.ziele()["ziele"]}
|
||||
|
||||
|
||||
def test_strom_stoesst_bei_aenderung_an_und_meldet_sich(daten):
|
||||
import asyncio
|
||||
|
||||
from routers import homelab as homelab_router
|
||||
|
||||
class Anfrage:
|
||||
def __init__(self):
|
||||
self.runden = 0
|
||||
|
||||
async def is_disconnected(self):
|
||||
self.runden += 1
|
||||
return self.runden > 4
|
||||
|
||||
async def lesen() -> list[str]:
|
||||
teile = []
|
||||
async for teil in homelab_router._strom(Anfrage(), takt_s=0.01, lebenszeichen_takte=2):
|
||||
teile.append(teil)
|
||||
if len(teile) == 1:
|
||||
kanal.bericht_speichern(BERICHT) # ein neuer Bericht → Anstoß „homelab“
|
||||
return teile
|
||||
|
||||
teile = asyncio.run(lesen())
|
||||
assert teile[0] == ": verbunden\n\n"
|
||||
assert teile[1] == 'event: invalidate\ndata: {"keys": ["homelab"]}\n\n'
|
||||
assert 'event: invalidate\ndata: {"keys": []}\n\n' in teile[2:] # Lebenszeichen
|
||||
|
||||
|
||||
def test_hinweise_des_homelab_waechters(daten, monkeypatch):
|
||||
from services import waechter
|
||||
monkeypatch.setattr(waechter, "STORE_PATH", daten / "mc2-waechter.json")
|
||||
(daten / "mc2-waechter.json").write_text(json.dumps({"hinweise": {"gast:ct-104": {
|
||||
"id": "gast:ct-104", "stufe": "rot", "titel": "Gitea antwortet nicht", "text": "…", "seit": 1}}}),
|
||||
encoding="utf-8")
|
||||
antwort = _client().get("/api/homelab/hinweise").json()
|
||||
assert [h["titel"] for h in antwort["hinweise"]] == ["Gitea antwortet nicht"]
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{c as e,i as t,p as n,s as r,t as i,u as a}from"./button-BheHBEml.js";import{H as o,I as s,Q as c,U as l,b as u,s as d}from"./index-CsrOM_oc.js";import{a as f,i as p,n as m,r as h,t as g}from"./sheet-CKp82pOA.js";var _=r(),v=n(a(),1),y=e();function b(e){let t=(0,_.c)(4),{unit:n}=e,r=u(n),i;if(r.isPending)i=`Wird gelesen …`;else if(r.isError)i=`Das Protokoll lässt sich gerade nicht lesen: ${r.error.message}`;else if(r.data.ok===!1)i=r.data.err||r.data.text||`Das Protokoll lässt sich nicht lesen.`;else{let e;t[0]===r.data.text?e=t[1]:(e=r.data.text?.trim()||`Noch keine Einträge.`,t[0]=r.data.text,t[1]=e),i=e}let a;return t[2]===i?a=t[3]:(a=(0,y.jsx)(`pre`,{className:`ziffern m-0 max-h-[50vh] w-full basis-full overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap break-all text-text-2`,children:i}),t[2]=i,t[3]=a),a}function x({offen:e,onSchliessen:n}){let r=c(),a=d(),[u,_]=(0,v.useState)(null),[x,S]=(0,v.useState)(null),[C,w]=(0,v.useState)(null);async function T(e){if(!C){S(null),w(e.unit);try{let t=await o(`/api/maintenance/restart`,{service:e.unit});t.ok?l(`erfolg`,`${e.name} ${e.schlaeft?`wird geweckt`:`startet neu`}.`):l(`fehler`,`${e.name} ließ sich nicht starten: ${s(t)}`),r.invalidateQueries({queryKey:[`dienste`]})}catch(e){l(`fehler`,e.message)}finally{w(null)}}}let E;return E=a.isPending?(0,y.jsx)(`p`,{role:`status`,className:`px-4 text-[15px] text-text-2`,children:`Dienste werden gelesen …`}):a.isError&&!a.data?(0,y.jsxs)(`p`,{role:`alert`,className:`px-4 text-[15px] text-rot-text`,children:[`Die Dienste lassen sich gerade nicht lesen: `,a.error.message]}):a.data?.services.length?(0,y.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-4`,children:a.data.services.map(e=>(0,y.jsxs)(`li`,{className:`flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5`,children:[(0,y.jsxs)(`span`,{className:`flex min-w-0 items-center gap-2.5`,children:[(0,y.jsx)(`span`,{className:t(`size-2.5 shrink-0 rounded-full`,e.ok?`bg-gruen`:e.schlaeft?`bg-text-3`:`bg-rot`),"aria-hidden":!0}),(0,y.jsxs)(`span`,{className:`min-w-0`,children:[(0,y.jsx)(`span`,{className:`block text-[15px]`,children:e.name}),(0,y.jsxs)(`span`,{className:`ziffern text-xs text-text-3`,children:[e.unit,` · `,e.ok?`läuft`:e.schlaeft?`schläft (abgeschaltet)`:`antwortet nicht`]})]})]}),(0,y.jsxs)(`span`,{className:`flex flex-wrap gap-2`,children:[(0,y.jsx)(i,{variant:u===e.unit?`info`:`ghost`,size:`sm`,"aria-expanded":u===e.unit,onClick:()=>_(u===e.unit?null:e.unit),children:`Protokoll`}),x===e.unit?(0,y.jsxs)(y.Fragment,{children:[(0,y.jsx)(i,{variant:`gefahr`,size:`sm`,onClick:()=>T(e),children:e.schlaeft?`Wirklich wecken?`:`Wirklich neu starten?`}),(0,y.jsx)(i,{variant:`ghost`,size:`sm`,onClick:()=>S(null),children:`Nein`})]}):(0,y.jsx)(i,{variant:`outline`,size:`sm`,disabled:C!==null,onClick:()=>S(e.unit),children:C===e.unit?`Startet …`:e.schlaeft?`Wecken`:`Neu starten`})]}),u===e.unit&&(0,y.jsx)(b,{unit:e.unit})]},e.unit))}):(0,y.jsx)(`p`,{className:`px-4 text-[15px] text-text-2`,children:`Keine Dienste gefunden.`}),(0,y.jsx)(g,{open:e,onOpenChange:e=>!e&&n(),children:(0,y.jsxs)(m,{side:`right`,className:`gap-3 overflow-y-auto border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-2xl`,children:[(0,y.jsxs)(p,{children:[(0,y.jsx)(f,{className:`schild text-lg`,children:`Dienste und Protokolle`}),(0,y.jsx)(h,{className:`text-text-2`,children:`Welcher Dienst läuft, was er zuletzt geschrieben hat.`})]}),E]})})}export{x as Dienste};
|
||||
import{c as e,i as t,p as n,s as r,t as i,u as a}from"./button-BheHBEml.js";import{$ as o,G as s,L as c,s as l,x as u,z as d}from"./index-BGjA8vtO.js";import{a as f,i as p,n as m,r as h,t as g}from"./sheet-CKp82pOA.js";var _=r(),v=n(a(),1),y=e();function b(e){let t=(0,_.c)(4),{unit:n}=e,r=u(n),i;if(r.isPending)i=`Wird gelesen …`;else if(r.isError)i=`Das Protokoll lässt sich gerade nicht lesen: ${r.error.message}`;else if(r.data.ok===!1)i=r.data.err||r.data.text||`Das Protokoll lässt sich nicht lesen.`;else{let e;t[0]===r.data.text?e=t[1]:(e=r.data.text?.trim()||`Noch keine Einträge.`,t[0]=r.data.text,t[1]=e),i=e}let a;return t[2]===i?a=t[3]:(a=(0,y.jsx)(`pre`,{className:`ziffern m-0 max-h-[50vh] w-full basis-full overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap break-all text-text-2`,children:i}),t[2]=i,t[3]=a),a}function x({offen:e,onSchliessen:n}){let r=o(),a=l(),[u,_]=(0,v.useState)(null),[x,S]=(0,v.useState)(null),[C,w]=(0,v.useState)(null);async function T(e){if(!C){S(null),w(e.unit);try{let t=await s(`/api/maintenance/restart`,{service:e.unit});t.ok?c(`erfolg`,`${e.name} ${e.schlaeft?`wird geweckt`:`startet neu`}.`):c(`fehler`,`${e.name} ließ sich nicht starten: ${d(t)}`),r.invalidateQueries({queryKey:[`dienste`]})}catch(e){c(`fehler`,e.message)}finally{w(null)}}}let E;return E=a.isPending?(0,y.jsx)(`p`,{role:`status`,className:`px-4 text-[15px] text-text-2`,children:`Dienste werden gelesen …`}):a.isError&&!a.data?(0,y.jsxs)(`p`,{role:`alert`,className:`px-4 text-[15px] text-rot-text`,children:[`Die Dienste lassen sich gerade nicht lesen: `,a.error.message]}):a.data?.services.length?(0,y.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-4`,children:a.data.services.map(e=>(0,y.jsxs)(`li`,{className:`flex flex-wrap items-center justify-between gap-2 border-t border-linie py-2.5`,children:[(0,y.jsxs)(`span`,{className:`flex min-w-0 items-center gap-2.5`,children:[(0,y.jsx)(`span`,{className:t(`size-2.5 shrink-0 rounded-full`,e.ok?`bg-gruen`:e.schlaeft?`bg-text-3`:`bg-rot`),"aria-hidden":!0}),(0,y.jsxs)(`span`,{className:`min-w-0`,children:[(0,y.jsx)(`span`,{className:`block text-[15px]`,children:e.name}),(0,y.jsxs)(`span`,{className:`ziffern text-xs text-text-3`,children:[e.unit,` · `,e.ok?`läuft`:e.schlaeft?`schläft (abgeschaltet)`:`antwortet nicht`]})]})]}),(0,y.jsxs)(`span`,{className:`flex flex-wrap gap-2`,children:[(0,y.jsx)(i,{variant:u===e.unit?`info`:`ghost`,size:`sm`,"aria-expanded":u===e.unit,onClick:()=>_(u===e.unit?null:e.unit),children:`Protokoll`}),x===e.unit?(0,y.jsxs)(y.Fragment,{children:[(0,y.jsx)(i,{variant:`gefahr`,size:`sm`,onClick:()=>T(e),children:e.schlaeft?`Wirklich wecken?`:`Wirklich neu starten?`}),(0,y.jsx)(i,{variant:`ghost`,size:`sm`,onClick:()=>S(null),children:`Nein`})]}):(0,y.jsx)(i,{variant:`outline`,size:`sm`,disabled:C!==null,onClick:()=>S(e.unit),children:C===e.unit?`Startet …`:e.schlaeft?`Wecken`:`Neu starten`})]}),u===e.unit&&(0,y.jsx)(b,{unit:e.unit})]},e.unit))}):(0,y.jsx)(`p`,{className:`px-4 text-[15px] text-text-2`,children:`Keine Dienste gefunden.`}),(0,y.jsx)(g,{open:e,onOpenChange:e=>!e&&n(),children:(0,y.jsxs)(m,{side:`right`,className:`gap-3 overflow-y-auto border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-2xl`,children:[(0,y.jsxs)(p,{children:[(0,y.jsx)(f,{className:`schild text-lg`,children:`Dienste und Protokolle`}),(0,y.jsx)(h,{className:`text-text-2`,children:`Welcher Dienst läuft, was er zuletzt geschrieben hat.`})]}),E]})})}export{x as Dienste};
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
import{c as e,p as t,t as n,u as r}from"./button-BheHBEml.js";import{H as i,Q as a,U as o,V as s,Z as c,q as l}from"./index-CsrOM_oc.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-CKp82pOA.js";var h=t(r(),1),g=e();function _({offen:e,onSchliessen:t}){let r=a(),_=c({queryKey:[`geheimnisse`],queryFn:()=>s(`/api/maintenance/geheimnisse`),enabled:e}),[v,y]=(0,h.useState)(``),[b,x]=(0,h.useState)(!1),[S,C]=(0,h.useState)(!1);async function w(e){if(!b){x(!0),C(!1);try{await i(`/api/maintenance/geheimnisse`,{schluessel:`hf_token`,wert:e}),o(`erfolg`,e?`Hugging-Face-Zugang gespeichert.`:`Hugging-Face-Zugang gelöscht.`),y(``),r.invalidateQueries({queryKey:[`geheimnisse`]})}catch(e){o(`fehler`,e.message)}finally{x(!1)}}}let T=_.data,E;return E=_.isPending?`Wird gelesen …`:T?T.hf_token_gesetzt?`Ist hinterlegt.`:`Fehlt.`:`Lässt sich gerade nicht lesen${_.error?`: ${_.error.message}`:`.`}`,(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`gap-3 overflow-y-auto border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-lg`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Einstellungen`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Was die Box sich merkt.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-3 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hugging-Face-Zugang`}),(0,g.jsxs)(`p`,{className:`text-sm text-text-2`,"aria-live":`polite`,children:[(0,g.jsx)(`span`,{className:!T&&!_.isPending?`text-rot-text`:void 0,children:E}),` Nötig für gesperrte Modelle und schnellere Downloads.`,T?.hf_token_aus_env?` Er kommt aus der Dienst-Einstellung und lässt sich hier nicht löschen.`:``]}),T?.schreibbar===!1&&(0,g.jsx)(`p`,{className:`text-sm text-bernstein`,children:`Die Box kann den Zugang gerade nicht speichern: Ihre Ablage ist nicht beschreibbar.`}),(0,g.jsxs)(`form`,{className:`flex gap-2`,onSubmit:e=>{e.preventDefault(),v.trim()&&w(v.trim())},children:[(0,g.jsx)(`label`,{htmlFor:`hf-token`,className:`sr-only`,children:`Hugging-Face-Zugang`}),(0,g.jsx)(`input`,{id:`hf-token`,type:`password`,autoComplete:`off`,value:v,onChange:e=>y(e.target.value),placeholder:`hf_…`,className:`h-11 min-w-0 flex-1 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] outline-none focus:border-cyan`}),(0,g.jsx)(n,{type:`submit`,disabled:!v.trim()||T?.schreibbar===!1||b,children:b?`Speichert …`:`Speichern`})]}),T?.hf_token_gesetzt&&!T.hf_token_aus_env&&(S?(0,g.jsxs)(`span`,{className:`flex flex-wrap gap-2`,children:[(0,g.jsx)(n,{variant:`gefahr`,size:`sm`,disabled:b,onClick:()=>w(null),children:`Wirklich löschen?`}),(0,g.jsx)(n,{variant:`ghost`,size:`sm`,onClick:()=>C(!1),children:`Nein`})]}):(0,g.jsx)(n,{variant:`ghost`,size:`sm`,className:`self-start`,disabled:b,onClick:()=>C(!0),children:`Zugang löschen`}))]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hinweise`}),(0,g.jsx)(`p`,{className:`text-sm text-text-2`,children:`Dringende Hinweise gehen zusätzlich per Telegram raus, alles andere bleibt hier. Einfache Probleme wie einen abgestürzten Dienst behebt der Wächter selbst und schreibt es in den Verlauf.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4 pb-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hermes`}),(0,g.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-11 items-center gap-2 self-start rounded-lg border border-linie-stark bg-erhaben px-4 text-[15px] hover:border-text-3`,children:[(0,g.jsx)(l,{className:`size-4`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),(0,g.jsx)(`p`,{className:`text-xs text-text-3`,children:`Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.`})]})]})})}export{_ as Einstellungen};
|
||||
import{c as e,p as t,t as n,u as r}from"./button-BheHBEml.js";import{$ as i,G as a,J as o,L as s,Q as c,W as l}from"./index-BGjA8vtO.js";import{a as u,i as d,n as f,r as p,t as m}from"./sheet-CKp82pOA.js";var h=t(r(),1),g=e();function _({offen:e,onSchliessen:t}){let r=i(),_=c({queryKey:[`geheimnisse`],queryFn:()=>l(`/api/maintenance/geheimnisse`),enabled:e}),[v,y]=(0,h.useState)(``),[b,x]=(0,h.useState)(!1),[S,C]=(0,h.useState)(!1);async function w(e){if(!b){x(!0),C(!1);try{await a(`/api/maintenance/geheimnisse`,{schluessel:`hf_token`,wert:e}),s(`erfolg`,e?`Hugging-Face-Zugang gespeichert.`:`Hugging-Face-Zugang gelöscht.`),y(``),r.invalidateQueries({queryKey:[`geheimnisse`]})}catch(e){s(`fehler`,e.message)}finally{x(!1)}}}let T=_.data,E;return E=_.isPending?`Wird gelesen …`:T?T.hf_token_gesetzt?`Ist hinterlegt.`:`Fehlt.`:`Lässt sich gerade nicht lesen${_.error?`: ${_.error.message}`:`.`}`,(0,g.jsx)(m,{open:e,onOpenChange:e=>!e&&t(),children:(0,g.jsxs)(f,{side:`right`,className:`gap-3 overflow-y-auto border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-lg`,children:[(0,g.jsxs)(d,{children:[(0,g.jsx)(u,{className:`schild text-lg`,children:`Einstellungen`}),(0,g.jsx)(p,{className:`text-text-2`,children:`Was die Box sich merkt.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-3 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hugging-Face-Zugang`}),(0,g.jsxs)(`p`,{className:`text-sm text-text-2`,"aria-live":`polite`,children:[(0,g.jsx)(`span`,{className:!T&&!_.isPending?`text-rot-text`:void 0,children:E}),` Nötig für gesperrte Modelle und schnellere Downloads.`,T?.hf_token_aus_env?` Er kommt aus der Dienst-Einstellung und lässt sich hier nicht löschen.`:``]}),T?.schreibbar===!1&&(0,g.jsx)(`p`,{className:`text-sm text-bernstein`,children:`Die Box kann den Zugang gerade nicht speichern: Ihre Ablage ist nicht beschreibbar.`}),(0,g.jsxs)(`form`,{className:`flex gap-2`,onSubmit:e=>{e.preventDefault(),v.trim()&&w(v.trim())},children:[(0,g.jsx)(`label`,{htmlFor:`hf-token`,className:`sr-only`,children:`Hugging-Face-Zugang`}),(0,g.jsx)(`input`,{id:`hf-token`,type:`password`,autoComplete:`off`,value:v,onChange:e=>y(e.target.value),placeholder:`hf_…`,className:`h-11 min-w-0 flex-1 rounded-lg border border-linie-stark bg-erhaben px-3 text-[15px] outline-none focus:border-cyan`}),(0,g.jsx)(n,{type:`submit`,disabled:!v.trim()||T?.schreibbar===!1||b,children:b?`Speichert …`:`Speichern`})]}),T?.hf_token_gesetzt&&!T.hf_token_aus_env&&(S?(0,g.jsxs)(`span`,{className:`flex flex-wrap gap-2`,children:[(0,g.jsx)(n,{variant:`gefahr`,size:`sm`,disabled:b,onClick:()=>w(null),children:`Wirklich löschen?`}),(0,g.jsx)(n,{variant:`ghost`,size:`sm`,onClick:()=>C(!1),children:`Nein`})]}):(0,g.jsx)(n,{variant:`ghost`,size:`sm`,className:`self-start`,disabled:b,onClick:()=>C(!0),children:`Zugang löschen`}))]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hinweise`}),(0,g.jsx)(`p`,{className:`text-sm text-text-2`,children:`Dringende Hinweise gehen zusätzlich per Telegram raus, alles andere bleibt hier. Einfache Probleme wie einen abgestürzten Dienst behebt der Wächter selbst und schreibt es in den Verlauf.`})]}),(0,g.jsxs)(`section`,{className:`flex flex-col gap-2 border-t border-linie px-4 pt-4 pb-4`,children:[(0,g.jsx)(`h3`,{className:`schild text-sm text-text-3`,children:`Hermes`}),(0,g.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-11 items-center gap-2 self-start rounded-lg border border-linie-stark bg-erhaben px-4 text-[15px] hover:border-text-3`,children:[(0,g.jsx)(o,{className:`size-4`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),(0,g.jsx)(`p`,{className:`text-xs text-text-3`,children:`Chat, Sitzungen und Cron-Jobs verwaltet Hermes selbst. Es fragt nach seiner eigenen Anmeldung.`})]})]})})}export{_ as Einstellungen};
|
||||
-1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{c as e,s as t}from"./button-BheHBEml.js";import{G as n,K as r,q as i}from"./index-CsrOM_oc.js";import{a,i as o,n as s,r as c,t as l}from"./sheet-CKp82pOA.js";var u=t(),d=e();function f(e){let t=(0,u.c)(16),{onDienste:f,onEinstellungen:p,onSchliessen:m}=e,h;t[0]===m?h=t[1]:(h=e=>!e&&m(),t[0]=m,t[1]=h);let g;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(g=(0,d.jsxs)(o,{children:[(0,d.jsx)(a,{className:`schild text-lg`,children:`Mehr`}),(0,d.jsx)(c,{className:`sr-only`,children:`Weitere Bereiche`})]}),t[2]=g):g=t[2];let _;t[3]===Symbol.for(`react.memo_cache_sentinel`)?(_=(0,d.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-base`,children:[(0,d.jsx)(i,{className:`size-5`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),t[3]=_):_=t[3];let v;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,d.jsx)(r,{className:`size-5`,"aria-hidden":!0}),t[4]=v):v=t[4];let y;t[5]===f?y=t[6]:(y=(0,d.jsxs)(`button`,{type:`button`,onClick:f,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[v,` Dienste und Protokolle`]}),t[5]=f,t[6]=y);let b;t[7]===Symbol.for(`react.memo_cache_sentinel`)?(b=(0,d.jsx)(n,{className:`size-5`,"aria-hidden":!0}),t[7]=b):b=t[7];let x;t[8]===p?x=t[9]:(x=(0,d.jsxs)(`button`,{type:`button`,onClick:p,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[b,` Einstellungen`]}),t[8]=p,t[9]=x);let S;t[10]!==y||t[11]!==x?(S=(0,d.jsxs)(s,{side:`bottom`,className:`border-linie bg-panel pb-[calc(1rem+env(safe-area-inset-bottom))]`,children:[g,(0,d.jsxs)(`div`,{className:`flex flex-col gap-2 px-4`,children:[_,y,x]})]}),t[10]=y,t[11]=x,t[12]=S):S=t[12];let C;return t[13]!==h||t[14]!==S?(C=(0,d.jsx)(l,{open:!0,onOpenChange:h,children:S}),t[13]=h,t[14]=S,t[15]=C):C=t[15],C}export{f as MehrMenue};
|
||||
import{c as e,s as t}from"./button-BheHBEml.js";import{J as n,K as r,q as i}from"./index-BGjA8vtO.js";import{a,i as o,n as s,r as c,t as l}from"./sheet-CKp82pOA.js";var u=t(),d=e();function f(e){let t=(0,u.c)(16),{onDienste:f,onEinstellungen:p,onSchliessen:m}=e,h;t[0]===m?h=t[1]:(h=e=>!e&&m(),t[0]=m,t[1]=h);let g;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(g=(0,d.jsxs)(o,{children:[(0,d.jsx)(a,{className:`schild text-lg`,children:`Mehr`}),(0,d.jsx)(c,{className:`sr-only`,children:`Weitere Bereiche`})]}),t[2]=g):g=t[2];let _;t[3]===Symbol.for(`react.memo_cache_sentinel`)?(_=(0,d.jsxs)(`a`,{href:`/hermes-ui/`,target:`_blank`,rel:`noopener`,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-base`,children:[(0,d.jsx)(n,{className:`size-5`,"aria-hidden":!0}),` Hermes-Dashboard öffnen`]}),t[3]=_):_=t[3];let v;t[4]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,d.jsx)(i,{className:`size-5`,"aria-hidden":!0}),t[4]=v):v=t[4];let y;t[5]===f?y=t[6]:(y=(0,d.jsxs)(`button`,{type:`button`,onClick:f,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[v,` Dienste und Protokolle`]}),t[5]=f,t[6]=y);let b;t[7]===Symbol.for(`react.memo_cache_sentinel`)?(b=(0,d.jsx)(r,{className:`size-5`,"aria-hidden":!0}),t[7]=b):b=t[7];let x;t[8]===p?x=t[9]:(x=(0,d.jsxs)(`button`,{type:`button`,onClick:p,className:`flex h-12 items-center gap-3 rounded-lg border border-linie px-4 text-left text-base`,children:[b,` Einstellungen`]}),t[8]=p,t[9]=x);let S;t[10]!==y||t[11]!==x?(S=(0,d.jsxs)(s,{side:`bottom`,className:`border-linie bg-panel pb-[calc(1rem+env(safe-area-inset-bottom))]`,children:[g,(0,d.jsxs)(`div`,{className:`flex flex-col gap-2 px-4`,children:[_,y,x]})]}),t[10]=y,t[11]=x,t[12]=S):S=t[12];let C;return t[13]!==h||t[14]!==S?(C=(0,d.jsx)(l,{open:!0,onOpenChange:h,children:S}),t[13]=h,t[14]=S,t[15]=C):C=t[15],C}export{f as MehrMenue};
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
-2
File diff suppressed because one or more lines are too long
+4
-4
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -7,7 +7,7 @@
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Homelab Orchestrator</title>
|
||||
<script type="module" crossorigin src="/assets/index-CsrOM_oc.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BGjA8vtO.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/button-BheHBEml.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bpe3m53F.css">
|
||||
</head>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { lazy, Suspense, useEffect, useState } from "react"
|
||||
import { Link, Outlet } from "@tanstack/react-router"
|
||||
import { Cpu, Ellipsis, ExternalLink, House, RefreshCw, ScrollText, Server, Settings } from "lucide-react"
|
||||
import { cn } from "cn"
|
||||
import { useInstanz } from "@/lib/abfragen"
|
||||
import { useStrom, useStromLage } from "@/lib/strom"
|
||||
import { Meldungen } from "./Meldungen"
|
||||
|
||||
@@ -40,6 +41,8 @@ function Uhr() {
|
||||
function Verbindung() {
|
||||
const lage = useStromLage()
|
||||
const live = lage === "live"
|
||||
// Die Anzeige gilt der Instanz, die die Seite ausliefert: der KI-Box oder dem Homelab-Teil.
|
||||
const wer = useInstanz().data?.rolle === "homelab" ? "Homelab" : "Box"
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
@@ -52,7 +55,7 @@ function Verbindung() {
|
||||
aria-hidden
|
||||
className={cn("size-2 shrink-0 rounded-full", live ? "bg-gruen shadow-[0_0_8px_rgba(61,220,138,0.7)]" : "bg-bernstein")}
|
||||
/>
|
||||
<span className="whitespace-nowrap">{live ? "Box online" : lage === "verbinde" ? "Verbinde" : "Getrennt"}</span>
|
||||
<span className="whitespace-nowrap">{live ? `${wer} online` : lage === "verbinde" ? "Verbinde" : "Getrennt"}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import { fehlerText, laeuftNoch } from "./anzeige"
|
||||
import { melden } from "./meldungen"
|
||||
import { stromVerbunden } from "./strom"
|
||||
import type {
|
||||
AktionsErgebnis, AufraeumKandidat, HomelabLauf, HomelabStand, Instanz, Job, Modell, Nutzung, PartnerStatus, Radar,
|
||||
AktionsErgebnis, AufraeumKandidat, Hinweis, HomelabLauf, HomelabStand, Instanz, Job, Modell, Nutzung, PartnerStatus,
|
||||
Radar,
|
||||
Sicherung, Start, UpdateDetails, ZielAktion, ZielStand,
|
||||
} from "./typen"
|
||||
import { gbText } from "./zeit"
|
||||
@@ -291,3 +292,12 @@ export const useZielAktion = () =>
|
||||
erfolg: (a) => `${a.label}: gestartet. Das Ergebnis kommt als Meldung.`,
|
||||
neuLaden: ["homelab", "homelab-laeufe", "ziele-box", "jobs", "start"],
|
||||
})
|
||||
|
||||
/** Was der Wächter des Homelab-Teils gerade sieht (Ausführer schweigt, ein Gast antwortet nicht …). */
|
||||
export const useHomelabHinweise = (aktiv: boolean) =>
|
||||
useQuery({
|
||||
queryKey: ["homelab-hinweise"],
|
||||
queryFn: () => api<{ hinweise: Hinweis[]; aktiv: boolean }>("/api/homelab/hinweise"),
|
||||
enabled: aktiv,
|
||||
refetchInterval: takt(60_000),
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
|
||||
import { Panel } from "@/components/cockpit/Panel"
|
||||
import { ZielKarte } from "@/components/cockpit/ZielKarte"
|
||||
import { useBoxZiele, useHomelab, useHomelabLaeufe, useInstanz, usePartner, useZielAktion } from "@/lib/abfragen"
|
||||
import type { HomelabLauf, PartnerStatus, ZielStand } from "@/lib/typen"
|
||||
import {
|
||||
useBoxZiele, useHomelab, useHomelabHinweise, useHomelabLaeufe, useInstanz, usePartner, useZielAktion,
|
||||
} from "@/lib/abfragen"
|
||||
import type { Hinweis, HomelabLauf, PartnerStatus, ZielStand } from "@/lib/typen"
|
||||
import { flugplanZeit } from "@/lib/zeit"
|
||||
|
||||
/** Was der Homelab-Teil übernimmt (Bestandsaufnahme vom 24.09.2026). */
|
||||
@@ -60,6 +62,25 @@ function HomelabFehlt({ partner }: { partner: PartnerStatus }) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Hinweise des Homelab-Wächters (die der KI-Box stehen auf dem Start). Rot zuerst. */
|
||||
function HomelabHinweise({ hinweise }: { hinweise: Hinweis[] }) {
|
||||
if (hinweise.length === 0) return null
|
||||
return (
|
||||
<Panel titel="Hinweise im Homelab" rechts={<Chip art={hinweise.some((h) => h.stufe === "rot") ? "rot" : "bernstein"}>{hinweise.length}</Chip>}>
|
||||
<ul className="flex flex-col gap-3">
|
||||
{hinweise.map((h) => (
|
||||
<li key={h.id} className="flex flex-col gap-1 border-t border-linie pt-3 first:border-t-0 first:pt-0">
|
||||
<span className={h.stufe === "rot" ? "text-[15px] font-medium text-rot-text" : "text-[15px] font-medium text-bernstein"}>
|
||||
{h.titel}
|
||||
</span>
|
||||
<span className="text-sm text-text-2">{h.text}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function LetzteLaeufe({ laeufe }: { laeufe: HomelabLauf[] }) {
|
||||
const fertig = laeufe.filter((l) => l.status !== "laeuft").slice(0, 6)
|
||||
if (fertig.length === 0) return null
|
||||
@@ -94,6 +115,7 @@ export function HomelabSeite() {
|
||||
const aktiv = homelabHier || !!(partner.data?.eingerichtet && partner.data.erreichbar)
|
||||
const homelab = useHomelab(aktiv)
|
||||
const laeufe = useHomelabLaeufe(aktiv)
|
||||
const hinweise = useHomelabHinweise(aktiv)
|
||||
const aktion = useZielAktion()
|
||||
|
||||
if (instanz.isPending || partner.isPending) return <Zustandsfeld text="Wird gelesen …" />
|
||||
@@ -131,6 +153,8 @@ export function HomelabSeite() {
|
||||
{homelab.isError && <p role="alert" className="text-sm text-rot-text">Der Homelab-Teil antwortet gerade nicht: {homelab.error.message}</p>}
|
||||
</Panel>
|
||||
|
||||
{aktiv && <HomelabHinweise hinweise={hinweise.data?.hinweise ?? []} />}
|
||||
|
||||
{ziele.length > 0 && (
|
||||
<div className="grid gap-5 md:gap-6 xl:grid-cols-2">
|
||||
{ziele.map((z) => (
|
||||
|
||||
Reference in New Issue
Block a user