This commit is contained in:
root
2025-10-21 06:30:47 +00:00
parent 2c3379026b
commit 98a6df5081
8 changed files with 255 additions and 116 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -32,8 +32,8 @@
<!-- Nepcha Analytics (nepcha.com) -->
<!-- Nepcha is a easy-to-use web analytics. No cookies and fully compliant with GDPR, CCPA and PECR. -->
<script defer data-site="YOUR_DOMAIN_HERE" src="https://api.nepcha.com/js/nepcha-analytics.js"></script>
<script type="module" crossorigin src="/assets/index-55cb988b.js"></script>
<link rel="stylesheet" href="/assets/index-c9347383.css">
<script type="module" crossorigin src="/assets/index-3cdb6846.js"></script>
<link rel="stylesheet" href="/assets/index-422138e0.css">
</head>
<body>
<div id="root"></div>
+8 -8
View File
@@ -4,20 +4,20 @@ const ToastContext = createContext(null);
const VARIANT_STYLES = {
success: {
container: "border-green-500/80 bg-green-950/90 text-green-100",
accent: "bg-green-400"
container: "border-mossGreen-500/80 bg-mossGreen-900/90 text-white",
accent: "bg-mossGreen-400"
},
warning: {
container: "border-yellow-500/80 bg-yellow-950/90 text-yellow-100",
accent: "bg-yellow-400"
container: "border-warmAmberGlow-500/80 bg-warmAmberGlow-900/90 text-white",
accent: "bg-warmAmberGlow-400"
},
error: {
container: "border-red-500/80 bg-red-950/90 text-red-100",
accent: "bg-red-400"
container: "border-sunsetCoral-500/80 bg-sunsetCoral-900/90 text-white",
accent: "bg-sunsetCoral-400"
},
info: {
container: "border-sky-500/60 bg-sky-950/90 text-sky-100",
accent: "bg-sky-400"
container: "border-arcticBlue-500 bg-arcticBlue-900/90 text-white",
accent: "bg-arcticBlue-400"
}
};
+65 -44
View File
@@ -18,7 +18,7 @@ import {
useSelect
} from "@material-tailwind/react";
const StickyOption = React.forwardRef(({ value, onValueSelect, onClick, onKeyDown, ...props }, ref) => {
const StickyOption = React.forwardRef(({ value, onClick, onKeyDown, ...props }, ref) => {
const { setOpen } = useSelect();
const reopen = useCallback(() => {
@@ -31,20 +31,13 @@ const StickyOption = React.forwardRef(({ value, onValueSelect, onClick, onKeyDow
});
}, [setOpen]);
const commitSelection = useCallback(() => {
if (typeof onValueSelect === "function") {
onValueSelect(value);
}
}, [onValueSelect, value]);
const handleClick = useCallback((event) => {
if (typeof onClick === "function") {
onClick(event);
}
commitSelection();
reopen();
}, [onClick, commitSelection, reopen]);
}, [onClick, reopen]);
const handleKeyDown = useCallback((event) => {
if (typeof onKeyDown === "function") {
@@ -52,10 +45,9 @@ const StickyOption = React.forwardRef(({ value, onValueSelect, onClick, onKeyDow
}
if (event.key === "Enter" || event.key === " ") {
commitSelection();
reopen();
}
}, [onKeyDown, commitSelection, reopen]);
}, [onKeyDown, reopen]);
return (
<Option
@@ -71,10 +63,10 @@ const StickyOption = React.forwardRef(({ value, onValueSelect, onClick, onKeyDow
StickyOption.displayName = "StickyOption";
const STATUS_COLORS = {
success: "text-green-400",
warning: "text-yellow-400",
error: "text-red-400",
started: "text-blue-300"
success: "text-mossGreen-500",
warning: "text-warmAmberGlow-500",
error: "text-sunsetCoral-500",
started: "text-arcticBlue-500"
};
const formatTimestamp = (value) => {
@@ -419,7 +411,7 @@ export function Logs() {
}
}, [filtersReady, currentFilters, perPage, page]);
const handleMultiSelectChange = (setter) => (valueOrEvent) => {
const handleMultiSelectChange = useCallback((setter) => (valueOrEvent) => {
if (typeof valueOrEvent === "string") {
const value = valueOrEvent;
if (value === "") {
@@ -453,7 +445,12 @@ export function Logs() {
setter(values);
}
setPage(1);
};
}, [setPage]);
const selectStacks = useMemo(() => handleMultiSelectChange(setSelectedStacks), [handleMultiSelectChange, setSelectedStacks]);
const selectStatuses = useMemo(() => handleMultiSelectChange(setSelectedStatuses), [handleMultiSelectChange, setSelectedStatuses]);
const selectRedeployTypes = useMemo(() => handleMultiSelectChange(setSelectedRedeployTypes), [handleMultiSelectChange, setSelectedRedeployTypes]);
const selectEndpoints = useMemo(() => handleMultiSelectChange(setSelectedEndpoints), [handleMultiSelectChange, setSelectedEndpoints]);
const removeFilterValue = (setter, value) => {
setter((prev) => prev.filter((entry) => entry !== value));
@@ -631,7 +628,7 @@ export function Logs() {
return (
<div className="mt-12 mb-8 flex flex-col gap-12">
<Card>
<CardHeader variant="gradient" color="gray" className="p-6 pt-2 pb-2">
<CardHeader variant="gradient" color="gray" className="p-4 pt-2 pb-2">
<Typography variant="h6" color="white">
<button
onClick={handleToggleFilters}
@@ -679,7 +676,7 @@ export function Logs() {
<Select
multiple
onChange={noop}
className="text-gray-500"
className="text-stormGrey-500"
variant="static"
dismiss={{ itemPress: false }}
label="Stacks"
@@ -688,16 +685,22 @@ export function Logs() {
<StickyOption
key={value}
value={value}
onValueSelect={handleMultiSelectChange(setSelectedStacks)}
onClick={() => selectStacks(value)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
selectStacks(value);
}
}}
className={`text-black-600 ${value === ALL_OPTION_VALUE ? 'font-semibold text-black-800' : ''}`}
>
{label}
</StickyOption>
))}
</Select>
<div className="mt-2 mb-2 min-h-[1.5rem] text-xs text-gray-400">
<div className="mt-2 mb-2 min-h-[1.5rem] text-xs text-stormGrey-400">
{selectedStacks.length === 0 ? (
<span className="rounded-full bg-gray-700/60 px-2 py-0.5 text-gray-300 ">
<span className="rounded-full bg-stormGrey-700/60 px-2 py-0.5 text-white ">
Alle Stacks
</span>
) : (
@@ -707,7 +710,7 @@ export function Logs() {
key={stackId}
type="button"
onClick={() => removeFilterValue(setSelectedStacks, stackId)}
className="rounded-full bg-purple-500/80 px-2 py-0.5 text-white transition hover:bg-purple-500/90 focus:outline-none focus:ring-2 focus:ring-purple-300 cursor-pointer"
className="rounded-full bg-lavenderSmoke-600/80 px-2 py-0.5 text-white transition hover:bg-lavenderSmoke-600/90 focus:outline-none focus:ring-2 focus:ring-lavenderSmoke-400 cursor-pointer"
title="Filter entfernen"
>
{stackLabelMap.get(stackId) ?? `Stack ${stackId}`}
@@ -721,7 +724,7 @@ export function Logs() {
<Select
multiple
onChange={noop}
className="text-gray-500"
className="text-stormGrey-500"
variant="static"
dismiss={{ itemPress: false }}
label="Status"
@@ -730,16 +733,22 @@ export function Logs() {
<StickyOption
key={value}
value={value}
onValueSelect={handleMultiSelectChange(setSelectedStatuses)}
onClick={() => selectStatuses(value)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
selectStatuses(value);
}
}}
className={`text-black-600 ${value === ALL_OPTION_VALUE ? 'font-semibold text-black-800' : ''}`}
>
{label}
</StickyOption>
))}
</Select>
<div className="mt-2 mb-2 min-h-[1.5rem] text-xs text-gray-400">
<div className="mt-2 mb-2 min-h-[1.5rem] text-xs text-stormGrey-400">
{selectedStatuses.length === 0 ? (
<span className="rounded-full bg-gray-700/60 px-2 py-0.5 text-gray-300">
<span className="rounded-full bg-stormGrey-700/60 px-2 py-0.5 text-white">
Alle Status
</span>
) : (
@@ -749,7 +758,7 @@ export function Logs() {
key={status}
type="button"
onClick={() => removeFilterValue(setSelectedStatuses, status)}
className="rounded-full bg-brown-500/80 px-2 py-0.5 text-white transition hover:bg-brown-500/90 focus:outline-none focus:ring-2 focus:ring-brown-300 cursor-pointer"
className="rounded-full bg-copperRust-600/80 px-2 py-0.5 text-white transition hover:bg-copperRust-600/90 focus:outline-none focus:ring-2 focus:ring-copperRust-400 cursor-pointer"
title="Filter entfernen"
>
{status}
@@ -763,7 +772,7 @@ export function Logs() {
<Select
multiple
onChange={noop}
className="text-gray-500"
className="text-stormGrey-500"
variant="static"
dismiss={{ itemPress: false }}
label="Redeploy-Typ"
@@ -772,7 +781,13 @@ export function Logs() {
<StickyOption
key={value}
value={value}
onValueSelect={handleMultiSelectChange(setSelectedRedeployTypes)}
onClick={() => selectRedeployTypes(value)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
selectRedeployTypes(value);
}
}}
className={`text-black-600 ${value === ALL_OPTION_VALUE ? 'font-semibold text-black-800' : ''}`}
>
{label}
@@ -780,9 +795,9 @@ export function Logs() {
))}
</Select>
<div className="mt-2 mb-2 min-h-[1.5rem] text-xs text-gray-400">
<div className="mt-2 mb-2 min-h-[1.5rem] text-xs text-stormGrey-400">
{selectedRedeployTypes.length === 0 ? (
<span className="rounded-full bg-gray-700/60 px-2 py-0.5 text-gray-300">
<span className="rounded-full bg-stormGrey-700/60 px-2 py-0.5 text-white">
Alle Typen
</span>
) : (
@@ -792,7 +807,7 @@ export function Logs() {
key={type}
type="button"
onClick={() => removeFilterValue(setSelectedRedeployTypes, type)}
className="rounded-full bg-brown-500/80 px-2 py-0.5 text-white transition hover:bg-brown-500/90 focus:outline-none focus:ring-2 focus:ring-brown-300 cursor-pointer"
className="rounded-full bg-citrusPunch-600/80 px-2 py-0.5 text-white transition hover:bg-citrusPunch-600/90 focus:outline-none focus:ring-2 focus:ring-citrusPunch-400 cursor-pointer"
title="Filter entfernen"
>
{REDEPLOY_TYPE_LABELS[type] ?? type}
@@ -806,7 +821,7 @@ export function Logs() {
<Select
multiple
onChange={noop}
className="text-gray-500"
className="text-stormGrey-500"
variant="static"
dismiss={{ itemPress: false }}
label="Endpoints"
@@ -815,16 +830,22 @@ export function Logs() {
<StickyOption
key={value}
value={value}
onValueSelect={handleMultiSelectChange(setSelectedEndpoints)}
onClick={() => selectEndpoints(value)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
selectEndpoints(value);
}
}}
className={`text-black-600 ${value === ALL_OPTION_VALUE ? 'font-semibold text-black-800' : ''}`}
>
{label}
</StickyOption>
))}
</Select>
<div className="mt-2 mb-2 min-h-[1.5rem] text-xs text-gray-400">
<div className="mt-2 mb-2 min-h-[1.5rem] text-xs text-stormGrey-400">
{selectedEndpoints.length === 0 ? (
<span className="rounded-full bg-gray-700/60 px-2 py-0.5 text-gray-300">
<span className="rounded-full bg-stormGrey-700/60 px-2 py-0.5 text-white">
Alle Endpoints
</span>
) : (
@@ -834,7 +855,7 @@ export function Logs() {
key={endpoint}
type="button"
onClick={() => removeFilterValue(setSelectedEndpoints, endpoint)}
className="rounded-full bg-teal-500/80 px-2 py-0.5 text-white transition hover:bg-teal-500/90 focus:outline-none focus:ring-2 focus:ring-brown-300 cursor-pointer"
className="rounded-full bg-emeraldMist-500/80 px-2 py-0.5 text-white transition hover:bg-emeraldMist-500/90 focus:outline-none focus:ring-2 focus:ring-emeraldMist-400 cursor-pointer"
title="Filter entfernen"
>
Endpoint {endpoint}
@@ -903,7 +924,7 @@ export function Logs() {
</CardBody>
</Card>
<Card>
<CardHeader variant="gradient" color="gray" className="mb-8 p-6">
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography
variant="h6"
color="white"
@@ -918,7 +939,7 @@ export function Logs() {
<select
value={perPage}
onChange={handlePerPageChange}
className="rounded-md border border-gray-700 bg-gray-900 px-2 py-1 text-sm text-gray-100 focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500"
className="rounded-md border border-gray-700 bg-gray-900 px-2 py-1 text-sm text-gray-100 focus:border-lavenderSmoke-500 focus:outline-none focus:ring-1 focus:ring-lavenderSmoke-500"
>
{PER_PAGE_OPTIONS.map(({ value, label }) => (
<option key={value} value={value}>
@@ -936,11 +957,11 @@ export function Logs() {
{["Zeitpunkt", "Stack", "Art", "Status", "Nachricht", "Endpoint", "Aktionen"].map((el) => (
<th
key={el}
className="border-b border-blue-gray-50 py-3 px-5 text-left"
className="border-b border-stormGrey-50 py-3 px-5 text-left"
>
<Typography
variant="small"
className="text-[11px] font-bold uppercase text-blue-gray-400"
className="text-[11px] font-bold uppercase text-stormGrey-400"
>
{el}
</Typography>
@@ -962,7 +983,7 @@ export function Logs() {
<td className={className}>
<Typography
variant="small"
className="mb-1 block text-xs font-medium text-blue-gray-600">
className="mb-1 block text-xs font-medium text-stormGrey-600">
{formatTimestamp(log.timestamp)}
</Typography>
</td>
@@ -973,7 +994,7 @@ export function Logs() {
variant="small"
>
{showStackId && (
<span className="text-xs text-gray-400">ID: {log.stackId}</span>
<span className="text-xs text-stormGrey-400">ID: {log.stackId}</span>
)}
</Typography>
</div>
+173 -55
View File
@@ -20,10 +20,10 @@ const UPDATE_STATUS_LABELS = {
};
const UPDATE_STATUS_STYLES = {
idle: "bg-gray-700 text-gray-200",
running: "bg-sky-600 text-white",
success: "bg-green-600 text-white",
error: "bg-red-600 text-white"
idle: "bg-stormGrey-700 text-gray-200",
running: "bg-arcticBlue-600 text-white",
success: "bg-mossGreen-600 text-white",
error: "bg-sunsetCoral-600 text-white"
};
const UPDATE_STAGE_LABELS = {
@@ -36,13 +36,13 @@ const UPDATE_STAGE_LABELS = {
};
const LOG_LEVEL_STYLES = {
info: "text-blue-700",
success: "text-green-700",
warning: "text-amber-700",
error: "text-red-700",
stdout: "text-gray-700",
stderr: "text-orange-700",
debug: "text-cyan-700"
info: "text-arcticBlue-700",
success: "text-mossGreen-700",
warning: "text-warmAmberGlow-700",
error: "text-sunsetCoral-700",
stdout: "text-stormGrey-700",
stderr: "text-warmAmberGlow-700",
debug: "text-lavenderSmoke-700"
};
const formatLogTimestamp = (value) => {
@@ -128,6 +128,7 @@ export function Maintenance() {
const maintenanceActive = Boolean(maintenanceMeta?.active);
const maintenanceMessage = maintenanceMeta?.message;
const maintenanceExtraType = maintenanceMeta?.extra?.type;
const updateRunning = Boolean(updateState?.running);
const [scriptDraft, setScriptDraft] = useState("");
@@ -432,10 +433,16 @@ export function Maintenance() {
setMaintenanceToggleLoading(true);
try {
await setMaintenanceMode({
active: nextActive,
message: nextActive ? (maintenanceMessage ?? undefined) : null
});
const shouldPreserveMessage = nextActive && Boolean(maintenanceExtraType);
const payload = { active: nextActive };
if (shouldPreserveMessage && maintenanceMessage) {
payload.message = maintenanceMessage;
} else {
payload.message = null;
}
await setMaintenanceMode(payload);
showToast({
variant: nextActive ? "info" : "success",
title: nextActive ? "Wartungsmodus aktiviert" : "Wartungsmodus deaktiviert",
@@ -449,7 +456,7 @@ export function Maintenance() {
} finally {
setMaintenanceToggleLoading(false);
}
}, [maintenanceLoading, maintenanceToggleLoading, maintenanceActive, updateRunning, setMaintenanceMode, maintenanceMessage, showToast]);
}, [maintenanceLoading, maintenanceToggleLoading, maintenanceActive, updateRunning, setMaintenanceMode, maintenanceMessage, maintenanceExtraType, showToast]);
const handleTriggerUpdate = useCallback(async () => {
if (maintenanceActive || updateRunning) {
@@ -589,8 +596,41 @@ export function Maintenance() {
</div>
</div>
)}
<Card>
<CardHeader variant="gradient" color="gray" className="mb-8 p-6">
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography
variant="h6"
color="white"
className="flex items-center justify-between"
>
<span>Wartungsmodus</span>
</Typography>
</CardHeader>
<CardBody className="flex flex-col gap-4 p-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<p className="text-sm antialiased font-sans text-sm font-light leading-normal text-inherit">
Schaltet StackPulse für Benutzer in den Wartungsmodus.
</p>
</div>
<Switch
checked={maintenanceActive}
disabled={maintenanceToggleLoading || maintenanceLoading || updateRunning}
onChange={(event) => handleMaintenanceToggle(event.target.checked)}
ripple={false}
color="amber"
/>
</div>
{maintenanceToggleLoading && (
<p className="text-xs text-stormGrey-500">Wartungsmodus wird aktualisiert</p>
)}
</CardBody>
</Card>
<Card>
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography
variant="h6"
color="white"
@@ -616,7 +656,7 @@ export function Maintenance() {
: "Portainer ist auf dem aktuellen Stand.")}
</p>
{portainerUpdatedAt && !portainerLoading && (
<p className="mt-1 text-xs text-gray-500 block antialiased font-sans">
<p className="mt-1 text-xs text-stormGrey-500 block antialiased font-sans">
Stand: {portainerUpdatedAt.toLocaleString("de-DE", {
hour: "2-digit",
minute: "2-digit",
@@ -665,23 +705,23 @@ export function Maintenance() {
</div>
{portainerStatus.edition && (
<div className="flex items-center justify-between">
<span className="text-gray-400">Edition</span>
<span className="text-stormGrey-400">Edition</span>
<span className="font-medium">{portainerStatus.edition}</span>
</div>
)}
{portainerStatus.build && (
<div className="flex items-center justify-between">
<span className="text-gray-400">Build</span>
<span className="text-stormGrey-400">Build</span>
<span className="font-medium">{portainerStatus.build}</span>
</div>
)}
{portainerStatus.errors?.latestVersion && (
<div className="rounded-md mt-8 border border-red-500/40 bg-red-900/40 px-3 py-2 text-xs">
<div className="rounded-md mt-8 border bg-sunsetCoral-600 text-white px-3 py-2 text-xs">
Neueste Version konnte nicht ermittelt werden: {portainerStatus.errors.latestVersion}
</div>
)}
{portainerStatus.errors?.container && (
<div className="rounded-md mt-8 border border-red-500/40 bg-red-900/40 px-3 py-2 text-xs">
<div className="rounded-md mt-8 border bg-sunsetCoral-600 text-white px-3 py-2 text-xs">
Container-Details konnten nicht ermittelt werden: {portainerStatus.errors.container}
</div>
)}
@@ -870,7 +910,7 @@ export function Maintenance() {
</Button>
</div>
{sshTestResult && (
<p className={`mt-3 text-xs ${sshTestResult.success ? 'text-green-800' : 'text-red-800'}`}>
<p className={`mt-3 text-xs ${sshTestResult.success ? 'text-mossGreen-600' : 'text-sunsetCoral-500'}`}>
{sshTestResult.success ? 'SSH-Verbindung erfolgreich.' : `SSH-Verbindung fehlgeschlagen: ${sshTestResult.error}`}
</p>
)}
@@ -905,7 +945,7 @@ export function Maintenance() {
</div>
{scriptConfig?.customUpdatedAt && (
<p className="mt-2 text-xs text-gray-500">
<p className="mt-2 text-xs text-stormGrey-500">
Zuletzt geändert: {formatCreatedAt(scriptConfig.customUpdatedAt)}
</p>
)}
@@ -940,29 +980,29 @@ export function Maintenance() {
<span>{updateFinishedAt}</span>
</div>
{updateStatusMessage && (
<div className="rounded-md border border-green-700 bg-green-800 px-3 py-2 text-xs text-white">
<div className="rounded-md border border-mossGreen-700 bg-mossGreen-800 px-3 py-2 text-xs text-white">
{updateStatusMessage}
</div>
)}
{updateState?.error && (
<div className="rounded-md border border-red-500/60 bg-red-900/40 px-3 py-2 text-xs text-red-200">
<div className="rounded-md border border-sunsetCoral-500/60 bg-sunsetCoral-900/40 px-3 py-2 text-xs text-white">
{updateState.error}
</div>
)}
{updateActionError && (
<div className="rounded-md border border-red-500/60 bg-red-900/40 px-3 py-2 text-xs text-red-200">
<div className="rounded-md border border-sunsetCoral-500/60 bg-sunsetCoral-900/40 px-3 py-2 text-xs text-white">
{updateActionError}
</div>
)}
</div>
<div className="mt-3 h-40 overflow-y-auto rounded-md border border-gray-700/20 bg-gray-950/70 p-3 font-mono text-[11px] leading-relaxed">
{updateLogs.length === 0 ? (
<p className="text-gray-500">Noch keine Protokolle vorhanden.</p>
<p className="text-stormGrey-500">Noch keine Protokolle vorhanden.</p>
) : (
updateLogs.map((entry, index) => (
<div key={`${entry.timestamp}-${index}`} className="mb-2 last:mb-0">
<span className="text-gray-500">[{formatLogTimestamp(entry.timestamp)}]</span>{" "}
<span className={(LOG_LEVEL_STYLES[entry.level] ?? "text-gray-300") + " whitespace-normal break-words"} style={{ overflowWrap: "anywhere", wordBreak: "normal" }}>
<span className={(LOG_LEVEL_STYLES[entry.level] ?? "text-stormGrey-300") + " whitespace-normal break-words"} style={{ overflowWrap: "anywhere", wordBreak: "normal" }}>
{entry.message}
</span>
</div>
@@ -984,46 +1024,124 @@ export function Maintenance() {
</CardBody>
</Card>
<Card>
<CardHeader
color="transparent"
floated={false}
shadow={false}
className="m-0 p-4 block antialiased font-sans"
<CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography
variant="h6"
color="white"
className="flex items-center justify-between"
>
<Typography variant="h5" color="blue-gray" className="block antialiased font-sans">
Wartungsmodus
<span>Doppelte Stacks</span>
</Typography>
</CardHeader>
<CardBody className="flex flex-col gap-4 p-4">
<div className="rounded-lg border border-warmAmberGlow-600 bg-warmAmberGlow-700 px-4 py-3 text-sm text-white">
Das Entfernen von deppelten Stacks ist noch nicht getestet. Bitte nicht in Produktivumgebungen einsetzen.
</div>
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<p className="text-sm text-blue-gray-200">
Schaltet StackPulse für Benutzer in den Wartungsmodus.
<p className="text-sm antialiased font-sans text-sm font-light leading-normal text-inherit">
{isDuplicatesDisabled
? "Wartungsmodus aktiv Duplikat-Verwaltung ist vorübergehend deaktiviert."
: duplicatesLoading
? "Analyse läuft…"
: totals.groups === 0
? "Keine Duplikate gefunden"
: `${totals.groups} Stack-Namen mit insgesamt ${totals.duplicateCount} Duplikaten gefunden`}
</p>
{maintenanceMessage ? (<p className="text-xs text-gray-500">Hinweis: {maintenanceMessage}</p>) : null}
<p className="text-xs text-gray-500">
{maintenanceActive
? `Aktiv seit ${maintenanceActivatedAt ?? "-"}.`
: `Zuletzt geändert: ${maintenanceUpdatedAt ?? "-"}.`}
{duplicatesUpdatedAt && !duplicatesLoading && !isDuplicatesDisabled && (
<p className="mt-1 text-xs text-gray-500">
Stand: {duplicatesUpdatedAt.toLocaleString("de-DE", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit"
})}
</p>
{updateRunning && (
<p className="text-xs text-amber-600">Portainer-Update läuft Umschalten vorübergehend gesperrt.</p>
)}
</div>
<Button
onClick={() => fetchDuplicates({ silent: false })}
disabled={isDuplicatesDisabled || duplicatesLoading || duplicatesRefreshing || activeCleanupId !== null}
className=""
>
Aktualisieren
</Button>
{duplicatesError && !isDuplicatesDisabled && (
<div className="rounded-lg border border-sunsetCoral-500/60 bg-sunsetCoral-900/40 px-4 py-3 text-sm text-white">
{duplicatesError}
</div>
)}
</div>
{duplicatesLoading ? (
<div className="flex flex-col gap-4 p-4">
Daten werden geladen
</div>
) : totals.groups === 0 || isDuplicatesDisabled ? (
<div className="rounded-xl border-mossGreen-500/80 bg-mossGreen-900/90 text-mossGreen-100 p-8 text-center text-sm text-white">
{isDuplicatesDisabled
? "Wartungsmodus aktiv Duplikat-Verwaltung ist vorübergehend deaktiviert."
: "Es wurden keine doppelten Stacks gefunden."}
</div>
) : (
<div className="space-y-5">
{duplicates.map((entry) => {
const canonicalId = entry?.canonical?.Id;
const duplicatesForEntry = entry?.duplicates || [];
const isProcessing = activeCleanupId === String(canonicalId);
return (
<div
key={canonicalId || entry.name}
className="rounded-xl border border-gray-700 bg-gray-800/70 p-6 shadow"
>
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-white">{entry.name}</h3>
<span className="rounded-full bg-amber-500/20 px-3 py-0.5 text-xs font-medium text-amber-200">
{duplicatesForEntry.length} Duplikat{duplicatesForEntry.length === 1 ? "" : "e"}
</span>
</div>
<p className="text-sm text-gray-300">
Behaltener Stack: ID {canonicalId} (Endpoint {entry?.canonical?.EndpointId ?? "-"})
</p>
<p className="text-xs text-gray-500">
Status: {maintenanceActive ? "Aktiv" : "Inaktiv"}
Typ: {resolveStackType(entry?.canonical?.Type)} Erstellt: {formatCreatedAt(entry?.canonical?.Created)}
</p>
</div>
<Switch
checked={maintenanceActive}
disabled={maintenanceToggleLoading || maintenanceLoading || updateRunning}
onChange={(event) => handleMaintenanceToggle(event.target.checked)}
ripple={false}
color="amber"
/>
<button
type="button"
onClick={() => handleCleanup(entry)}
disabled={isDuplicatesDisabled || isProcessing || duplicatesRefreshing || duplicatesLoading}
className="self-start rounded-lg bg-sunsetCoral-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-sunsetCoral-600 disabled:opacity-50"
>
{isProcessing ? "Bereinigung läuft…" : `Bereinigen (${duplicatesForEntry.length})`}
</button>
</div>
<div className="mt-5 grid gap-3">
{duplicatesForEntry.map((duplicate) => (
<div
key={duplicate.Id}
className="rounded-lg border border-sunsetCoral-500/40 bg-sunsetCoral-900/20 p-4 text-sm text-white"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-semibold text-white">ID: {duplicate.Id}</span>
<span>Endpoint: {duplicate.EndpointId ?? "-"}</span>
<span>Typ: {resolveStackType(duplicate.Type)}</span>
<span>Erstellt: {formatCreatedAt(duplicate.Created)}</span>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
{maintenanceToggleLoading && (
<p className="text-xs text-gray-500">Wartungsmodus wird aktualisiert</p>
)}
</CardBody>
</Card>