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 Analytics (nepcha.com) -->
<!-- Nepcha is a easy-to-use web analytics. No cookies and fully compliant with GDPR, CCPA and PECR. --> <!-- 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 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> <script type="module" crossorigin src="/assets/index-3cdb6846.js"></script>
<link rel="stylesheet" href="/assets/index-c9347383.css"> <link rel="stylesheet" href="/assets/index-422138e0.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+8 -8
View File
@@ -4,20 +4,20 @@ const ToastContext = createContext(null);
const VARIANT_STYLES = { const VARIANT_STYLES = {
success: { success: {
container: "border-green-500/80 bg-green-950/90 text-green-100", container: "border-mossGreen-500/80 bg-mossGreen-900/90 text-white",
accent: "bg-green-400" accent: "bg-mossGreen-400"
}, },
warning: { warning: {
container: "border-yellow-500/80 bg-yellow-950/90 text-yellow-100", container: "border-warmAmberGlow-500/80 bg-warmAmberGlow-900/90 text-white",
accent: "bg-yellow-400" accent: "bg-warmAmberGlow-400"
}, },
error: { error: {
container: "border-red-500/80 bg-red-950/90 text-red-100", container: "border-sunsetCoral-500/80 bg-sunsetCoral-900/90 text-white",
accent: "bg-red-400" accent: "bg-sunsetCoral-400"
}, },
info: { info: {
container: "border-sky-500/60 bg-sky-950/90 text-sky-100", container: "border-arcticBlue-500 bg-arcticBlue-900/90 text-white",
accent: "bg-sky-400" accent: "bg-arcticBlue-400"
} }
}; };
+65 -44
View File
@@ -18,7 +18,7 @@ import {
useSelect useSelect
} from "@material-tailwind/react"; } 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 { setOpen } = useSelect();
const reopen = useCallback(() => { const reopen = useCallback(() => {
@@ -31,20 +31,13 @@ const StickyOption = React.forwardRef(({ value, onValueSelect, onClick, onKeyDow
}); });
}, [setOpen]); }, [setOpen]);
const commitSelection = useCallback(() => {
if (typeof onValueSelect === "function") {
onValueSelect(value);
}
}, [onValueSelect, value]);
const handleClick = useCallback((event) => { const handleClick = useCallback((event) => {
if (typeof onClick === "function") { if (typeof onClick === "function") {
onClick(event); onClick(event);
} }
commitSelection();
reopen(); reopen();
}, [onClick, commitSelection, reopen]); }, [onClick, reopen]);
const handleKeyDown = useCallback((event) => { const handleKeyDown = useCallback((event) => {
if (typeof onKeyDown === "function") { if (typeof onKeyDown === "function") {
@@ -52,10 +45,9 @@ const StickyOption = React.forwardRef(({ value, onValueSelect, onClick, onKeyDow
} }
if (event.key === "Enter" || event.key === " ") { if (event.key === "Enter" || event.key === " ") {
commitSelection();
reopen(); reopen();
} }
}, [onKeyDown, commitSelection, reopen]); }, [onKeyDown, reopen]);
return ( return (
<Option <Option
@@ -71,10 +63,10 @@ const StickyOption = React.forwardRef(({ value, onValueSelect, onClick, onKeyDow
StickyOption.displayName = "StickyOption"; StickyOption.displayName = "StickyOption";
const STATUS_COLORS = { const STATUS_COLORS = {
success: "text-green-400", success: "text-mossGreen-500",
warning: "text-yellow-400", warning: "text-warmAmberGlow-500",
error: "text-red-400", error: "text-sunsetCoral-500",
started: "text-blue-300" started: "text-arcticBlue-500"
}; };
const formatTimestamp = (value) => { const formatTimestamp = (value) => {
@@ -419,7 +411,7 @@ export function Logs() {
} }
}, [filtersReady, currentFilters, perPage, page]); }, [filtersReady, currentFilters, perPage, page]);
const handleMultiSelectChange = (setter) => (valueOrEvent) => { const handleMultiSelectChange = useCallback((setter) => (valueOrEvent) => {
if (typeof valueOrEvent === "string") { if (typeof valueOrEvent === "string") {
const value = valueOrEvent; const value = valueOrEvent;
if (value === "") { if (value === "") {
@@ -453,7 +445,12 @@ export function Logs() {
setter(values); setter(values);
} }
setPage(1); 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) => { const removeFilterValue = (setter, value) => {
setter((prev) => prev.filter((entry) => entry !== value)); setter((prev) => prev.filter((entry) => entry !== value));
@@ -631,7 +628,7 @@ export function Logs() {
return ( return (
<div className="mt-12 mb-8 flex flex-col gap-12"> <div className="mt-12 mb-8 flex flex-col gap-12">
<Card> <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"> <Typography variant="h6" color="white">
<button <button
onClick={handleToggleFilters} onClick={handleToggleFilters}
@@ -679,7 +676,7 @@ export function Logs() {
<Select <Select
multiple multiple
onChange={noop} onChange={noop}
className="text-gray-500" className="text-stormGrey-500"
variant="static" variant="static"
dismiss={{ itemPress: false }} dismiss={{ itemPress: false }}
label="Stacks" label="Stacks"
@@ -688,16 +685,22 @@ export function Logs() {
<StickyOption <StickyOption
key={value} key={value}
value={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' : ''}`} className={`text-black-600 ${value === ALL_OPTION_VALUE ? 'font-semibold text-black-800' : ''}`}
> >
{label} {label}
</StickyOption> </StickyOption>
))} ))}
</Select> </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 ? ( {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 Alle Stacks
</span> </span>
) : ( ) : (
@@ -707,7 +710,7 @@ export function Logs() {
key={stackId} key={stackId}
type="button" type="button"
onClick={() => removeFilterValue(setSelectedStacks, stackId)} 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" title="Filter entfernen"
> >
{stackLabelMap.get(stackId) ?? `Stack ${stackId}`} {stackLabelMap.get(stackId) ?? `Stack ${stackId}`}
@@ -721,7 +724,7 @@ export function Logs() {
<Select <Select
multiple multiple
onChange={noop} onChange={noop}
className="text-gray-500" className="text-stormGrey-500"
variant="static" variant="static"
dismiss={{ itemPress: false }} dismiss={{ itemPress: false }}
label="Status" label="Status"
@@ -730,16 +733,22 @@ export function Logs() {
<StickyOption <StickyOption
key={value} key={value}
value={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' : ''}`} className={`text-black-600 ${value === ALL_OPTION_VALUE ? 'font-semibold text-black-800' : ''}`}
> >
{label} {label}
</StickyOption> </StickyOption>
))} ))}
</Select> </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 ? ( {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 Alle Status
</span> </span>
) : ( ) : (
@@ -749,7 +758,7 @@ export function Logs() {
key={status} key={status}
type="button" type="button"
onClick={() => removeFilterValue(setSelectedStatuses, status)} 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" title="Filter entfernen"
> >
{status} {status}
@@ -763,7 +772,7 @@ export function Logs() {
<Select <Select
multiple multiple
onChange={noop} onChange={noop}
className="text-gray-500" className="text-stormGrey-500"
variant="static" variant="static"
dismiss={{ itemPress: false }} dismiss={{ itemPress: false }}
label="Redeploy-Typ" label="Redeploy-Typ"
@@ -772,7 +781,13 @@ export function Logs() {
<StickyOption <StickyOption
key={value} key={value}
value={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' : ''}`} className={`text-black-600 ${value === ALL_OPTION_VALUE ? 'font-semibold text-black-800' : ''}`}
> >
{label} {label}
@@ -780,9 +795,9 @@ export function Logs() {
))} ))}
</Select> </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 ? ( {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 Alle Typen
</span> </span>
) : ( ) : (
@@ -792,7 +807,7 @@ export function Logs() {
key={type} key={type}
type="button" type="button"
onClick={() => removeFilterValue(setSelectedRedeployTypes, type)} 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" title="Filter entfernen"
> >
{REDEPLOY_TYPE_LABELS[type] ?? type} {REDEPLOY_TYPE_LABELS[type] ?? type}
@@ -806,7 +821,7 @@ export function Logs() {
<Select <Select
multiple multiple
onChange={noop} onChange={noop}
className="text-gray-500" className="text-stormGrey-500"
variant="static" variant="static"
dismiss={{ itemPress: false }} dismiss={{ itemPress: false }}
label="Endpoints" label="Endpoints"
@@ -815,16 +830,22 @@ export function Logs() {
<StickyOption <StickyOption
key={value} key={value}
value={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' : ''}`} className={`text-black-600 ${value === ALL_OPTION_VALUE ? 'font-semibold text-black-800' : ''}`}
> >
{label} {label}
</StickyOption> </StickyOption>
))} ))}
</Select> </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 ? ( {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 Alle Endpoints
</span> </span>
) : ( ) : (
@@ -834,7 +855,7 @@ export function Logs() {
key={endpoint} key={endpoint}
type="button" type="button"
onClick={() => removeFilterValue(setSelectedEndpoints, endpoint)} 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" title="Filter entfernen"
> >
Endpoint {endpoint} Endpoint {endpoint}
@@ -903,7 +924,7 @@ export function Logs() {
</CardBody> </CardBody>
</Card> </Card>
<Card> <Card>
<CardHeader variant="gradient" color="gray" className="mb-8 p-6"> <CardHeader variant="gradient" color="gray" className="mb-5 p-4">
<Typography <Typography
variant="h6" variant="h6"
color="white" color="white"
@@ -918,7 +939,7 @@ export function Logs() {
<select <select
value={perPage} value={perPage}
onChange={handlePerPageChange} 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 }) => ( {PER_PAGE_OPTIONS.map(({ value, label }) => (
<option key={value} value={value}> <option key={value} value={value}>
@@ -936,11 +957,11 @@ export function Logs() {
{["Zeitpunkt", "Stack", "Art", "Status", "Nachricht", "Endpoint", "Aktionen"].map((el) => ( {["Zeitpunkt", "Stack", "Art", "Status", "Nachricht", "Endpoint", "Aktionen"].map((el) => (
<th <th
key={el} 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 <Typography
variant="small" variant="small"
className="text-[11px] font-bold uppercase text-blue-gray-400" className="text-[11px] font-bold uppercase text-stormGrey-400"
> >
{el} {el}
</Typography> </Typography>
@@ -962,7 +983,7 @@ export function Logs() {
<td className={className}> <td className={className}>
<Typography <Typography
variant="small" 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)} {formatTimestamp(log.timestamp)}
</Typography> </Typography>
</td> </td>
@@ -973,7 +994,7 @@ export function Logs() {
variant="small" variant="small"
> >
{showStackId && ( {showStackId && (
<span className="text-xs text-gray-400">ID: {log.stackId}</span> <span className="text-xs text-stormGrey-400">ID: {log.stackId}</span>
)} )}
</Typography> </Typography>
</div> </div>
+173 -55
View File
@@ -20,10 +20,10 @@ const UPDATE_STATUS_LABELS = {
}; };
const UPDATE_STATUS_STYLES = { const UPDATE_STATUS_STYLES = {
idle: "bg-gray-700 text-gray-200", idle: "bg-stormGrey-700 text-gray-200",
running: "bg-sky-600 text-white", running: "bg-arcticBlue-600 text-white",
success: "bg-green-600 text-white", success: "bg-mossGreen-600 text-white",
error: "bg-red-600 text-white" error: "bg-sunsetCoral-600 text-white"
}; };
const UPDATE_STAGE_LABELS = { const UPDATE_STAGE_LABELS = {
@@ -36,13 +36,13 @@ const UPDATE_STAGE_LABELS = {
}; };
const LOG_LEVEL_STYLES = { const LOG_LEVEL_STYLES = {
info: "text-blue-700", info: "text-arcticBlue-700",
success: "text-green-700", success: "text-mossGreen-700",
warning: "text-amber-700", warning: "text-warmAmberGlow-700",
error: "text-red-700", error: "text-sunsetCoral-700",
stdout: "text-gray-700", stdout: "text-stormGrey-700",
stderr: "text-orange-700", stderr: "text-warmAmberGlow-700",
debug: "text-cyan-700" debug: "text-lavenderSmoke-700"
}; };
const formatLogTimestamp = (value) => { const formatLogTimestamp = (value) => {
@@ -128,6 +128,7 @@ export function Maintenance() {
const maintenanceActive = Boolean(maintenanceMeta?.active); const maintenanceActive = Boolean(maintenanceMeta?.active);
const maintenanceMessage = maintenanceMeta?.message; const maintenanceMessage = maintenanceMeta?.message;
const maintenanceExtraType = maintenanceMeta?.extra?.type;
const updateRunning = Boolean(updateState?.running); const updateRunning = Boolean(updateState?.running);
const [scriptDraft, setScriptDraft] = useState(""); const [scriptDraft, setScriptDraft] = useState("");
@@ -432,10 +433,16 @@ export function Maintenance() {
setMaintenanceToggleLoading(true); setMaintenanceToggleLoading(true);
try { try {
await setMaintenanceMode({ const shouldPreserveMessage = nextActive && Boolean(maintenanceExtraType);
active: nextActive, const payload = { active: nextActive };
message: nextActive ? (maintenanceMessage ?? undefined) : null
}); if (shouldPreserveMessage && maintenanceMessage) {
payload.message = maintenanceMessage;
} else {
payload.message = null;
}
await setMaintenanceMode(payload);
showToast({ showToast({
variant: nextActive ? "info" : "success", variant: nextActive ? "info" : "success",
title: nextActive ? "Wartungsmodus aktiviert" : "Wartungsmodus deaktiviert", title: nextActive ? "Wartungsmodus aktiviert" : "Wartungsmodus deaktiviert",
@@ -449,7 +456,7 @@ export function Maintenance() {
} finally { } finally {
setMaintenanceToggleLoading(false); setMaintenanceToggleLoading(false);
} }
}, [maintenanceLoading, maintenanceToggleLoading, maintenanceActive, updateRunning, setMaintenanceMode, maintenanceMessage, showToast]); }, [maintenanceLoading, maintenanceToggleLoading, maintenanceActive, updateRunning, setMaintenanceMode, maintenanceMessage, maintenanceExtraType, showToast]);
const handleTriggerUpdate = useCallback(async () => { const handleTriggerUpdate = useCallback(async () => {
if (maintenanceActive || updateRunning) { if (maintenanceActive || updateRunning) {
@@ -589,8 +596,41 @@ export function Maintenance() {
</div> </div>
</div> </div>
)} )}
<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"
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 <Typography
variant="h6" variant="h6"
color="white" color="white"
@@ -616,7 +656,7 @@ export function Maintenance() {
: "Portainer ist auf dem aktuellen Stand.")} : "Portainer ist auf dem aktuellen Stand.")}
</p> </p>
{portainerUpdatedAt && !portainerLoading && ( {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", { Stand: {portainerUpdatedAt.toLocaleString("de-DE", {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
@@ -665,23 +705,23 @@ export function Maintenance() {
</div> </div>
{portainerStatus.edition && ( {portainerStatus.edition && (
<div className="flex items-center justify-between"> <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> <span className="font-medium">{portainerStatus.edition}</span>
</div> </div>
)} )}
{portainerStatus.build && ( {portainerStatus.build && (
<div className="flex items-center justify-between"> <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> <span className="font-medium">{portainerStatus.build}</span>
</div> </div>
)} )}
{portainerStatus.errors?.latestVersion && ( {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} Neueste Version konnte nicht ermittelt werden: {portainerStatus.errors.latestVersion}
</div> </div>
)} )}
{portainerStatus.errors?.container && ( {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} Container-Details konnten nicht ermittelt werden: {portainerStatus.errors.container}
</div> </div>
)} )}
@@ -870,7 +910,7 @@ export function Maintenance() {
</Button> </Button>
</div> </div>
{sshTestResult && ( {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}`} {sshTestResult.success ? 'SSH-Verbindung erfolgreich.' : `SSH-Verbindung fehlgeschlagen: ${sshTestResult.error}`}
</p> </p>
)} )}
@@ -905,7 +945,7 @@ export function Maintenance() {
</div> </div>
{scriptConfig?.customUpdatedAt && ( {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)} Zuletzt geändert: {formatCreatedAt(scriptConfig.customUpdatedAt)}
</p> </p>
)} )}
@@ -940,29 +980,29 @@ export function Maintenance() {
<span>{updateFinishedAt}</span> <span>{updateFinishedAt}</span>
</div> </div>
{updateStatusMessage && ( {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} {updateStatusMessage}
</div> </div>
)} )}
{updateState?.error && ( {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} {updateState.error}
</div> </div>
)} )}
{updateActionError && ( {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} {updateActionError}
</div> </div>
)} )}
</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"> <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 ? ( {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) => ( updateLogs.map((entry, index) => (
<div key={`${entry.timestamp}-${index}`} className="mb-2 last:mb-0"> <div key={`${entry.timestamp}-${index}`} className="mb-2 last:mb-0">
<span className="text-gray-500">[{formatLogTimestamp(entry.timestamp)}]</span>{" "} <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} {entry.message}
</span> </span>
</div> </div>
@@ -984,46 +1024,124 @@ export function Maintenance() {
</CardBody> </CardBody>
</Card> </Card>
<Card> <Card>
<CardHeader <CardHeader variant="gradient" color="gray" className="mb-5 p-4">
color="transparent" <Typography
floated={false} variant="h6"
shadow={false} color="white"
className="m-0 p-4 block antialiased font-sans" className="flex items-center justify-between"
> >
<Typography variant="h5" color="blue-gray" className="block antialiased font-sans"> <span>Doppelte Stacks</span>
Wartungsmodus
</Typography> </Typography>
</CardHeader> </CardHeader>
<CardBody className="flex flex-col gap-4 p-4"> <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="flex items-start justify-between gap-4">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-sm text-blue-gray-200"> <p className="text-sm antialiased font-sans text-sm font-light leading-normal text-inherit">
Schaltet StackPulse für Benutzer in den Wartungsmodus. {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> </p>
{maintenanceMessage ? (<p className="text-xs text-gray-500">Hinweis: {maintenanceMessage}</p>) : null} {duplicatesUpdatedAt && !duplicatesLoading && !isDuplicatesDisabled && (
<p className="text-xs text-gray-500"> <p className="mt-1 text-xs text-gray-500">
{maintenanceActive Stand: {duplicatesUpdatedAt.toLocaleString("de-DE", {
? `Aktiv seit ${maintenanceActivatedAt ?? "-"}.` hour: "2-digit",
: `Zuletzt geändert: ${maintenanceUpdatedAt ?? "-"}.`} minute: "2-digit",
second: "2-digit"
})}
</p> </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"> <p className="text-xs text-gray-500">
Status: {maintenanceActive ? "Aktiv" : "Inaktiv"} Typ: {resolveStackType(entry?.canonical?.Type)} Erstellt: {formatCreatedAt(entry?.canonical?.Created)}
</p> </p>
</div> </div>
<Switch
checked={maintenanceActive} <button
disabled={maintenanceToggleLoading || maintenanceLoading || updateRunning} type="button"
onChange={(event) => handleMaintenanceToggle(event.target.checked)} onClick={() => handleCleanup(entry)}
ripple={false} disabled={isDuplicatesDisabled || isProcessing || duplicatesRefreshing || duplicatesLoading}
color="amber" 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> </div>
{maintenanceToggleLoading && (
<p className="text-xs text-gray-500">Wartungsmodus wird aktualisiert</p>
)} )}
</CardBody> </CardBody>
</Card> </Card>