Agent+MultiServer

This commit is contained in:
2025-11-25 16:02:00 +00:00
parent 9b25cb807a
commit fc3ed57a40
26 changed files with 3233 additions and 338 deletions
+1
View File
@@ -6,3 +6,4 @@ export * from "@/pages/dashboard/usergroups";
export * from "@/pages/dashboard/userDetails";
export * from "@/pages/dashboard/userGroupDetail";
export * from "@/pages/dashboard/securityPhrase";
export * from "@/pages/dashboard/serverDetail";
+381 -187
View File
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import axios from "axios";
import { useMaintenance } from "@/components/MaintenanceProvider";
import { useAuth } from "@/components/AuthProvider.jsx";
@@ -11,8 +12,12 @@ import {
Button,
Switch,
Input,
Alert
Alert,
Chip,
Select,
Option
} from "@material-tailwind/react";
import { PaginationControls, usePage } from "@/components/PageProvider.jsx";
const UPDATE_STATUS_LABELS = {
idle: "Bereit",
@@ -111,6 +116,7 @@ const createEmptySshDraft = () => ({
});
export function Maintenance() {
const navigate = useNavigate();
const { hasPermission, user: authUser } = useAuth();
const showToast = useCallback(() => { }, []);
const {
@@ -139,7 +145,7 @@ export function Maintenance() {
const canViewMaintenance = Boolean(isSuperuserAccount || hasPermission("maintenance-access", "read"));
const canControlMaintenance = Boolean(isSuperuserAccount || hasPermission("maintenance-access", "full"));
const canViewServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-manage", "read"));
const canEditServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-manage", "full"));
const canEditServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-edit", "full"));
const canDeleteServers = Boolean(isSuperuserAccount || hasPermission("maintenance-server-delete", "full"));
const canDeleteSuperuser = Boolean(isSuperuserAccount || hasPermission("maintenance-superuser-delete", "full"));
const canViewPortainer = Boolean(isSuperuserAccount || hasPermission("maintenance-portainer", "read"));
@@ -148,6 +154,8 @@ export function Maintenance() {
const canManageSsh = Boolean(isSuperuserAccount || hasPermission("maintenance-ssh-update", "full"));
const canViewDuplicates = Boolean(isSuperuserAccount || hasPermission("maintenance-duplicates", "read"));
const canManageDuplicates = Boolean(isSuperuserAccount || hasPermission("maintenance-duplicates", "full"));
const showPortainerSection = false;
const showDuplicateSection = false;
const maintenanceActive = Boolean(maintenanceMeta?.active);
const maintenanceMessage = maintenanceMeta?.message;
@@ -214,6 +222,11 @@ export function Maintenance() {
const [setupResources, setSetupResources] = useState(null);
const [setupResourcesLoading, setSetupResourcesLoading] = useState(true);
const [setupResourcesError, setSetupResourcesError] = useState("");
const [createServerName, setCreateServerName] = useState("");
const [createServerUrl, setCreateServerUrl] = useState("");
const [createServerApiKey, setCreateServerApiKey] = useState("");
const [createServerError, setCreateServerError] = useState("");
const [creatingServer, setCreatingServer] = useState(false);
const [apiKeyDrafts, setApiKeyDrafts] = useState({});
const [apiKeyUpdatingId, setApiKeyUpdatingId] = useState(null);
const [serverDeleteId, setServerDeleteId] = useState(null);
@@ -229,6 +242,35 @@ export function Maintenance() {
const [duplicatesUpdatedAt, setDuplicatesUpdatedAt] = useState(null);
const duplicatesRequestRef = useRef(null);
const [serverStatuses, setServerStatuses] = useState(new Map());
const [serverStatusesLoading, setServerStatusesLoading] = useState(false);
const [serverStatusesError, setServerStatusesError] = useState("");
const setupServers = useMemo(() => setupResources?.servers?.items ?? [], [setupResources]);
const apiKeyInfoMap = useMemo(() => {
const items = setupResources?.apiKeys?.items ?? [];
return new Map(items.map((entry) => [entry.serverId, entry]));
}, [setupResources]);
const setupComplete = useMemo(() => Boolean(setupResources?.setupComplete), [setupResources]);
const selfStackInfo = setupResources?.selfStack ?? null;
const currentSelfStackValue = selfStackInfo?.current ?? "";
const envSelfStackValue = setupResources?.envDefaults?.selfStackId ?? "";
const {
page,
perPage,
perPageOptions,
perPageIsAll,
handlePerPageChange,
setPage,
setTotals,
resetPagination
} = usePage();
useEffect(() => () => resetPagination(), [resetPagination]);
const [searchQuery, setSearchQuery] = useState("");
const [portainerStatus, setPortainerStatus] = useState(null);
const [portainerLoading, setPortainerLoading] = useState(true);
const [portainerError, setPortainerError] = useState("");
@@ -385,15 +427,78 @@ export function Maintenance() {
fetchDuplicates();
}, [fetchDuplicates]);
const setupServers = useMemo(() => setupResources?.servers?.items ?? [], [setupResources]);
const apiKeyInfoMap = useMemo(() => {
const items = setupResources?.apiKeys?.items ?? [];
return new Map(items.map((entry) => [entry.serverId, entry]));
}, [setupResources]);
const setupComplete = useMemo(() => Boolean(setupResources?.setupComplete), [setupResources]);
const selfStackInfo = setupResources?.selfStack ?? null;
const currentSelfStackValue = selfStackInfo?.current ?? "";
const envSelfStackValue = setupResources?.envDefaults?.selfStackId ?? "";
useEffect(() => {
if (!canViewServers || setupResourcesLoading) {
return;
}
const fetchStatuses = async () => {
if (!setupServers.length) {
setServerStatuses(new Map());
return;
}
setServerStatusesLoading(true);
setServerStatusesError("");
try {
const entries = await Promise.all(
setupServers.map(async (server) => {
try {
const res = await axios.get(`/api/setup/servers/${server.id}/check`);
const payload = res.data ?? {};
return [
server.id,
{
online: Boolean(payload.online),
portainer: payload.portainer ?? {},
error: null
}
];
} catch (err) {
const message = err.response?.data?.error || err.message || "Status unbekannt";
return [
server.id,
{
online: false,
portainer: {},
error: message
}
];
}
})
);
setServerStatuses(new Map(entries));
} catch (err) {
const message = err.response?.data?.error || err.message || "Server-Status konnte nicht geladen werden.";
setServerStatusesError(message);
} finally {
setServerStatusesLoading(false);
}
};
fetchStatuses();
}, [canViewServers, setupResourcesLoading, setupServers, setTotals]);
const filteredServers = useMemo(() => {
const query = searchQuery.trim().toLowerCase();
if (!query) {
return setupServers;
}
return setupServers.filter((server) => {
const haystacks = [server.name, server.url].map((value) => String(value || "").toLowerCase());
return haystacks.some((value) => value.includes(query));
});
}, [searchQuery, setupServers]);
useEffect(() => {
setTotals(filteredServers.length);
}, [filteredServers.length, setTotals]);
const paginatedServers = useMemo(() => {
if (perPageIsAll) {
return filteredServers;
}
const start = (page - 1) * perPage;
return filteredServers.slice(start, start + perPage);
}, [filteredServers, page, perPage, perPageIsAll]);
useEffect(() => {
setSelfStackDraft(currentSelfStackValue || "");
@@ -401,6 +506,48 @@ export function Maintenance() {
}, [currentSelfStackValue]);
const selfStackDirty = selfStackDraft !== currentSelfStackValue;
const handleSearchChange = useCallback((event) => {
setSearchQuery(event.target.value);
}, []);
useEffect(() => {
if (page !== 1) {
setPage(1);
}
}, [searchQuery, page, setPage]);
const handleCreateServer = useCallback(async () => {
if (!canEditServers || creatingServer) return;
const name = (createServerName || "").trim();
const url = (createServerUrl || "").trim();
const apiKey = (createServerApiKey || "").trim();
if (!url || !apiKey) {
setCreateServerError("Bitte Name, URL und API-Key angeben.");
return;
}
setCreateServerError("");
setCreatingServer(true);
try {
await axios.post("/api/setup/servers", { name, url, apiKey });
setCreateServerName("");
setCreateServerUrl("");
setCreateServerApiKey("");
await loadSetupResources({ silent: true });
} catch (err) {
const message = err.response?.data?.message || err.response?.data?.error || err.message || "Server konnte nicht angelegt werden.";
setCreateServerError(message);
} finally {
setCreatingServer(false);
}
}, [canEditServers, createServerApiKey, createServerName, createServerUrl, creatingServer, loadSetupResources]);
const handleResetCreateServer = useCallback(() => {
if (creatingServer) return;
setCreateServerName("");
setCreateServerUrl("");
setCreateServerApiKey("");
setCreateServerError("");
}, [creatingServer]);
const totals = useMemo(() => {
const groups = Array.isArray(duplicates) ? duplicates.length : 0;
@@ -974,189 +1121,236 @@ export function Maintenance() {
{canViewServers && (
<Card>
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography
variant="h6"
color="white"
className="flex items-center justify-between"
>
<span>Server &amp; API-Keys</span>
<Typography variant="h6" color="white" className="flex items-center justify-between">
<span>Server</span>
<Typography variant="small" color="white">
{setupServers.length} vorhanden
</Typography>
</Typography>
</CardHeader>
<CardBody className="flex flex-col gap-4 p-4">
{setupResourcesLoading && (
<p className="text-sm text-stormGrey-500">Daten werden geladen</p>
)}
{!setupResourcesLoading && setupResourcesError && (
<Alert color="red" className="border border-red-200 bg-red-50 text-red-700">
{setupResourcesError}
</Alert>
)}
{!setupResourcesLoading && !setupResourcesError && (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<Typography variant="small" color="blue-gray" className="font-semibold uppercase">
Server
</Typography>
<Typography variant="small" color="blue-gray">
{setupServers.length} vorhanden
</Typography>
</div>
{setupServers.length === 0 ? (
<p className="text-sm text-stormGrey-500">
Es sind derzeit keine Server hinterlegt.
</p>
) : (
<div className="flex flex-col gap-3">
{setupServers.map((server) => {
const apiKeyInfo = apiKeyInfoMap.get(server.id) || null;
const hasKey = Boolean(apiKeyInfo?.hasKey);
const apiKeyUpdatedLabel = apiKeyInfo?.updatedAt
? new Date(apiKeyInfo.updatedAt).toLocaleString("de-DE", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
})
: null;
return (
<div
key={server.id}
className="flex flex-col gap-3 rounded-md border border-blue-gray-100 p-3 md:flex-row md:items-start md:justify-between"
>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-stormGrey-900">
{server.name || "Ohne Namen"}
</p>
<p className="text-xs text-stormGrey-500 break-all">
{server.url}
</p>
<p className="text-xs text-stormGrey-500">
API-Key: {hasKey ? "vorhanden" : "nicht hinterlegt"}{apiKeyUpdatedLabel ? ` (aktualisiert am ${apiKeyUpdatedLabel})` : ""}
</p>
</div>
<div className="flex w-full flex-col gap-2 md:max-w-xs">
<Input
type="password"
label="Neuer API-Key"
value={apiKeyDrafts[server.id] ?? ""}
onChange={(event) => handleApiKeyDraftChange(server.id, event.target.value)}
disabled={!canEditServers || setupResourcesLoading || apiKeyUpdatingId === server.id}
/>
<div className="flex items-center gap-2 justify-end">
<Button
variant="text"
color="red"
size="sm"
onClick={() => handleDeleteServer(server.id)}
disabled={maintenanceLocked || !canDeleteServers || setupResourcesLoading || serverDeleteId === server.id || apiKeyUpdatingId === server.id}
>
{serverDeleteId === server.id ? "Lösche…" : "Löschen"}
</Button>
<Button
color="blue"
size="sm"
onClick={() => handleApiKeyUpdate(server.id)}
disabled={maintenanceLocked || !canEditServers || setupResourcesLoading || apiKeyUpdatingId === server.id}
>
{apiKeyUpdatingId === server.id ? "Speichere…" : "API-Key speichern"}
</Button>
</div>
</div>
</div>
);
})}
</div>
)}
<CardBody className="p-4">
{canEditServers && (
<div className="mb-8 rounded-lg border border-blue-gray-50 p-4">
<Typography variant="h6" color="blue-gray" className="mb-2">
Neuen Server hinzufügen
</Typography>
<p className="text-sm text-stormGrey-600 mb-4">
Hinterlege deine Portainer-Instanz. Die Verbindung wird beim Speichern automatisch geprüft.
</p>
<div className="grid gap-4 md:grid-cols-3">
<Input
label="Servername"
value={createServerName}
onChange={(event) => setCreateServerName(event.target.value)}
disabled={creatingServer || maintenanceLocked}
crossOrigin=""
/>
<Input
label="Server-URL oder IP"
value={createServerUrl}
onChange={(event) => setCreateServerUrl(event.target.value)}
disabled={creatingServer || maintenanceLocked}
crossOrigin=""
/>
<Input
type="password"
label="Portainer API-Key"
value={createServerApiKey}
onChange={(event) => setCreateServerApiKey(event.target.value)}
disabled={creatingServer || maintenanceLocked}
crossOrigin=""
/>
</div>
<div className="flex flex-col gap-3 border-t border-blue-gray-50 pt-4">
<div className="flex flex-col gap-1">
<Typography variant="small" color="blue-gray" className="font-semibold uppercase">
Self-Stack-ID
</Typography>
<p className="text-xs text-stormGrey-500">
Optional lass das Feld leer, wenn keine Self-Stack-ID benötigt wird.
</p>
</div>
{selfStackInfo?.envProvided && (
<p className="text-xs text-stormGrey-500">
Hinweis: Eine Umgebungsvariable liefert bereits einen Standardwert.
</p>
)}
<div className="flex flex-col gap-2 md:flex-row md:items-end">
<div className="flex-1">
<Input
label="Self-Stack-ID"
value={selfStackDraft}
onChange={(event) => handleSelfStackDraftChange(event.target.value)}
disabled={!canEditServers || setupResourcesLoading || selfStackSaving}
/>
</div>
{canEditServers && (
<div className="flex items-center justify-end gap-2">
<Button
variant="text"
color="red"
size="sm"
onClick={handleSelfStackRemove}
disabled={maintenanceLocked || selfStackSaving || (!currentSelfStackValue && !selfStackDraft)}
>
Entfernen
</Button>
<Button
color="blue"
size="sm"
onClick={handleSelfStackSave}
disabled={maintenanceLocked || selfStackSaving || setupResourcesLoading || !selfStackDirty}
>
{selfStackSaving ? "Speichere…" : "Self-Stack-ID speichern"}
</Button>
</div>
)}
</div>
{!canEditServers && (
<p className="text-xs text-stormGrey-500">
Bearbeitung benötigt das Gruppenrecht Server-Sektion.
</p>
)}
<p className="text-xs text-stormGrey-500">
Aktuell gespeichert: {currentSelfStackValue || "nicht gesetzt"}
</p>
{envSelfStackValue && (
<p className="text-xs text-stormGrey-500">
Vorgabe aus Umgebung: {envSelfStackValue}
</p>
)}
{selfStackError && (
<Alert color="red" className="border border-red-200 bg-red-50 text-red-700">
{selfStackError}
</Alert>
)}
</div>
{!setupComplete && (
<Alert color="amber" className="border border-amber-200 bg-amber-50 text-amber-800">
Das Setup ist aktuell unvollständig. Bitte öffne den Setup-Bereich, um Server und API-Key erneut festzulegen.
</Alert>
)}
<div className="flex items-center justify-end">
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:items-center">
<Button
variant="outlined"
color="gray"
size="sm"
onClick={() => loadSetupResources()}
disabled={maintenanceLocked || setupResourcesLoading}
color="green"
onClick={handleCreateServer}
disabled={creatingServer || maintenanceLocked}
>
Aktualisieren
{creatingServer ? "Verifiziere …" : "Server anlegen"}
</Button>
<Button
variant="text"
color="blue-gray"
onClick={handleResetCreateServer}
disabled={creatingServer}
>
Formular zurücksetzen
</Button>
</div>
{createServerError && (
<p className="mt-2 text-sm text-sunsetCoral-600">{createServerError}</p>
)}
</div>
)}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="md:flex-1">
<Input
label="Suchen nach Name oder URL"
value={searchQuery}
onChange={handleSearchChange}
crossOrigin=""
/>
</div>
<div className="md:mt-0 mt-4 md:flex-1">
<Select variant="static" label="Einträge pro Seite" onChange={handlePerPageChange} value={perPage}>
{perPageOptions.map(({ value, label }) => (
<Option key={value} value={value}>
{label}
</Option>
))}
</Select>
</div>
</div>
{setupResourcesError && (
<div className="mt-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800">
{setupResourcesError}
</div>
)}
{serverStatusesError && !setupResourcesLoading && (
<div className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
{serverStatusesError}
</div>
)}
<div className="overflow-x-auto rounded-lg border border-blue-gray-50 mt-6">
<table className="w-full min-w-[720px] table-auto text-left">
<thead>
<tr className="bg-blue-gray-50/50 text-xs uppercase tracking-wide text-stormGrey-400">
<th className="px-6 py-4 font-semibold">Name</th>
<th className="px-6 py-4 font-semibold">IP / Host</th>
<th className="px-6 py-4 font-semibold">Server Status</th>
<th className="px-6 py-4 font-semibold">Portainer Status</th>
<th className="px-6 py-4 font-semibold">Edition</th>
<th className="px-6 py-4 font-semibold text-right">Aktionen</th>
</tr>
</thead>
<tbody>
{setupResourcesLoading ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-blue-gray-400">
Server werden geladen ...
</td>
</tr>
) : filteredServers.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-8 text-center text-blue-gray-400">
Keine Server gefunden.
</td>
</tr>
) : (
paginatedServers.map((server, index) => {
const rowClass = index === paginatedServers.length - 1 ? "" : "border-b border-blue-gray-50";
const statusEntry = serverStatuses.get(server.id) || {};
const serverOnline = statusEntry.online === true;
const portainer = statusEntry.portainer || {};
const portainerStatus = portainer.updateAvailable === true
? "update"
: portainer.updateAvailable === false
? "ok"
: "unknown";
const portainerEdition = portainer.edition || "-";
const hostLabel = (() => {
try {
const parsed = new URL(server.url);
return parsed.hostname || server.url;
} catch {
return server.url;
}
})();
return (
<tr key={server.id} className={`text-sm text-stormGrey-700 ${rowClass}`}>
<td className="px-6 py-4">
<Typography variant="small" className="font-medium text-stormGrey-900">
{server.name || ""}
</Typography>
<Typography variant="small" className="text-xs text-stormGrey-500">
{server.url}
</Typography>
</td>
<td className="px-6 py-4">
<Typography variant="small">{hostLabel || ""}</Typography>
</td>
<td className="px-6 py-4">
{serverStatusesLoading && !statusEntry ? (
<span className="text-xs text-stormGrey-400">Prüfe</span>
) : (
<Chip
value={serverOnline ? "Online" : "Offline"}
size="sm"
color={serverOnline ? "green" : "red"}
variant="ghost"
/>
)}
</td>
<td className="px-6 py-4">
{serverStatusesLoading && !statusEntry ? (
<span className="text-xs text-stormGrey-400">Prüfe</span>
) : (
<Chip
value={
portainerStatus === "update"
? "Update vorhanden"
: portainerStatus === "ok"
? "Aktuell"
: "Unbekannt"
}
size="sm"
color={
portainerStatus === "update"
? "amber"
: portainerStatus === "ok"
? "green"
: "blue-gray"
}
variant="ghost"
/>
)}
</td>
<td className="px-6 py-4">
{serverStatusesLoading && !statusEntry ? (
<span className="text-xs text-stormGrey-400">Prüfe</span>
) : (
<Typography variant="small">{portainerEdition}</Typography>
)}
</td>
<td className="px-6 py-4">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="outlined"
color="blue-gray"
onClick={() => navigate(`/dashboard/maintenance/servers/${server.id}`)}
disabled={!canEditServers}
>
Bearbeiten
</Button>
<Button
size="sm"
variant="text"
color="red"
onClick={() => handleDeleteServer(server.id)}
disabled={maintenanceLocked || !canDeleteServers || serverDeleteId === server.id}
>
{serverDeleteId === server.id ? "Lösche…" : "Löschen"}
</Button>
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
<div className="mt-4 flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<Typography variant="small" color="blue-gray" className="text-sm">
{perPageIsAll ? "Alle Server" : `${paginatedServers.length} von ${filteredServers.length}`} angezeigt
</Typography>
<PaginationControls />
</div>
</CardBody>
</Card>
)}
@@ -1220,7 +1414,7 @@ export function Maintenance() {
</Card>
)}
{canViewPortainer && (
{showPortainerSection && canViewPortainer && (
<Card>
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography
@@ -1624,7 +1818,7 @@ export function Maintenance() {
</Card>
)}
{canViewDuplicates && (
{showDuplicateSection && canViewDuplicates && (
<Card>
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography
File diff suppressed because it is too large Load Diff
@@ -519,8 +519,12 @@ export function UserGroupDetail() {
setPermissionSelection((prev) => {
const shouldResetDelete =
permissionKey === "maintenance-server-manage" && nextValue !== "full";
const shouldResetEdit =
permissionKey === "maintenance-server-manage" && nextValue === "none";
const shouldResetDeleteFromEdit =
permissionKey === "maintenance-server-edit" && nextValue !== "full";
if (!shouldResetDelete && prev[permissionKey] === nextValue) {
if (!shouldResetDelete && !shouldResetEdit && !shouldResetDeleteFromEdit && prev[permissionKey] === nextValue) {
return prev;
}
@@ -532,6 +536,12 @@ export function UserGroupDetail() {
if (shouldResetDelete) {
nextState["maintenance-server-delete"] = "none";
}
if (shouldResetEdit) {
nextState["maintenance-server-edit"] = "none";
}
if (shouldResetDeleteFromEdit) {
nextState["maintenance-server-delete"] = "none";
}
return nextState;
});
@@ -609,6 +619,7 @@ export function UserGroupDetail() {
const currentValue = getPermissionValue(row.key) ?? "none";
const displayValue = normalizePermissionLevel(row.key, currentValue);
const serverManageLevel = getPermissionValue("maintenance-server-manage") ?? "none";
const serverEditLevel = getPermissionValue("maintenance-server-edit") ?? "none";
const { addTopBorder = true } = options;
const rowClasses = [
@@ -641,13 +652,18 @@ export function UserGroupDetail() {
row.key === "maintenance-server-delete" &&
option.value === "full" &&
(LEVEL_PRIORITY[serverManageLevel] ?? 0) < LEVEL_PRIORITY.full;
const requiresEditFull =
row.key === "maintenance-server-delete" &&
option.value === "full" &&
(LEVEL_PRIORITY[serverEditLevel] ?? 0) < LEVEL_PRIORITY.full;
const disabled =
!availableLevels.has(option.value) ||
permissionsLoading ||
savingGroup ||
!canAdjustPermissions ||
isSuperuserGroup ||
requiresManageFull;
requiresManageFull ||
requiresEditFull;
return (
<ListItem