feat: PBS Datastores — Backup-Anzahl, neues Layout mit GC/Verify/Prune Buttons
This commit is contained in:
parent
a036849565
commit
67651a5aeb
@ -21,7 +21,7 @@ type PBSDatastore struct {
|
|||||||
Total int64 `json:"total"`
|
Total int64 `json:"total"`
|
||||||
Used int64 `json:"used"`
|
Used int64 `json:"used"`
|
||||||
Available int64 `json:"available"`
|
Available int64 `json:"available"`
|
||||||
Namespaces int `json:"namespaces,omitempty"`
|
BackupCount int `json:"backup_count"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type PBSTask struct {
|
type PBSTask struct {
|
||||||
@ -87,6 +87,14 @@ func CollectPBS() *PBSInfo {
|
|||||||
if avail, ok := d["avail"].(float64); ok {
|
if avail, ok := d["avail"].(float64); ok {
|
||||||
ds.Available = int64(avail)
|
ds.Available = int64(avail)
|
||||||
}
|
}
|
||||||
|
// Backup-Anzahl ermitteln
|
||||||
|
if snapOut, err := exec.Command("/usr/sbin/proxmox-backup-manager", "snapshot", "list", ds.Name, "--output-format", "json").Output(); err == nil {
|
||||||
|
var snaps []interface{}
|
||||||
|
if json.Unmarshal(snapOut, &snaps) == nil {
|
||||||
|
ds.BackupCount = len(snaps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pbs.Datastores = append(pbs.Datastores, ds)
|
pbs.Datastores = append(pbs.Datastores, ds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -127,7 +127,7 @@ export default function ProxmoxPanel({ agentId, customers, onClose, onReload })
|
|||||||
) : tab === 'updates' ? (
|
) : tab === 'updates' ? (
|
||||||
<UpdatesTab sys={sys} />
|
<UpdatesTab sys={sys} />
|
||||||
) : tab === 'pbs' ? (
|
) : tab === 'pbs' ? (
|
||||||
<PBSTab pbs={pbs} />
|
<PBSTab pbs={pbs} agentId={agentId} />
|
||||||
) : tab === 'backups' ? (
|
) : tab === 'backups' ? (
|
||||||
<BackupsTab sys={sys} />
|
<BackupsTab sys={sys} />
|
||||||
) : tab === 'agent' ? (
|
) : tab === 'agent' ? (
|
||||||
@ -952,7 +952,11 @@ function formatUptime(seconds) {
|
|||||||
if (d > 0) return `${d}d ${h}h`
|
if (d > 0) return `${d}d ${h}h`
|
||||||
return `${h}h ${m}m`
|
return `${h}h ${m}m`
|
||||||
}
|
}
|
||||||
function PBSTab({ pbs }) {
|
|
||||||
|
function PBSTab({ pbs, agentId }) {
|
||||||
|
const [loading, setLoading] = React.useState({})
|
||||||
|
const [feedback, setFeedback] = React.useState({})
|
||||||
|
|
||||||
if (!pbs?.available) return <div className="p-4 text-gray-500">Keine PBS-Daten verfuegbar</div>
|
if (!pbs?.available) return <div className="p-4 text-gray-500">Keine PBS-Daten verfuegbar</div>
|
||||||
|
|
||||||
const formatBytes = (b) => {
|
const formatBytes = (b) => {
|
||||||
@ -979,59 +983,109 @@ function PBSTab({ pbs }) {
|
|||||||
return 'text-yellow-400'
|
return 'text-yellow-400'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pbsAction = async (datastore, action) => {
|
||||||
|
const key = `${datastore}-${action}`
|
||||||
|
const cmds = {
|
||||||
|
gc: `/usr/sbin/proxmox-backup-manager garbage-collection start ${datastore}`,
|
||||||
|
verify: `/usr/sbin/proxmox-backup-manager verify ${datastore}`,
|
||||||
|
prune: `/usr/sbin/proxmox-backup-manager prune-datastore ${datastore}`,
|
||||||
|
}
|
||||||
|
setLoading(l => ({ ...l, [key]: true }))
|
||||||
|
setFeedback(f => ({ ...f, [key]: null }))
|
||||||
|
try {
|
||||||
|
await api.execCommand(agentId, cmds[action], 60)
|
||||||
|
setFeedback(f => ({ ...f, [key]: 'ok' }))
|
||||||
|
} catch (e) {
|
||||||
|
setFeedback(f => ({ ...f, [key]: 'err' }))
|
||||||
|
} finally {
|
||||||
|
setLoading(l => ({ ...l, [key]: false }))
|
||||||
|
setTimeout(() => setFeedback(f => ({ ...f, [key]: null })), 4000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-white font-semibold">Proxmox Backup Server</div>
|
<div className="text-white font-semibold">Proxmox Backup Server</div>
|
||||||
{pbs.version && <div className="text-xs text-gray-500">Version {pbs.version}</div>}
|
{pbs.version && <div className="text-xs text-gray-500 mt-0.5">Version {pbs.version}</div>}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Datastores */}
|
{/* Datastores */}
|
||||||
{pbs.datastores?.length > 0 && (
|
{pbs.datastores?.length > 0 && (
|
||||||
<>
|
|
||||||
<h3 className="text-sm font-medium text-gray-300">Datastores</h3>
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
<h3 className="text-sm font-medium text-gray-400">Datastores</h3>
|
||||||
{pbs.datastores.map((ds) => {
|
{pbs.datastores.map((ds) => {
|
||||||
const usedPct = ds.total > 0 ? (ds.used / ds.total * 100) : 0
|
const usedPct = ds.total > 0 ? (ds.used / ds.total * 100) : 0
|
||||||
const barColor = usedPct > 90 ? 'bg-red-500' : usedPct > 70 ? 'bg-yellow-500' : 'bg-green-500'
|
const barColor = usedPct > 90 ? 'bg-red-500' : usedPct > 70 ? 'bg-yellow-500' : 'bg-green-500'
|
||||||
const gc = pbs.gc?.find(g => g.datastore === ds.name)
|
const gc = pbs.gc?.find(g => g.datastore === ds.name)
|
||||||
|
const mkKey = (a) => `${ds.name}-${a}`
|
||||||
|
const ActionBtn = ({ action, label, cls }) => {
|
||||||
|
const k = mkKey(action)
|
||||||
|
const fb = feedback[k]
|
||||||
return (
|
return (
|
||||||
<div key={ds.name} className="bg-gray-800/50 rounded-lg border border-gray-700/50 p-4">
|
<button
|
||||||
<div className="flex items-center justify-between mb-2">
|
onClick={() => pbsAction(ds.name, action)}
|
||||||
|
disabled={loading[k]}
|
||||||
|
className={`flex-1 py-1.5 rounded text-sm font-semibold transition-all ${cls} disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
{loading[k] ? '...' : fb === 'ok' ? 'Gestartet' : fb === 'err' ? 'Fehler' : label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div key={ds.name} className="bg-[#1a2235] rounded-xl border border-gray-700/40 p-4 space-y-3">
|
||||||
|
{/* Titel + Backup-Count */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-orange-500/20 flex items-center justify-center">
|
||||||
|
<svg className="w-4 h-4 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span className="text-white font-semibold">{ds.name}</span>
|
||||||
|
</div>
|
||||||
|
{ds.backup_count > 0 && (
|
||||||
|
<span className="text-sm text-gray-400">{ds.backup_count} Backups</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Speicher-Balken */}
|
||||||
<div>
|
<div>
|
||||||
<span className="text-white font-medium">{ds.name}</span>
|
<div className="flex justify-between text-xs text-gray-400 mb-1.5">
|
||||||
{ds.path && <span className="text-xs text-gray-500 ml-2">{ds.path}</span>}
|
<span>{formatBytes(ds.used)} / {formatBytes(ds.total)}</span>
|
||||||
|
<span>{usedPct.toFixed(1)}%</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-gray-400">{usedPct.toFixed(1)}%</span>
|
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div className={`h-full rounded-full ${barColor} transition-all`} style={{ width: `${Math.min(usedPct, 100)}%` }} />
|
||||||
</div>
|
</div>
|
||||||
<div className="h-1.5 bg-gray-700 rounded overflow-hidden mb-2">
|
|
||||||
<div className={`h-full rounded ${barColor}`} style={{ width: `${Math.min(usedPct, 100)}%` }} />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-xs text-gray-500">
|
|
||||||
<span>Belegt: {formatBytes(ds.used)} / {formatBytes(ds.total)}</span>
|
{/* GC-Status */}
|
||||||
<span>Frei: {formatBytes(ds.available)}</span>
|
{gc && gc.status && (
|
||||||
</div>
|
<div className="text-xs text-gray-500 flex gap-3">
|
||||||
{gc && (
|
<span>GC: <span className={gc.status === 'ok' ? 'text-green-400' : gc.status === 'running' ? 'text-blue-400' : 'text-gray-500'}>{gc.status}</span></span>
|
||||||
<div className="mt-2 pt-2 border-t border-gray-700/50 text-xs text-gray-500 flex gap-4">
|
|
||||||
<span>GC: <span className={gc.status === 'ok' ? 'text-green-400' : gc.status === 'running' ? 'text-blue-400' : 'text-gray-500'}>{gc.status || '—'}</span></span>
|
|
||||||
{gc.last_run > 0 && <span>Letzter Lauf: {formatTs(gc.last_run)}</span>}
|
{gc.last_run > 0 && <span>Letzter Lauf: {formatTs(gc.last_run)}</span>}
|
||||||
{gc.duration > 0 && <span>Dauer: {formatDuration(gc.duration)}</span>}
|
{gc.duration > 0 && <span>({formatDuration(gc.duration)})</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Aktions-Buttons */}
|
||||||
|
<div className="flex gap-2 pt-1">
|
||||||
|
<ActionBtn action="gc" label="GC" cls="bg-yellow-500 hover:bg-yellow-400 text-gray-900" />
|
||||||
|
<ActionBtn action="verify" label="Verify" cls="bg-gray-600 hover:bg-gray-500 text-white" />
|
||||||
|
<ActionBtn action="prune" label="Prune" cls="bg-orange-600 hover:bg-orange-500 text-white" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tasks */}
|
{/* Tasks */}
|
||||||
{pbs.tasks?.length > 0 && (
|
{pbs.tasks?.length > 0 && (
|
||||||
<>
|
<div className="space-y-2">
|
||||||
<h3 className="text-sm font-medium text-gray-300 mt-4">Letzte Tasks</h3>
|
<h3 className="text-sm font-medium text-gray-400">Letzte Tasks</h3>
|
||||||
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
<div className="bg-gray-800/50 rounded-lg border border-gray-700/50 overflow-hidden">
|
||||||
<table className="w-full text-xs">
|
<table className="w-full text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
@ -1039,24 +1093,22 @@ function PBSTab({ pbs }) {
|
|||||||
<th className="px-4 py-2">Typ</th>
|
<th className="px-4 py-2">Typ</th>
|
||||||
<th className="px-4 py-2">Start</th>
|
<th className="px-4 py-2">Start</th>
|
||||||
<th className="px-4 py-2">Dauer</th>
|
<th className="px-4 py-2">Dauer</th>
|
||||||
<th className="px-4 py-2">Benutzer</th>
|
|
||||||
<th className="px-4 py-2 text-right">Status</th>
|
<th className="px-4 py-2 text-right">Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-700/50">
|
<tbody className="divide-y divide-gray-700/50">
|
||||||
{pbs.tasks.map((t, i) => (
|
{pbs.tasks.map((t, i) => (
|
||||||
<tr key={i}>
|
<tr key={i} className="hover:bg-gray-700/20">
|
||||||
<td className="px-4 py-2 text-gray-300 font-mono">{t.type || '—'}</td>
|
<td className="px-4 py-2 text-gray-300 font-mono">{t.type || '—'}</td>
|
||||||
<td className="px-4 py-2 text-gray-400">{formatTs(t.start_time)}</td>
|
<td className="px-4 py-2 text-gray-400">{formatTs(t.start_time)}</td>
|
||||||
<td className="px-4 py-2 text-gray-400">{formatDuration(t.duration)}</td>
|
<td className="px-4 py-2 text-gray-400">{formatDuration(t.duration)}</td>
|
||||||
<td className="px-4 py-2 text-gray-500">{t.user || '—'}</td>
|
|
||||||
<td className={`px-4 py-2 text-right font-medium ${taskStatusColor(t.status)}`}>{t.status || '—'}</td>
|
<td className={`px-4 py-2 text-right font-medium ${taskStatusColor(t.status)}`}>{t.status || '—'}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user