mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 10:52:22 +00:00
Merge develop into local application icon discovery
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import { useIsPbs } from "../lib/use-platform"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"
|
||||
import { Input } from "./ui/input"
|
||||
import {
|
||||
@@ -283,8 +284,12 @@ function computeVisualRange(
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
|
||||
// Guests and PVE storages do not exist on a Proxmox Backup Server.
|
||||
const PVE_ONLY_SECTIONS = new Set(["lxc_rootfs", "lxc_mount", "pve_storage"])
|
||||
|
||||
export function HealthThresholds() {
|
||||
const t = useT()
|
||||
const isPbs = useIsPbs()
|
||||
const tFallback = (key: string, fallback: string) => {
|
||||
const translated = t(key)
|
||||
return translated === key ? fallback : translated
|
||||
@@ -773,7 +778,7 @@ export function HealthThresholds() {
|
||||
Mobile (<md) stays single-column as today.
|
||||
*/}
|
||||
<div className="columns-1 md:columns-2 2xl:columns-3 gap-4 space-y-4 [&>*]:break-inside-avoid">
|
||||
{SECTIONS.map((section) => {
|
||||
{SECTIONS.filter((section) => !isPbs || !PVE_ONLY_SECTIONS.has(section.id)).map((section) => {
|
||||
const Icon = section.icon
|
||||
return (
|
||||
<div key={section.id} className="rounded-md border border-border/50 px-3 py-2">
|
||||
|
||||
@@ -132,7 +132,7 @@ interface NativeDiscovery {
|
||||
|
||||
interface DetectedApp {
|
||||
discovery_id?: string
|
||||
source?: "native_metadata"
|
||||
source?: "native_metadata" | "docker_metadata"
|
||||
installed_version?: string | null
|
||||
evidence?: Array<{ kind: string; value: string }>
|
||||
slug: string
|
||||
@@ -511,6 +511,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
|
||||
const [suggestions, setSuggestions] = useState<Suggestions | null>(seed?.suggestions ?? null)
|
||||
const [nativeDiscovery, setNativeDiscovery] = useState<NativeDiscovery | null>(null)
|
||||
const nativeRequest = useRef(0)
|
||||
const [dockerDiscovery, setDockerDiscovery] = useState<NativeDiscovery | null>(null)
|
||||
const dockerRequest = useRef(0)
|
||||
const targetRef = useRef(vmid)
|
||||
targetRef.current = vmid
|
||||
const targetGeneration = useRef(0)
|
||||
@@ -521,6 +523,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
|
||||
}
|
||||
useEffect(() => {
|
||||
setNativeDiscovery(null)
|
||||
setDockerDiscovery(null)
|
||||
setEditing(null)
|
||||
setDetectionNotice(null)
|
||||
setSearchingApplications(false)
|
||||
@@ -538,6 +541,17 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
|
||||
}).catch(() => { /* cached metadata is optional; never initiate a scan */ })
|
||||
return () => { cancelled = true }
|
||||
}, [vmid, managed, cacheRevision])
|
||||
useEffect(() => {
|
||||
const generation = targetGeneration.current
|
||||
const request = dockerRequest.current
|
||||
let cancelled = false
|
||||
if (managed) return
|
||||
fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/docker-discovery`).then(result => {
|
||||
if (!cancelled && targetRef.current === vmid && targetGeneration.current === generation
|
||||
&& dockerRequest.current === request) setDockerDiscovery(result)
|
||||
}).catch(() => { /* optional cached result; never initiate a scan */ })
|
||||
return () => { cancelled = true }
|
||||
}, [vmid, managed])
|
||||
const [adguardSetupAvailable, setAdguardSetupAvailable] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchingApplications, setSearchingApplications] = useState(false)
|
||||
@@ -753,7 +767,8 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
|
||||
// (`extras[]` from the backend). Both use the same DetectedApp
|
||||
// shape so the empty state renders them uniformly.
|
||||
const detectedList: DetectedApp[] = useMemo(() => {
|
||||
const out: DetectedApp[] = [...(nativeDiscovery?.vmid === vmid ? nativeDiscovery.candidates : [])]
|
||||
const out: DetectedApp[] = [...(nativeDiscovery?.vmid === vmid ? nativeDiscovery.candidates : []),
|
||||
...(dockerDiscovery?.vmid === vmid ? dockerDiscovery.candidates || [] : [])]
|
||||
if (!suggestions) return out
|
||||
// A ProxMenux OCI install has no helper slug — nothing was installed by a
|
||||
// community script — so its template id identifies it instead.
|
||||
@@ -779,7 +794,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
|
||||
}
|
||||
}
|
||||
return out
|
||||
}, [suggestions, nativeDiscovery, vmid])
|
||||
}, [suggestions, nativeDiscovery, dockerDiscovery, vmid])
|
||||
|
||||
// Registered slugs — used to filter the detection list down to
|
||||
// what the user hasn't already registered on this CT.
|
||||
@@ -852,6 +867,7 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
|
||||
const searchInstalledApplications = async () => {
|
||||
const generation = targetGeneration.current
|
||||
const request = ++nativeRequest.current
|
||||
++dockerRequest.current
|
||||
const isCurrent = () => targetRef.current === vmid && targetGeneration.current === generation
|
||||
&& nativeRequest.current === request
|
||||
const before = new Set([...visibleDetected, ...visibleWorkloads].map((item) => item.slug))
|
||||
@@ -871,6 +887,11 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
|
||||
? "vmLxc.appEditor.nativeScanStopped" : "vmLxc.appEditor.nativeScanFailed") })
|
||||
return
|
||||
}
|
||||
let docker = await fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/docker-discovery`, {
|
||||
method: "POST",
|
||||
})
|
||||
if (!isCurrent()) return
|
||||
setDockerDiscovery(docker)
|
||||
const result: Suggestions = await fetchApi(`/api/vms/${vmid}/apps/suggestions`, {
|
||||
method: "POST",
|
||||
})
|
||||
@@ -879,6 +900,9 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
|
||||
native = await fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/native-discovery`)
|
||||
if (!isCurrent()) return
|
||||
setNativeDiscovery(native)
|
||||
docker = await fetchApi<NativeDiscovery>(`/api/vms/${vmid}/apps/docker-discovery`)
|
||||
if (!isCurrent()) return
|
||||
setDockerDiscovery(docker)
|
||||
setSuggestions(result)
|
||||
if (sidecar) setLxcAppsCached(vmid, sidecar, result)
|
||||
|
||||
@@ -887,11 +911,16 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
|
||||
for (const item of result.extras || []) detected.add(item.slug)
|
||||
for (const item of result.docker_workloads || []) detected.add(item.slug)
|
||||
for (const item of native.candidates) detected.add(item.slug)
|
||||
for (const item of docker.candidates || []) detected.add(item.slug)
|
||||
const visible = [...detected].filter(
|
||||
(slug) => !registeredSlugs.has(slug) && !dismissedSlugs.has(slug),
|
||||
)
|
||||
const newCount = visible.filter((slug) => !before.has(slug)).length
|
||||
if (native.status === "partial") {
|
||||
if (docker.status === "error" || docker.status === "busy") {
|
||||
setDetectionNotice({ found: visible.length > 0, text: t("vmLxc.appEditor.dockerScanFailed") })
|
||||
} else if (docker.status === "partial" || docker.stale) {
|
||||
setDetectionNotice({ found: visible.length > 0, text: t("vmLxc.appEditor.dockerScanPartial") })
|
||||
} else if (native.status === "partial") {
|
||||
setDetectionNotice({ found: visible.length > 0, text: t("vmLxc.appEditor.nativeScanPartial") })
|
||||
} else if (newCount === 1) {
|
||||
setDetectionNotice({ found: true, text: t("vmLxc.appEditor.oneNewApplicationDetected") })
|
||||
@@ -2649,22 +2678,29 @@ export function LxcAppPanel({ vmid, ctIp, onChange, managed, initialData, oci, c
|
||||
? t("vmLxc.appEditor.versionDetected", { version: d.tracking_suggestion.detected_version || "" })
|
||||
: t("vmLxc.appEditor.detectedInContainer")}
|
||||
</div>
|
||||
{d.source === "native_metadata" && (
|
||||
{(d.source === "native_metadata" || d.source === "docker_metadata") && (
|
||||
<div className="text-xs text-muted-foreground mt-1 break-all">
|
||||
<div>{d.installed_version
|
||||
? t("vmLxc.appEditor.versionDetected", { version: d.installed_version })
|
||||
: t("vmLxc.appEditor.nativeVersionUnknown")}</div>
|
||||
{(nativeDiscovery?.stale || nativeDiscovery?.status === "partial") && (
|
||||
{(d.source === "docker_metadata"
|
||||
? dockerDiscovery?.stale || dockerDiscovery?.status === "partial"
|
||||
: nativeDiscovery?.stale || nativeDiscovery?.status === "partial") && (
|
||||
<div className="text-amber-400">{t("vmLxc.appEditor.nativeEvidenceStale")}</div>
|
||||
)}
|
||||
{d.evidence?.map((e, index) => <div key={index}>
|
||||
{t(e.kind === "tcp_listener" ? "vmLxc.appEditor.nativeEvidencePort"
|
||||
{t(e.kind === "docker_container" ? "vmLxc.appEditor.dockerEvidenceContainer"
|
||||
: e.kind === "docker_image" ? "vmLxc.appEditor.dockerEvidenceImage"
|
||||
: e.kind === "compose_project" ? "vmLxc.appEditor.dockerEvidenceProject"
|
||||
: e.kind === "tcp_listener" ? (d.source === "docker_metadata"
|
||||
? "vmLxc.appEditor.dockerEvidencePort" : "vmLxc.appEditor.nativeEvidencePort")
|
||||
: e.kind === "installation_file" ? "vmLxc.appEditor.nativeEvidenceFile"
|
||||
: e.kind === "process_executable" ? "vmLxc.appEditor.nativeEvidenceProcess"
|
||||
: e.kind === "working_directory" ? "vmLxc.appEditor.nativeEvidenceDirectory"
|
||||
: "vmLxc.appEditor.nativeEvidenceService")}: {e.value}
|
||||
</div>)}
|
||||
<div>{t("vmLxc.appEditor.nativeRegisterOnly")}</div>
|
||||
<div>{t(d.source === "docker_metadata"
|
||||
? "vmLxc.appEditor.dockerEndpointReview" : "vmLxc.appEditor.nativeRegisterOnly")}</div>
|
||||
</div>
|
||||
)}
|
||||
{d.tracking_suggestion?.detector_source === "legacy_fallback" && (
|
||||
|
||||
@@ -22,9 +22,11 @@ interface MonitorLink {
|
||||
product: "pve" | "pbs"
|
||||
group: "cluster" | "standalone" | "pbs"
|
||||
url: string | null
|
||||
status: "online" | "offline" | "unknown"
|
||||
status: "online" | "offline" | "unknown" | "local"
|
||||
native_url?: string | null
|
||||
manual: boolean
|
||||
// A PBS installed on this same host.
|
||||
local?: boolean
|
||||
hidden: boolean
|
||||
custom_url: boolean
|
||||
}
|
||||
@@ -48,6 +50,15 @@ function bareOrigin(value: unknown): string | null {
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
// localhost names the computer that opens the address, never another node.
|
||||
function isLoopback(url: string): boolean {
|
||||
try {
|
||||
const host = new URL(url).hostname.replace(/^\[|\]$/g, "").toLowerCase()
|
||||
return host === "localhost" || host.endsWith(".localhost") || /^127\./.test(host)
|
||||
|| host === "::1" || /^::ffff:127\./.test(host) || /^::ffff:7f/.test(host)
|
||||
} catch { return false }
|
||||
}
|
||||
|
||||
// The Monitors the source already links, carried in the jump so this one can add them too.
|
||||
function readCarried(raw: string | null): Carried[] {
|
||||
if (!raw) return []
|
||||
@@ -58,7 +69,7 @@ function readCarried(raw: string | null): Carried[] {
|
||||
const out: Carried[] = []
|
||||
for (const row of rows.slice(0, MAX_CARRIED)) {
|
||||
const url = bareOrigin(row?.u)
|
||||
if (!url || url === window.location.origin || seen.has(url)) continue
|
||||
if (!url || url === window.location.origin || isLoopback(url) || seen.has(url)) continue
|
||||
seen.add(url)
|
||||
out.push({ url, name: String(row?.n || new URL(url).hostname).slice(0, 64), product: row?.p === "pbs" ? "pbs" : "pve" })
|
||||
}
|
||||
@@ -102,6 +113,7 @@ function takeOrigin(): (Origin & { fresh?: boolean }) | null {
|
||||
|
||||
// Where a link leads: its Monitor, or the native PBS interface when no Monitor answers there.
|
||||
function destination(link: MonitorLink): { href: string; native: boolean } | null {
|
||||
if (link.local && !link.url) return { href: `https://${window.location.hostname}:8007`, native: true }
|
||||
if (link.product === "pbs" && link.status !== "online" && link.native_url) return { href: link.native_url, native: true }
|
||||
return link.url ? { href: link.url, native: false } : null
|
||||
}
|
||||
@@ -127,7 +139,7 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
|
||||
const originKnown = !!origin && known(origin.url)
|
||||
// The source Monitor and the Monitors it links that this one does not know yet.
|
||||
const candidates: Carried[] = origin && data
|
||||
? [origin, ...(origin.links || [])].filter(row => !known(row.url))
|
||||
? [origin, ...(origin.links || [])].filter(row => !known(row.url) && !isLoopback(row.url))
|
||||
: []
|
||||
const declined = (url: string) => {
|
||||
try { return (JSON.parse(sessionStorage.getItem(DECLINED_KEY) || "[]") as string[]).includes(url) } catch { return false }
|
||||
@@ -147,7 +159,8 @@ export function MonitorSwitcher({ nodeName, compact = false }: { nodeName: strin
|
||||
const withOrigin = (href: string) => {
|
||||
const target = bareOrigin(href.replace(/\/+$/, ""))
|
||||
const carried = (data?.links || [])
|
||||
.filter(link => link.url && link.status === "online" && !destination(link)?.native && link.url !== target)
|
||||
.filter(link => link.url && link.status === "online" && !destination(link)?.native && link.url !== target
|
||||
&& !isLoopback(link.url))
|
||||
.map(link => ({ url: link.url as string, name: link.name, product: link.product }))
|
||||
const params = new URLSearchParams({ pmx_from: window.location.origin, pmx_name: nodeName, pmx_product: data?.self.product === "pbs" ? "pbs" : "pve" })
|
||||
if (carried.length > 0) params.set("pmx_links", writeCarried(carried))
|
||||
@@ -359,7 +372,12 @@ function ManageLinksDialog({ open, onOpenChange }: { open: boolean; onOpenChange
|
||||
<Trash2 className="h-4 w-4 text-red-500" />
|
||||
</Button>}
|
||||
</div>
|
||||
{isEditing
|
||||
{link.local && !link.custom_url
|
||||
? <div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-foreground">{t("monitorLinks.pbsWebInterface")}</span>
|
||||
<span className="shrink-0 rounded border border-border px-1.5 py-0.5 text-[11px] text-muted-foreground">{t("monitorLinks.thisNode")}</span>
|
||||
</div>
|
||||
: isEditing
|
||||
? <div className="flex gap-2">
|
||||
<Input value={draft} disabled={busy} autoFocus aria-label={t("monitorLinks.address")}
|
||||
placeholder={link.manual ? "" : `${t("monitorLinks.autoAddress")}${shown ? `: ${shown}` : ""}`}
|
||||
|
||||
@@ -17,7 +17,8 @@ import { fetchApi } from "../lib/api-config"
|
||||
import { useT } from "../lib/i18n/provider"
|
||||
|
||||
interface Connection { id: string; name: string; url: string; token_id: string; fingerprint: string; has_token: boolean }
|
||||
interface Connections { local_available: boolean; servers: Connection[] }
|
||||
interface Detected { name: string; url: string; fingerprint: string }
|
||||
interface Connections { local_available: boolean; servers: Connection[]; detected?: Detected[] }
|
||||
interface Task { upid: string; worker_type?: string; worker_id?: string; starttime?: number; endtime?: number; status?: string }
|
||||
interface Job { id: string; store?: string; schedule?: string; "last-run-state"?: string }
|
||||
interface PBSStatus {
|
||||
@@ -122,7 +123,7 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
|
||||
<Select value={selected || undefined} onValueChange={setSelected}>
|
||||
<SelectTrigger aria-label={t("pbs.server")} className="w-full sm:ml-auto sm:w-56"><SelectValue placeholder={t("pbs.chooseServer")} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{connections?.local_available && <SelectItem value="local">{t("pbs.local")}</SelectItem>}
|
||||
{connections?.local_available && <SelectItem value="local">{t("pbs.thisNode")}</SelectItem>}
|
||||
{connections?.servers.map(row => <SelectItem key={row.id} value={row.id}>{row.name}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -137,6 +138,22 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
|
||||
{String((connectionError || statusError)?.message || t("pbs.loadFailed"))}</p>}
|
||||
{isLoading && <p role="status" className="text-sm text-muted-foreground">{t("pbs.loading")}</p>}
|
||||
{!selected && !connectionError && connections && <p className="text-sm text-muted-foreground">{t("pbs.noServer")}</p>}
|
||||
{!localOnly && !manage && (connections?.detected?.length || 0) > 0 && <Card className="bg-card border-border">
|
||||
<CardHeader><CardTitle className="flex items-center text-foreground"><DatabaseBackup className="mr-2 h-5 w-5" />{t("pbs.detectedTitle")}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-sm text-muted-foreground">{t("pbs.detectedHelp")}</p>
|
||||
{connections?.detected?.map(row => <div key={row.url} className="flex flex-wrap items-center gap-3 rounded-lg border border-border px-3 py-2">
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-foreground">{row.name}</span>
|
||||
<span className="block break-all font-mono text-xs text-muted-foreground">{row.url}</span>
|
||||
</span>
|
||||
<Button size="sm" className="bg-blue-600 text-white hover:bg-blue-700" onClick={() => {
|
||||
setForm({ ...emptyForm, name: row.name, url: row.url, fingerprint: row.fingerprint })
|
||||
setError(""); setMessage(""); setManage(true)
|
||||
}}>{t("pbs.connect")}</Button>
|
||||
</div>)}
|
||||
</CardContent>
|
||||
</Card>}
|
||||
{manage && !localOnly && <Card>
|
||||
<CardHeader><CardTitle>{t("pbs.manage")}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
@@ -171,9 +188,11 @@ export function PBSPanel({ localOnly = false }: { localOnly?: boolean } = {}) {
|
||||
</Card>}
|
||||
{data && <>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.sampled")} {new Date(data.fetched_at * 1000).toLocaleTimeString()}</span>
|
||||
{Object.entries(data.services).map(([service, state]) =>
|
||||
<Badge key={service} variant="outline" className={state === "active" ? badgeTone.ok : badgeTone.error}>{service}: {state}</Badge>)}
|
||||
<span>{data.name} · PBS {data.version || "—"} · {t("pbs.updatedAt", { time: new Date(data.fetched_at * 1000).toLocaleTimeString() })}</span>
|
||||
{Object.keys(data.services).length > 0 && Object.values(data.services).every(state => state === "active")
|
||||
? <Badge variant="outline" className={badgeTone.ok}>{t("pbs.servicesActive")}</Badge>
|
||||
: Object.entries(data.services).filter(([, state]) => state !== "active").map(([service, state]) =>
|
||||
<Badge key={service} variant="outline" className={badgeTone.error}>{service}: {state}</Badge>)}
|
||||
</div>
|
||||
{Object.entries(data.errors).map(([section, detail]) => <p key={section} role="alert" className="text-sm text-amber-500">{t(`pbs.${section}`)}: {detail}</p>)}
|
||||
|
||||
|
||||
@@ -843,6 +843,7 @@ function MonitorDashboard({ product }: { product: Product }) {
|
||||
{isPbs && (
|
||||
<TabsContent value="datastores" className="space-y-4 md:space-y-6 mt-0">
|
||||
<PBSPanel key={`datastores-${componentKey}`} localOnly />
|
||||
<StorageOverview key={`datastore-disks-${componentKey}`} disksOnly />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1878,7 +1878,7 @@ export function Settings() {
|
||||
the matching toggle in NotificationSettings is hidden (the
|
||||
preference is preserved in the DB and reappears when detection
|
||||
is re-enabled). */}
|
||||
<LxcUpdateDetection />
|
||||
{!isPbs && <LxcUpdateDetection />}
|
||||
|
||||
{!isPbs && <>
|
||||
{/* GitHub API — optional authentication for app upstream checks. */}
|
||||
|
||||
@@ -231,7 +231,8 @@ function translateAtaError(raw: string, t: (key: string) => string): string | nu
|
||||
return out.length ? out.join('; ') : null
|
||||
}
|
||||
|
||||
export function StorageOverview() {
|
||||
// `disksOnly` is the PBS Monitor: its disks and pools, without Proxmox VE storages.
|
||||
export function StorageOverview({ disksOnly = false }: { disksOnly?: boolean } = {}) {
|
||||
const t = useT()
|
||||
|
||||
// User-configurable disk temperature thresholds (Settings → Health
|
||||
@@ -284,7 +285,7 @@ export function StorageOverview() {
|
||||
try {
|
||||
const [data, proxmoxData, mountsData] = await Promise.all([
|
||||
fetchApi<StorageData>("/api/storage"),
|
||||
fetchApi<ProxmoxStorageData>("/api/proxmox-storage"),
|
||||
disksOnly ? Promise.resolve(null) : fetchApi<ProxmoxStorageData>("/api/proxmox-storage"),
|
||||
// Sprint 13 — host-level NFS/CIFS/SMB mounts. Wrapped in catch
|
||||
// so a failure here doesn't blank the whole storage tab.
|
||||
fetchApi<RemoteMountsData>("/api/mounts").catch(() => ({ mounts: [], available: false } as RemoteMountsData)),
|
||||
@@ -1134,7 +1135,7 @@ export function StorageOverview() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Storage Summary */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 xl:gap-6">
|
||||
{!disksOnly && <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 xl:gap-6">
|
||||
{/* ── Total Storage (preview restyle: headline + stacked bar Local·Remote·Free) ── */}
|
||||
{(() => {
|
||||
const totalGB = (totalLocalCapacity || 0) + (totalRemoteCapacity || 0)
|
||||
@@ -1355,9 +1356,9 @@ export function StorageOverview() {
|
||||
</Card>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{proxmoxStorage && proxmoxStorage.storage && proxmoxStorage.storage.length > 0 && (
|
||||
{!disksOnly && proxmoxStorage && proxmoxStorage.storage && proxmoxStorage.storage.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
|
||||
Reference in New Issue
Block a user