homelab: Speicherpool, NAS und Sicherungen überwachen – mit Empfehlungen, was Platz bringt
User-Wunsch 25.09.: Speicherpool im Wächter (gelb ab 80 %, rot bei 10 % frei) samt Empfehlungen, dazu das NAS (Backup-Ziel, per NFS auf dem Proxmox-Host). - Ausführer: Thin-Pools mit echter Belegung je Volume (lvs), ungenutzte und verwaiste Platten, VM-Belegung über den Gast-Agenten, Discard je VM, Netzlaufwerke (df mit Zeitlimit, hängender harter NFS-Mount hält nichts auf), Sicherungsaufträge und jüngste Sicherung je Gast. - Homelab-Teil: services/homelab/speicher.py (Auswertung, Empfehlungen, Wächter pruefe_speicher, NAS-Erreichbarkeit per TCP), GET /api/homelab/speicher; Gast-Plattenwächter nennt jetzt auch VMs richtig. - Oberfläche: Karte „Speicher und Sicherungen“ im Homelab-Cockpit. - deploy.sh und ausfuehrer-einrichten.sh warten, solange im Homelab ein Update oder „Alle aktualisieren“ läuft (der Neustart bräche den Lauf ab). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9867affc5a
commit
903fbbfbb9
-2
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{i as e,n as t,r as n,s as r,t as i}from"./dist-D4CVSex9.js";import{At as a,Et as o,I as s,Xt as c,tt as l,zt as u}from"./index-C0aLfVi8.js";import{t as d}from"./button-D8rvET0Z.js";import{a as f,i as p,n as m,r as h,t as g}from"./sheet-BOT7swIA.js";var _=t(),v=r(e(),1),y=n();function b(e){let t=(0,_.c)(4),{unit:n}=e,r=l(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:t}){let n=c(),r=s(),[l,_]=(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 u(`/api/maintenance/restart`,{service:e.unit});t.ok?o(`erfolg`,`${e.name} ${e.schlaeft?`wird geweckt`:`startet neu`}.`):o(`fehler`,`${e.name} ließ sich nicht starten: ${a(t)}`),n.invalidateQueries({queryKey:[`dienste`]})}catch(e){o(`fehler`,e.message)}finally{w(null)}}}let E;return E=r.isPending?(0,y.jsx)(`p`,{role:`status`,className:`px-4 text-[15px] text-text-2`,children:`Dienste werden gelesen …`}):r.isError&&!r.data?(0,y.jsxs)(`p`,{role:`alert`,className:`px-4 text-[15px] text-rot-text`,children:[`Die Dienste lassen sich gerade nicht lesen: `,r.error.message]}):r.data?.services.length?(0,y.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-4`,children:r.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:i(`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)(d,{variant:l===e.unit?`info`:`ghost`,size:`sm`,"aria-expanded":l===e.unit,onClick:()=>_(l===e.unit?null:e.unit),children:`Protokoll`}),x===e.unit?(0,y.jsxs)(y.Fragment,{children:[(0,y.jsx)(d,{variant:`gefahr`,size:`sm`,onClick:()=>T(e),children:e.schlaeft?`Wirklich wecken?`:`Wirklich neu starten?`}),(0,y.jsx)(d,{variant:`ghost`,size:`sm`,onClick:()=>S(null),children:`Nein`})]}):(0,y.jsx)(d,{variant:`outline`,size:`sm`,disabled:C!==null,onClick:()=>S(e.unit),children:C===e.unit?`Startet …`:e.schlaeft?`Wecken`:`Neu starten`})]}),l===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&&t(),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{i as e,n as t,r as n,s as r,t as i}from"./dist-D4CVSex9.js";import{Bt as a,Dt as o,I as s,Zt as c,jt as l,nt as u}from"./index-COh3_SSc.js";import{t as d}from"./button-D8rvET0Z.js";import{a as f,i as p,n as m,r as h,t as g}from"./sheet-06g2D7J7.js";var _=t(),v=r(e(),1),y=n();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:t}){let n=c(),r=s(),[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 a(`/api/maintenance/restart`,{service:e.unit});t.ok?o(`erfolg`,`${e.name} ${e.schlaeft?`wird geweckt`:`startet neu`}.`):o(`fehler`,`${e.name} ließ sich nicht starten: ${l(t)}`),n.invalidateQueries({queryKey:[`dienste`]})}catch(e){o(`fehler`,e.message)}finally{w(null)}}}let E;return E=r.isPending?(0,y.jsx)(`p`,{role:`status`,className:`px-4 text-[15px] text-text-2`,children:`Dienste werden gelesen …`}):r.isError&&!r.data?(0,y.jsxs)(`p`,{role:`alert`,className:`px-4 text-[15px] text-rot-text`,children:[`Die Dienste lassen sich gerade nicht lesen: `,r.error.message]}):r.data?.services.length?(0,y.jsx)(`ul`,{className:`m-0 flex list-none flex-col px-4 pb-4`,children:r.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:i(`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)(d,{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)(d,{variant:`gefahr`,size:`sm`,onClick:()=>T(e),children:e.schlaeft?`Wirklich wecken?`:`Wirklich neu starten?`}),(0,y.jsx)(d,{variant:`ghost`,size:`sm`,onClick:()=>S(null),children:`Nein`})]}):(0,y.jsx)(d,{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&&t(),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
File diff suppressed because one or more lines are too long
+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
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -1 +1 @@
|
||||
import{n as e,r as t}from"./dist-D4CVSex9.js";import{g as n,h as r}from"./index-C0aLfVi8.js";import{a as i,i as a,n as o,r as s,t as c}from"./sheet-BOT7swIA.js";var l=e(),u=t();function d(e){let t=(0,l.c)(9),{onDienste:d,onSchliessen:f}=e,p;t[0]===f?p=t[1]:(p=e=>!e&&f(),t[0]=f,t[1]=p);let m;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,u.jsxs)(a,{className:`p-0`,children:[(0,u.jsx)(i,{asChild:!0,children:(0,u.jsx)(`div`,{children:(0,u.jsx)(r,{})})}),(0,u.jsx)(s,{className:`sr-only`,children:`Bereiche und Seiten`})]}),t[2]=m):m=t[2];let h;t[3]!==d||t[4]!==f?(h=(0,u.jsxs)(o,{side:`left`,className:`flex flex-col gap-6 border-linie bg-[#0c0f12] px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] data-[side=left]:w-[300px]`,children:[m,(0,u.jsx)(n,{onDienste:d,onGewaehlt:f})]}),t[3]=d,t[4]=f,t[5]=h):h=t[5];let g;return t[6]!==p||t[7]!==h?(g=(0,u.jsx)(c,{open:!0,onOpenChange:p,children:h}),t[6]=p,t[7]=h,t[8]=g):g=t[8],g}export{d as MenueSchublade};
|
||||
import{n as e,r as t}from"./dist-D4CVSex9.js";import{g as n,h as r}from"./index-COh3_SSc.js";import{a as i,i as a,n as o,r as s,t as c}from"./sheet-06g2D7J7.js";var l=e(),u=t();function d(e){let t=(0,l.c)(9),{onDienste:d,onSchliessen:f}=e,p;t[0]===f?p=t[1]:(p=e=>!e&&f(),t[0]=f,t[1]=p);let m;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,u.jsxs)(a,{className:`p-0`,children:[(0,u.jsx)(i,{asChild:!0,children:(0,u.jsx)(`div`,{children:(0,u.jsx)(r,{})})}),(0,u.jsx)(s,{className:`sr-only`,children:`Bereiche und Seiten`})]}),t[2]=m):m=t[2];let h;t[3]!==d||t[4]!==f?(h=(0,u.jsxs)(o,{side:`left`,className:`flex flex-col gap-6 border-linie bg-[#0c0f12] px-4 pt-5 pb-[calc(1.25rem+env(safe-area-inset-bottom))] data-[side=left]:w-[300px]`,children:[m,(0,u.jsx)(n,{onDienste:d,onGewaehlt:f})]}),t[3]=d,t[4]=f,t[5]=h):h=t[5];let g;return t[6]!==p||t[7]!==h?(g=(0,u.jsx)(c,{open:!0,onOpenChange:p,children:h}),t[6]=p,t[7]=h,t[8]=g):g=t[8],g}export{d as MenueSchublade};
|
||||
+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
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{n as e,r as t}from"./dist-D4CVSex9.js";import{a as n,i as r,n as i,r as a,t as o}from"./sheet-BOT7swIA.js";var s=e(),c=t();function l(e){let t=(0,s.c)(14),{offen:l,titel:u,text:d,onSchliessen:f}=e,p;t[0]===f?p=t[1]:(p=e=>!e&&f(),t[0]=f,t[1]=p);let m;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,c.jsx)(n,{className:`schild text-lg`,children:`Protokoll`}),t[2]=m):m=t[2];let h;t[3]===u?h=t[4]:(h=(0,c.jsxs)(r,{children:[m,(0,c.jsx)(a,{className:`text-text-2`,children:u})]}),t[3]=u,t[4]=h);let g;t[5]===d?g=t[6]:(g=(0,c.jsx)(`pre`,{className:`ziffern mx-4 mb-4 flex-1 overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:d}),t[5]=d,t[6]=g);let _;t[7]!==h||t[8]!==g?(_=(0,c.jsxs)(i,{side:`right`,className:`border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-2xl`,children:[h,g]}),t[7]=h,t[8]=g,t[9]=_):_=t[9];let v;return t[10]!==l||t[11]!==p||t[12]!==_?(v=(0,c.jsx)(o,{open:l,onOpenChange:p,children:_}),t[10]=l,t[11]=p,t[12]=_,t[13]=v):v=t[13],v}export{l as Protokollfenster};
|
||||
import{n as e,r as t}from"./dist-D4CVSex9.js";import{a as n,i as r,n as i,r as a,t as o}from"./sheet-06g2D7J7.js";var s=e(),c=t();function l(e){let t=(0,s.c)(14),{offen:l,titel:u,text:d,onSchliessen:f}=e,p;t[0]===f?p=t[1]:(p=e=>!e&&f(),t[0]=f,t[1]=p);let m;t[2]===Symbol.for(`react.memo_cache_sentinel`)?(m=(0,c.jsx)(n,{className:`schild text-lg`,children:`Protokoll`}),t[2]=m):m=t[2];let h;t[3]===u?h=t[4]:(h=(0,c.jsxs)(r,{children:[m,(0,c.jsx)(a,{className:`text-text-2`,children:u})]}),t[3]=u,t[4]=h);let g;t[5]===d?g=t[6]:(g=(0,c.jsx)(`pre`,{className:`ziffern mx-4 mb-4 flex-1 overflow-auto rounded-lg border border-linie bg-[#0b0d0f] p-3 text-xs leading-relaxed whitespace-pre-wrap text-text-2`,children:d}),t[5]=d,t[6]=g);let _;t[7]!==h||t[8]!==g?(_=(0,c.jsxs)(i,{side:`right`,className:`border-linie bg-panel data-[side=right]:w-full data-[side=right]:sm:max-w-2xl`,children:[h,g]}),t[7]=h,t[8]=g,t[9]=_):_=t[9];let v;return t[10]!==l||t[11]!==p||t[12]!==_?(v=(0,c.jsx)(o,{open:l,onOpenChange:p,children:_}),t[10]=l,t[11]=p,t[12]=_,t[13]=v):v=t[13],v}export{l as Protokollfenster};
|
||||
+2
-2
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
Vendored
+1
-1
@@ -1 +1 @@
|
||||
import{Gt as e}from"./index-C0aLfVi8.js";var t={name:`chevron-right`,size:24,node:[[`path`,{d:`m9 18 6-6-6-6`,key:`mthhwq`}]]};t.node;var n=e(t);export{n as t};
|
||||
import{Kt as e}from"./index-COh3_SSc.js";var t={name:`chevron-right`,size:24,node:[[`path`,{d:`m9 18 6-6-6-6`,key:`mthhwq`}]]};t.node;var n=e(t);export{n as t};
|
||||
+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
-11
File diff suppressed because one or more lines are too long
+11
File diff suppressed because one or more lines are too long
+2
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
||||
import{Gt as e}from"./index-C0aLfVi8.js";var t={name:`search`,size:24,node:[[`path`,{d:`m21 21-4.34-4.34`,key:`14j7rj`}],[`circle`,{cx:`11`,cy:`11`,r:`8`,key:`4ej97u`}]]};t.node;var n=e(t);export{n as t};
|
||||
import{Kt as e}from"./index-COh3_SSc.js";var t={name:`search`,size:24,node:[[`path`,{d:`m21 21-4.34-4.34`,key:`14j7rj`}],[`circle`,{cx:`11`,cy:`11`,r:`8`,key:`4ej97u`}]]};t.node;var n=e(t);export{n as t};
|
||||
+1
-1
@@ -1 +1 @@
|
||||
import{i as e,n as t,r as n,t as r}from"./dist-D4CVSex9.js";import{Bt as i}from"./index-C0aLfVi8.js";import{t as a}from"./button-D8rvET0Z.js";import{a as o,i as s,n as c,o as l,r as u,s as d,t as f}from"./dist-Cqf1HmXO.js";var p=t();e();var m=n();function h(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(f,{"data-slot":`sheet`,...n}),t[2]=n,t[3]=r),r}function g(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(l,{"data-slot":`sheet-portal`,...n}),t[2]=n,t[3]=r),r}function _(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0`,n),t[3]=n,t[4]=a);let s;return t[5]!==i||t[6]!==a?(s=(0,m.jsx)(o,{"data-slot":`sheet-overlay`,className:a,...i}),t[5]=i,t[6]=a,t[7]=s):s=t[7],s}function v(e){let t=(0,p.c)(17),n,o,s,l,d;t[0]===e?(n=t[1],o=t[2],s=t[3],l=t[4],d=t[5]):({className:o,children:n,side:l,showCloseButton:d,...s}=e,t[0]=e,t[1]=n,t[2]=o,t[3]=s,t[4]=l,t[5]=d);let f=l===void 0?`right`:l,h=d===void 0||d,v;t[6]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,m.jsx)(_,{}),t[6]=v):v=t[6];let y;t[7]===o?y=t[8]:(y=r(`fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10`,o),t[7]=o,t[8]=y);let b;t[9]===h?b=t[10]:(b=h&&(0,m.jsx)(c,{"data-slot":`sheet-close`,asChild:!0,children:(0,m.jsxs)(a,{variant:`ghost`,className:`absolute top-3 right-3`,size:`icon-sm`,children:[(0,m.jsx)(i,{}),(0,m.jsx)(`span`,{className:`sr-only`,children:`Schließen`})]})}),t[9]=h,t[10]=b);let x;return t[11]!==n||t[12]!==s||t[13]!==f||t[14]!==y||t[15]!==b?(x=(0,m.jsxs)(g,{children:[v,(0,m.jsxs)(u,{"data-slot":`sheet-content`,"data-side":f,className:y,...s,children:[n,b]})]}),t[11]=n,t[12]=s,t[13]=f,t[14]=y,t[15]=b,t[16]=x):x=t[16],x}function y(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`flex flex-col gap-0.5 p-4`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(`div`,{"data-slot":`sheet-header`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function b(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`font-heading text-base font-medium text-foreground`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(d,{"data-slot":`sheet-title`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function x(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`text-sm text-muted-foreground`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(s,{"data-slot":`sheet-description`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}export{b as a,y as i,v as n,x as r,h as t};
|
||||
import{i as e,n as t,r as n,t as r}from"./dist-D4CVSex9.js";import{Vt as i}from"./index-COh3_SSc.js";import{t as a}from"./button-D8rvET0Z.js";import{a as o,i as s,n as c,o as l,r as u,s as d,t as f}from"./dist-D9JSQbGO.js";var p=t();e();var m=n();function h(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(f,{"data-slot":`sheet`,...n}),t[2]=n,t[3]=r),r}function g(e){let t=(0,p.c)(4),n;t[0]===e?n=t[1]:({...n}=e,t[0]=e,t[1]=n);let r;return t[2]===n?r=t[3]:(r=(0,m.jsx)(l,{"data-slot":`sheet-portal`,...n}),t[2]=n,t[3]=r),r}function _(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`fixed inset-0 z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0`,n),t[3]=n,t[4]=a);let s;return t[5]!==i||t[6]!==a?(s=(0,m.jsx)(o,{"data-slot":`sheet-overlay`,className:a,...i}),t[5]=i,t[6]=a,t[7]=s):s=t[7],s}function v(e){let t=(0,p.c)(17),n,o,s,l,d;t[0]===e?(n=t[1],o=t[2],s=t[3],l=t[4],d=t[5]):({className:o,children:n,side:l,showCloseButton:d,...s}=e,t[0]=e,t[1]=n,t[2]=o,t[3]=s,t[4]=l,t[5]=d);let f=l===void 0?`right`:l,h=d===void 0||d,v;t[6]===Symbol.for(`react.memo_cache_sentinel`)?(v=(0,m.jsx)(_,{}),t[6]=v):v=t[6];let y;t[7]===o?y=t[8]:(y=r(`fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10`,o),t[7]=o,t[8]=y);let b;t[9]===h?b=t[10]:(b=h&&(0,m.jsx)(c,{"data-slot":`sheet-close`,asChild:!0,children:(0,m.jsxs)(a,{variant:`ghost`,className:`absolute top-3 right-3`,size:`icon-sm`,children:[(0,m.jsx)(i,{}),(0,m.jsx)(`span`,{className:`sr-only`,children:`Schließen`})]})}),t[9]=h,t[10]=b);let x;return t[11]!==n||t[12]!==s||t[13]!==f||t[14]!==y||t[15]!==b?(x=(0,m.jsxs)(g,{children:[v,(0,m.jsxs)(u,{"data-slot":`sheet-content`,"data-side":f,className:y,...s,children:[n,b]})]}),t[11]=n,t[12]=s,t[13]=f,t[14]=y,t[15]=b,t[16]=x):x=t[16],x}function y(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`flex flex-col gap-0.5 p-4`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(`div`,{"data-slot":`sheet-header`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function b(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`font-heading text-base font-medium text-foreground`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(d,{"data-slot":`sheet-title`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}function x(e){let t=(0,p.c)(8),n,i;t[0]===e?(n=t[1],i=t[2]):({className:n,...i}=e,t[0]=e,t[1]=n,t[2]=i);let a;t[3]===n?a=t[4]:(a=r(`text-sm text-muted-foreground`,n),t[3]=n,t[4]=a);let o;return t[5]!==i||t[6]!==a?(o=(0,m.jsx)(s,{"data-slot":`sheet-description`,className:a,...i}),t[5]=i,t[6]=a,t[7]=o):o=t[7],o}export{b as a,y as i,v as n,x as r,h as t};
|
||||
Vendored
+2
-2
@@ -9,11 +9,11 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
<title>Homelab Orchestrator</title>
|
||||
<script type="module" crossorigin src="/assets/index-C0aLfVi8.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-COh3_SSc.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/dist-D4CVSex9.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/useSelector-uUwrspuX.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/useMatch-CiyhmkMV.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BAuTgr4Q.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CXja6cgU.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>␍
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { render, screen, within } from "@testing-library/react"
|
||||
import { auftraegeText, groesse, zeitplanText } from "@/lib/speicher"
|
||||
import type { HomelabSpeicher } from "@/lib/typen"
|
||||
import { Speicher } from "./Speicher"
|
||||
|
||||
// Die Zahlen vom 25.09.2026: local-lvm zu 49 %, das NAS (QNAP per NFS) zu 39 %, alle 8 Geräte heute Nacht gesichert.
|
||||
const daten: HomelabSpeicher = {
|
||||
bericht: true,
|
||||
pools: [{ speicher: "local-lvm", belegt: 184_123_256_523, gesamt: 374_538_764_288, frei: 190_415_507_765,
|
||||
anteil: 0.4916, meta_anteil: 0.0209, aktiv: true, stufe: "ok" }],
|
||||
nas: [{ server: "192.168.178.62", ziel: "/mnt/qnap-backup", quelle: "192.168.178.62:/Backup", art: "nfs",
|
||||
eingebunden: true, zustand: "ok", erreichbar: true, belegt: 1_517_685_571_584, gesamt: 3_847_503_609_856,
|
||||
anteil: 0.3945, stufe: "ok", problem: null }],
|
||||
sicherungen: { jobs: [{ zeitplan: "02:30", speicher: "pbs-qnap", alle: true }], ziele: [], erfasst: 8, fehlend: [],
|
||||
zuletzt: 1_790_296_485, probleme: [], stufe: "ok" },
|
||||
empfehlungen: [
|
||||
{ art: "trim-vm", titel: "Arcane (Docker): 125 GB an den Pool zurückgeben", platz: 125_493_947_924,
|
||||
kurz: "125 GB bei Arcane (Docker) per TRIM (Discard einschalten)",
|
||||
text: "Im Pool belegt die VM 160,9 GB, genutzt sind darin 35,5 GB. Discard ist aus …" },
|
||||
{ art: "snapshot", titel: "Gitea: Snapshot mc2-20260924-192352", platz: null, kurz: "Snapshot bei Gitea",
|
||||
text: "Rückweg des Updates vom 24.09. 19:23. …" },
|
||||
],
|
||||
}
|
||||
|
||||
describe("Speicher", () => {
|
||||
it("zeigt Pool, NAS und Sicherungen mit Füllstand", () => {
|
||||
render(<Speicher daten={daten} />)
|
||||
expect(screen.getByRole("meter", { name: "Speicherpool local-lvm: 49 % belegt" })).toBeInTheDocument()
|
||||
expect(screen.getByText(/184 GB von 375 GB/)).toBeInTheDocument()
|
||||
expect(screen.getByText("190 GB frei")).toBeInTheDocument()
|
||||
expect(screen.getByText("NAS 192.168.178.62")).toBeInTheDocument()
|
||||
expect(screen.getByText(/1,5 TB von 3,8 TB · 2,3 TB frei/)).toBeInTheDocument()
|
||||
expect(screen.getByText("täglich 02:30 auf pbs-qnap")).toBeInTheDocument()
|
||||
expect(screen.getByText(/Alle 8 Geräte gesichert/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("klappt die Vorschläge erst auf, wenn der Pool knapp wird", () => {
|
||||
const { container, rerender } = render(<Speicher daten={daten} />)
|
||||
const klappe = container.querySelector("details")!
|
||||
expect(klappe.open).toBe(false)
|
||||
expect(within(klappe).getByText("2 Vorschläge · bis zu 125 GB")).toBeInTheDocument()
|
||||
expect(within(klappe).getByText("≈ 125 GB")).toBeInTheDocument()
|
||||
expect(within(klappe).getByText("Größe unbekannt")).toBeInTheDocument()
|
||||
rerender(<Speicher daten={{ ...daten, pools: [{ ...daten.pools[0], anteil: 0.91, stufe: "rot" }] }} />)
|
||||
expect(container.querySelector("details")!.open).toBe(true)
|
||||
expect(screen.getByText("fast voll")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("nennt Störungen des NAS und fehlende Sicherungen", () => {
|
||||
render(<Speicher daten={{
|
||||
...daten,
|
||||
nas: [{ ...daten.nas[0], erreichbar: false, stufe: "rot", problem: "Das NAS (192.168.178.62) antwortet nicht." }],
|
||||
sicherungen: { ...daten.sicherungen, stufe: "gelb", fehlend: [{ ziel: "ct-104", name: "Gitea", zuletzt: null }] },
|
||||
}} />)
|
||||
expect(screen.getByText("Das NAS (192.168.178.62) antwortet nicht.")).toBeInTheDocument()
|
||||
expect(screen.getByText("gestört")).toBeInTheDocument()
|
||||
expect(screen.getByText("Gitea: noch nie gesichert")).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Speicher-Texte", () => {
|
||||
it("rundet Größen und liest den Zeitplan", () => {
|
||||
expect(groesse(5_200_000_000)).toBe("5,2 GB")
|
||||
expect(groesse(184_123_256_523)).toBe("184 GB")
|
||||
expect(groesse(3_847_503_609_856)).toBe("3,8 TB")
|
||||
expect(zeitplanText("02:30")).toBe("täglich 02:30")
|
||||
expect(zeitplanText("sat 03:00")).toBe("Zeitplan „sat 03:00“")
|
||||
expect(auftraegeText({ ...daten.sicherungen, jobs: [] })).toBe("Kein Sicherungsauftrag in Proxmox")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { ChevronRight } from "lucide-react"
|
||||
import { cn } from "cn"
|
||||
import { Chip, type ChipArt } from "@/components/cockpit/Chip"
|
||||
import { auftraegeText, groesse, STUFE_TEXT } from "@/lib/speicher"
|
||||
import type { HomelabSpeicher, NasStand, SicherungsLage, SpeicherPool, SpeicherStufe } from "@/lib/typen"
|
||||
import { flugplanZeit } from "@/lib/zeit"
|
||||
|
||||
const CHIP: Record<SpeicherStufe | "keine", ChipArt> = { ok: "gruen", gelb: "bernstein", rot: "rot", keine: "grau" }
|
||||
const FARBE: Record<SpeicherStufe, string> = { ok: "var(--reihe-1)", gelb: "var(--bernstein)", rot: "var(--rot)" }
|
||||
const zeitAus = (sekunden: number) => flugplanZeit(new Date(sekunden * 1000).toISOString())
|
||||
|
||||
/** Füllstand mit Marken bei 80 % (gelb) und 90 % (rot, 10 % frei). */
|
||||
function Fuellstand({ anteil, stufe, name }: { anteil: number; stufe: SpeicherStufe; name: string }) {
|
||||
const prozent = Math.round(anteil * 100)
|
||||
return (
|
||||
<div
|
||||
role="meter"
|
||||
aria-label={`${name}: ${prozent} % belegt`}
|
||||
aria-valuenow={prozent}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
className="relative h-2 w-full overflow-hidden rounded-full bg-erhaben"
|
||||
>
|
||||
<span className="block h-full rounded-full" style={{ width: `${Math.min(100, Math.max(2, prozent))}%`, background: FARBE[stufe] }} />
|
||||
<span aria-hidden className="absolute inset-y-0 left-[80%] w-px bg-bernstein/70" />
|
||||
<span aria-hidden className="absolute inset-y-0 left-[90%] w-px bg-rot/80" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Kachel({ titel, unter, chip, chipText, children }: {
|
||||
titel: string
|
||||
unter?: string
|
||||
chip: ChipArt
|
||||
chipText: string
|
||||
children: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-3 rounded-xl border border-linie bg-erhaben/40 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="text-[15px] font-semibold break-words">{titel}</span>
|
||||
{unter && <span className="text-sm break-words text-text-3">{unter}</span>}
|
||||
</div>
|
||||
<Chip art={chip}>{chipText}</Chip>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PoolKachel({ p }: { p: SpeicherPool }) {
|
||||
const prozent = Math.round(p.anteil * 100)
|
||||
return (
|
||||
<Kachel titel={`Speicherpool ${p.speicher}`} unter="Platten der Container und VMs" chip={CHIP[p.stufe]} chipText={STUFE_TEXT[p.stufe]}>
|
||||
<Fuellstand anteil={Math.max(p.anteil, p.meta_anteil ?? 0)} stufe={p.stufe} name={`Speicherpool ${p.speicher}`} />
|
||||
<p className="ziffern text-sm text-text-2">
|
||||
{prozent} % · {groesse(p.belegt)} von {groesse(p.gesamt)} · <span className={cn(p.stufe !== "ok" && "text-foreground")}>{groesse(p.frei)} frei</span>
|
||||
</p>
|
||||
{p.meta_anteil !== null && (
|
||||
<p className="text-xs text-text-3">Metadaten {Math.round(p.meta_anteil * 100)} % belegt</p>
|
||||
)}
|
||||
</Kachel>
|
||||
)
|
||||
}
|
||||
|
||||
function NasKachel({ n }: { n: NasStand }) {
|
||||
const chipText = n.problem ? (n.stufe === "rot" ? "gestört" : "unklar") : STUFE_TEXT[n.stufe]
|
||||
return (
|
||||
<Kachel
|
||||
titel={`NAS ${n.server ?? ""}`.trim()}
|
||||
unter={`${n.art.toUpperCase()} · ${n.quelle ?? "?"} → ${n.ziel}`}
|
||||
chip={CHIP[n.stufe]}
|
||||
chipText={chipText}
|
||||
>
|
||||
{n.problem ? (
|
||||
<p className={cn("text-sm", n.stufe === "rot" ? "text-rot-text" : "text-bernstein")}>{n.problem}</p>
|
||||
) : (
|
||||
n.anteil !== null && (
|
||||
<>
|
||||
<Fuellstand anteil={n.anteil} stufe={n.stufe} name={`NAS ${n.server ?? ""}`} />
|
||||
<p className="ziffern text-sm text-text-2">
|
||||
{Math.round(n.anteil * 100)} % · {groesse(n.belegt)} von {groesse(n.gesamt)} · {groesse((n.gesamt ?? 0) - (n.belegt ?? 0))} frei
|
||||
</p>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
<p className="text-xs text-text-3">
|
||||
{n.erreichbar === false ? "antwortet nicht" : "antwortet"} · {n.eingebunden ? "auf dem Proxmox-Host eingebunden" : "nicht eingebunden"}
|
||||
</p>
|
||||
</Kachel>
|
||||
)
|
||||
}
|
||||
|
||||
function SicherungsKachel({ s }: { s: SicherungsLage }) {
|
||||
const chipText = { ok: "aktuell", gelb: "Lücke", rot: "gestört", keine: "kein Auftrag" }[s.stufe]
|
||||
return (
|
||||
<Kachel titel="Sicherungen" unter={auftraegeText(s)} chip={CHIP[s.stufe]} chipText={chipText}>
|
||||
{s.probleme.map((p) => (
|
||||
<p key={p} className="text-sm text-rot-text">{p}</p>
|
||||
))}
|
||||
{s.stufe === "ok" && (
|
||||
<p className="text-sm text-text-2">
|
||||
Alle {s.erfasst} Geräte gesichert{s.zuletzt ? <>, zuletzt <span className="ziffern">{zeitAus(s.zuletzt)}</span></> : null}.
|
||||
</p>
|
||||
)}
|
||||
{s.fehlend.length > 0 && (
|
||||
<ul className="flex flex-col gap-1 text-sm">
|
||||
{s.fehlend.map((f) => (
|
||||
<li key={f.ziel} className="text-bernstein">
|
||||
{f.name}: {f.zuletzt ? <>zuletzt <span className="ziffern">{zeitAus(f.zuletzt)}</span></> : "noch nie gesichert"}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Kachel>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Speicher und Sicherungen des Homelabs: Speicherpool, NAS und die nächtlichen Sicherungen, darunter was im Pool
|
||||
* Platz bringt. Die Vorschläge sind aufgeklappt, sobald der Pool knapp wird (ab 80 %).
|
||||
*/
|
||||
export function Speicher({ daten }: { daten: HomelabSpeicher }) {
|
||||
const knapp = daten.pools.some((p) => p.stufe !== "ok")
|
||||
const summe = daten.empfehlungen.reduce((n, e) => n + (e.platz ?? 0), 0)
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
{daten.pools.map((p) => <PoolKachel key={p.speicher} p={p} />)}
|
||||
{daten.nas.length > 0 ? (
|
||||
daten.nas.map((n) => <NasKachel key={n.ziel} n={n} />)
|
||||
) : (
|
||||
<Kachel titel="NAS" chip="grau" chipText="keins">
|
||||
<p className="text-sm text-text-3">Auf dem Proxmox-Host ist kein Netzlaufwerk eingebunden.</p>
|
||||
</Kachel>
|
||||
)}
|
||||
<SicherungsKachel s={daten.sicherungen} />
|
||||
</div>
|
||||
{daten.empfehlungen.length > 0 && (
|
||||
<details open={knapp} className="group rounded-xl border border-linie bg-erhaben/40 px-4 open:pb-3">
|
||||
<summary className="flex min-h-12 cursor-pointer list-none items-center justify-between gap-3 [&::-webkit-details-marker]:hidden">
|
||||
<span className="flex items-center gap-2 text-[15px] font-medium">
|
||||
<ChevronRight className="size-4 text-text-3 transition-transform group-open:rotate-90" aria-hidden />
|
||||
Was im Speicherpool Platz bringt
|
||||
</span>
|
||||
<span className="ziffern text-sm text-text-2">
|
||||
{daten.empfehlungen.length} {daten.empfehlungen.length === 1 ? "Vorschlag" : "Vorschläge"}
|
||||
{summe > 0 && ` · bis zu ${groesse(summe)}`}
|
||||
</span>
|
||||
</summary>
|
||||
<ol className="flex flex-col">
|
||||
{daten.empfehlungen.map((e) => (
|
||||
<li key={e.titel} className="flex flex-col gap-1 border-t border-linie py-3 first:border-t-0 first:pt-1">
|
||||
<div className="flex flex-wrap items-baseline justify-between gap-x-3 gap-y-1">
|
||||
<span className="text-[15px] font-medium break-words">{e.titel}</span>
|
||||
<span className="ziffern text-sm text-text-2">{e.platz ? `≈ ${groesse(e.platz)}` : "Größe unbekannt"}</span>
|
||||
</div>
|
||||
<p className="text-sm break-words text-text-2">{e.text}</p>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { melden } from "./meldungen"
|
||||
import { stromVerbunden } from "./strom"
|
||||
import type {
|
||||
AktionsErgebnis, Altrest, AufraeumKandidat, BoxMesswerte, EinstellungenStand, Hinweis, HomelabEinstellungen,
|
||||
HomelabLauf, HomelabMesswerte, HomelabStand, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
|
||||
HomelabLauf, HomelabMesswerte, HomelabSpeicher, HomelabStand, Instanz, InstanzMitStand, Job, Modell, Nutzung, PartnerStatus,
|
||||
ProtokollEintrag, Radar, SammelPlanSchritt, Sammellauf, Sicherung, Start, TelegramTest, UpdateDetails, ZielAktion,
|
||||
ZielStand, Zeitraum,
|
||||
} from "./typen"
|
||||
@@ -423,6 +423,16 @@ export const useHomelabProtokoll = (aktiv: boolean, berichte: boolean) =>
|
||||
retry: false,
|
||||
})
|
||||
|
||||
/** Speicherpool, NAS und Sicherungen samt Empfehlungen — der Bericht dahinter kommt alle 10 Minuten. */
|
||||
export const useHomelabSpeicher = (aktiv: boolean) =>
|
||||
useQuery({
|
||||
queryKey: ["homelab-speicher"],
|
||||
queryFn: () => api<HomelabSpeicher>("/api/homelab/speicher"),
|
||||
enabled: aktiv,
|
||||
refetchInterval: takt(60_000),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
export const useHomelabEinstellungen = (aktiv: boolean) =>
|
||||
useQuery({
|
||||
queryKey: ["homelab-einstellungen"],
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { SpeicherStufe, SicherungsLage } from "@/lib/typen"
|
||||
|
||||
// Speicher und Sicherungen des Homelabs (seit 25.09.2026): die kleinen Texte der Karte im Homelab-Cockpit.
|
||||
|
||||
const komma = (n: number, stellen = 1) => n.toFixed(stellen).replace(".", ",")
|
||||
|
||||
/** „184 GB“, „5,2 GB“, „2,3 TB“ — ab 10 GB ohne Nachkommastelle. */
|
||||
export function groesse(bytes: number | null | undefined): string {
|
||||
if (bytes === null || bytes === undefined) return "–"
|
||||
if (bytes >= 1e12) return `${komma(bytes / 1e12)} TB`
|
||||
return bytes >= 10e9 ? `${Math.round(bytes / 1e9)} GB` : `${komma(bytes / 1e9)} GB`
|
||||
}
|
||||
|
||||
/** Der Zeitplan eines Sicherungsauftrags in Worten: „02:30“ heißt bei Proxmox täglich um 02:30. */
|
||||
export function zeitplanText(zeitplan: string | null): string {
|
||||
if (!zeitplan) return "ohne Zeitplan"
|
||||
return /^\s*\d{1,2}:\d{2}\s*$/.test(zeitplan) ? `täglich ${zeitplan.trim()}` : `Zeitplan „${zeitplan}“`
|
||||
}
|
||||
|
||||
/** „täglich 02:30 auf pbs-qnap“ — alle Aufträge in einer Zeile. */
|
||||
export function auftraegeText(s: SicherungsLage): string {
|
||||
if (s.jobs.length === 0) return "Kein Sicherungsauftrag in Proxmox"
|
||||
return s.jobs.map((j) => `${zeitplanText(j.zeitplan)} auf ${j.speicher ?? "?"}`).join(", ")
|
||||
}
|
||||
|
||||
export const STUFE_TEXT: Record<SpeicherStufe, string> = { ok: "in Ordnung", gelb: "wird knapp", rot: "fast voll" }
|
||||
@@ -461,3 +461,59 @@ export interface HomelabEinstellungen {
|
||||
ausfuehrer: { verbunden: boolean; zuletzt: number | null; nur_lesen: boolean; sicherung_speicher: string | null }
|
||||
}
|
||||
|
||||
|
||||
// --- Speicher und Sicherungen des Homelabs (seit 25.09.2026) -------------------------------------------------
|
||||
|
||||
export type SpeicherStufe = "ok" | "gelb" | "rot"
|
||||
|
||||
export interface SpeicherPool {
|
||||
speicher: string
|
||||
belegt: number
|
||||
gesamt: number
|
||||
frei: number
|
||||
anteil: number
|
||||
meta_anteil: number | null
|
||||
aktiv: boolean
|
||||
stufe: SpeicherStufe
|
||||
}
|
||||
|
||||
export interface NasStand {
|
||||
server: string | null
|
||||
ziel: string
|
||||
quelle: string | null
|
||||
art: string
|
||||
eingebunden: boolean
|
||||
zustand: "ok" | "haengt" | "fehler" | null
|
||||
erreichbar: boolean | null
|
||||
belegt: number | null
|
||||
gesamt: number | null
|
||||
anteil: number | null
|
||||
stufe: SpeicherStufe
|
||||
problem: string | null
|
||||
}
|
||||
|
||||
export interface SicherungsLage {
|
||||
jobs: { zeitplan: string | null; speicher: string | null; alle: boolean }[]
|
||||
ziele: { speicher: string; art: string | null; aktiv: boolean | null; belegt: number | null; gesamt: number | null }[]
|
||||
erfasst: number
|
||||
fehlend: { ziel: string; name: string; zuletzt: number | null }[]
|
||||
zuletzt: number | null
|
||||
probleme: string[]
|
||||
stufe: SpeicherStufe | "keine"
|
||||
}
|
||||
|
||||
export interface SpeicherEmpfehlung {
|
||||
art: "trim" | "trim-vm" | "ungenutzt" | "verwaist" | "snapshot"
|
||||
titel: string
|
||||
text: string
|
||||
kurz: string
|
||||
platz: number | null
|
||||
}
|
||||
|
||||
export interface HomelabSpeicher {
|
||||
bericht: boolean
|
||||
pools: SpeicherPool[]
|
||||
nas: NasStand[]
|
||||
sicherungen: SicherungsLage
|
||||
empfehlungen: SpeicherEmpfehlung[]
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ import { Chip, type ChipArt, Zustandsfeld } from "@/components/cockpit/Chip"
|
||||
import { Panel } from "@/components/cockpit/Panel"
|
||||
import { Gesundheit } from "@/components/homelab/Gesundheit"
|
||||
import { Lagebild } from "@/components/homelab/Lagebild"
|
||||
import { Speicher } from "@/components/homelab/Speicher"
|
||||
import { AlleKnopf, SammellaufFortschritt } from "@/components/homelab/Sammellauf"
|
||||
import { UpdateTabelle } from "@/components/homelab/UpdateTabelle"
|
||||
import {
|
||||
useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useInstanz, usePartner, useSammellauf,
|
||||
useHomelab, useHomelabHinweise, useHomelabLaeufe, useHomelabMesswerte, useHomelabSpeicher, useInstanz, usePartner,
|
||||
useSammellauf,
|
||||
useZielAktion,
|
||||
} from "@/lib/abfragen"
|
||||
import { lage, lampeFuer, offeneUpdates } from "@/lib/homelab"
|
||||
@@ -187,6 +189,7 @@ export function HomelabSeite() {
|
||||
const navigate = useNavigate()
|
||||
const { ersatz, stand, laufend, hinweise } = useHomelabBereich()
|
||||
const mess = useHomelabMesswerte("1h", !!stand)
|
||||
const speicher = useHomelabSpeicher(!!stand)
|
||||
if (ersatz || !stand) return ersatz
|
||||
|
||||
const berichtZeit = stand.bericht ? zeitAus(stand.bericht.empfangen) : null
|
||||
@@ -220,6 +223,11 @@ export function HomelabSeite() {
|
||||
<p className="text-xs text-text-3">CPU und Speicher erscheinen, sobald der Ausführer Messwerte schickt.</p>
|
||||
)}
|
||||
</Panel>
|
||||
{speicher.data?.bericht && (
|
||||
<Panel titel="Speicher und Sicherungen" id="speicher">
|
||||
<Speicher daten={speicher.data} />
|
||||
</Panel>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user