- Passwort-Reset

cd management
    npm run reset-password -- admin "neues-passwort"

  - Agent-Hardening
    backend/.env unterstützt jetzt:

    ALLOWED_MANAGEMENT_IPS="127.0.0.1,DEINE-MANAGEMENT-IP"

    Wenn gesetzt, akzeptiert der Agent nur Requests von diesen IPs.

  - Audit-Log
    Management speichert Aktionen wie Login, Logout, Node-Änderungen, Schedule-Updates, Backup/Restore, Settings-Änderungen.
  - Job-History
    Management speichert gestartete Backup/Restore/Scheduler-Jobs mit Node, VM, Typ, Agent-Job-ID und Status.
  - Operations-Seite
    Neue UI-Seite Operations mit:
      - Job History
      - Audit Log
  - systemd Templates

    deploy/systemd/incus-backup-agent.service
    deploy/systemd/incus-backup-management.service

  - Deployment-Doku

    docs/deployment.md
This commit is contained in:
Philipp
2026-05-21 10:14:33 +02:00
parent aae54167a1
commit e32ebbc734
21 changed files with 417 additions and 13 deletions
+15 -1
View File
@@ -1,9 +1,10 @@
import { useEffect, useMemo, useState } from 'react';
import { AlertTriangle, CalendarClock, DatabaseBackup, LogOut, Network, RefreshCw, Settings as SettingsIcon } from 'lucide-react';
import { AlertTriangle, CalendarClock, ClipboardList, DatabaseBackup, LogOut, Network, RefreshCw, Settings as SettingsIcon } from 'lucide-react';
import { api, errorMessage } from './api.js';
import { Dashboard } from './components/Dashboard.jsx';
import { Login } from './components/Login.jsx';
import { Nodes } from './components/Nodes.jsx';
import { Operations } from './components/Operations.jsx';
import { Scheduler } from './components/Scheduler.jsx';
import { Settings } from './components/Settings.jsx';
import { VMDetail } from './components/VMDetail.jsx';
@@ -139,6 +140,17 @@ export default function App() {
<CalendarClock className="h-4 w-4" />
Scheduler
</button>
<button
className="inline-flex h-9 items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-200 hover:border-zinc-700"
onClick={() => {
setSelectedVm(null);
setView('operations');
}}
type="button"
>
<ClipboardList className="h-4 w-4" />
Operations
</button>
<button
className="inline-flex h-9 items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-200 hover:border-zinc-700"
onClick={() => {
@@ -180,6 +192,8 @@ export default function App() {
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6">
{view === 'nodes' ? (
<Nodes nodes={nodes} onChanged={refresh} />
) : view === 'operations' ? (
<Operations />
) : view === 'settings' ? (
<Settings nodes={nodes} onChanged={refresh} />
) : view === 'scheduler' ? (
+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useState } from 'react';
import { api, errorMessage } from '../api.js';
export function Operations() {
const [audit, setAudit] = useState([]);
const [history, setHistory] = useState([]);
const [error, setError] = useState('');
async function load() {
setError('');
try {
const [auditResult, historyResult] = await Promise.all([
api.get('/operations/audit'),
api.get('/operations/history'),
]);
setAudit(auditResult.data);
setHistory(historyResult.data);
} catch (requestError) {
setError(errorMessage(requestError));
}
}
useEffect(() => {
load();
}, []);
return (
<section className="space-y-5">
<div className="border-b border-zinc-800 pb-5">
<h1 className="text-2xl font-semibold text-zinc-100">Operations</h1>
</div>
{error ? <div className="rounded-md border border-red-900 bg-red-950/50 px-4 py-3 text-sm text-red-200">{error}</div> : null}
<Table
columns={['Time', 'Type', 'Node', 'VM', 'Status', 'Agent Job']}
rows={history.map((row) => [
formatTime(row.startedAt),
row.type,
row.nodeName,
row.vmName,
row.error || row.status,
row.agentJobId || '-',
])}
title="Job History"
/>
<Table
columns={['Time', 'User', 'Action', 'Target', 'Details']}
rows={audit.map((row) => [
formatTime(row.createdAt),
row.username || 'system',
row.action,
`${row.targetType}${row.targetId ? `:${row.targetId}` : ''}`,
JSON.stringify(row.details || {}),
])}
title="Audit Log"
/>
</section>
);
}
function Table({ columns, rows, title }) {
return (
<section className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/70">
<div className="border-b border-zinc-800 px-4 py-3">
<h2 className="text-sm font-semibold">{title}</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[820px] text-left text-sm">
<thead className="border-b border-zinc-800 text-xs uppercase text-zinc-500">
<tr>{columns.map((column) => <th className="px-4 py-3 font-medium" key={column}>{column}</th>)}</tr>
</thead>
<tbody className="divide-y divide-zinc-800">
{rows.map((row, index) => (
<tr key={index}>
{row.map((cell, cellIndex) => <td className="px-4 py-3 text-zinc-300" key={cellIndex}>{cell}</td>)}
</tr>
))}
{!rows.length ? <tr><td className="px-4 py-8 text-center text-zinc-500" colSpan={columns.length}>No entries.</td></tr> : null}
</tbody>
</table>
</div>
</section>
);
}
function formatTime(value) {
if (!value) return '-';
return new Intl.DateTimeFormat(undefined, { dateStyle: 'short', timeStyle: 'medium' }).format(new Date(value));
}