29 Commits

Author SHA1 Message Date
root e3d802eeef Final Push 2025-10-13 18:44:15 +00:00
root ca59231480 Update 2025-10-12 20:42:37 +00:00
root 1d0ece6940 Update 2025-10-12 20:39:48 +00:00
root 5fcc7fdbf1 Update Dockerfile 2025-10-12 13:54:10 +00:00
root 3b12557a89 Update Dockerfile 2025-10-12 13:50:44 +00:00
root 666e4e67ad Update Dockerfile 2025-10-12 13:49:17 +00:00
root 11f4759e78 Merge feature/v03-notifications into dev 2025-10-12 13:33:50 +00:00
root de4a05a6e6 Update 2025-10-12 13:33:28 +00:00
root b682f3378c Merge feature/v03-notifications into dev 2025-10-11 19:08:46 +00:00
root 56891182d5 Portainer Update 2025-10-11 18:53:51 +00:00
root d25d34b8cd Portainer SSH Connection 2025-10-10 20:10:42 +00:00
root 016cb6671a Take all changes from b2 into b1 2025-10-10 10:36:42 +00:00
root 9313c01637 Update 2025-10-10 10:19:53 +00:00
root 96b1446292 Maintenance Page + Portainer Update 2025-10-10 10:18:35 +00:00
root 14b1459767 Update 2025-10-10 10:13:34 +00:00
root e2cc586584 Meine Änderungen 2025-10-10 09:36:07 +00:00
root c71025d717 Merge feature/v03-double_stack_remove into dev 2025-10-09 18:48:24 +00:00
root e8aeecec43 Maintenance Page / Double Stack Remove 2025-10-09 18:47:54 +00:00
root 4e63ae529e Maintenance Page and Doube Stack Find 2025-10-09 18:45:44 +00:00
root a8c01b9cb2 Merge feature/v03-notifications into dev 2025-10-09 18:21:58 +00:00
root b6579f511d Toast Notifications 2025-10-09 10:30:26 +00:00
root db46b366ab Sync README from dev 2025-10-09 08:57:36 +00:00
root efa732f347 Update 2025-10-09 08:57:13 +00:00
root a09a68ed39 Sync README from dev 2025-10-09 08:53:37 +00:00
root 81f7660768 Update 2025-10-09 08:52:55 +00:00
root a53a91caeb Update Readme 2025-10-09 08:52:14 +00:00
root 841a1713d2 Merge feature/v03-stack_filters into dev 2025-10-09 08:32:08 +00:00
root 737495d186 Minor frontend changes 2025-10-08 19:06:19 +00:00
root 6ed9b94d3a Stack Filter Begin 2025-10-08 15:03:42 +00:00
11 changed files with 3749 additions and 388 deletions
+3
View File
@@ -22,6 +22,9 @@ RUN npm run build
# ===============================
FROM node:20-alpine AS runtime
#Install SSH-Client
RUN apk add --no-cache openssh-client
# Arbeitsverzeichnis
WORKDIR /app/backend
+71
View File
@@ -0,0 +1,71 @@
import { db } from './index.js';
const CREATE_TABLE_SQL = `
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
)
`;
db.prepare(CREATE_TABLE_SQL).run();
const getSettingStmt = db.prepare('SELECT key, value, updated_at FROM settings WHERE key = ?');
const setSettingStmt = db.prepare(`
INSERT INTO settings (key, value, updated_at)
VALUES (@key, @value, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = CURRENT_TIMESTAMP
`);
const deleteSettingStmt = db.prepare('DELETE FROM settings WHERE key = ?');
export function getSetting(key) {
try {
return getSettingStmt.get(key) || null;
} catch (err) {
console.error(`❌ [Settings] Fehler beim Lesen des Settings "${key}":`, err.message);
return null;
}
}
export function setSetting(key, value) {
try {
setSettingStmt.run({ key, value });
return true;
} catch (err) {
console.error(`❌ [Settings] Fehler beim Speichern des Settings "${key}":`, err.message);
return false;
}
}
export function deleteSetting(key) {
try {
deleteSettingStmt.run(key);
return true;
} catch (err) {
console.error(`❌ [Settings] Fehler beim Löschen des Settings "${key}":`, err.message);
return false;
}
}
export function getJsonSetting(key, defaultValue = null) {
const row = getSetting(key);
if (!row || row.value === null || row.value === undefined) return defaultValue;
try {
return JSON.parse(row.value);
} catch (err) {
console.warn(`⚠️ [Settings] Konnte JSON für Setting "${key}" nicht parsen:`, err.message);
return defaultValue;
}
}
export function setJsonSetting(key, value) {
try {
const payload = value === undefined ? null : JSON.stringify(value);
return setSetting(key, payload);
} catch (err) {
console.error(`❌ [Settings] Fehler beim Serialisieren von Setting "${key}":`, err.message);
return false;
}
}
+1482 -261
View File
File diff suppressed because it is too large Load Diff
+63
View File
@@ -0,0 +1,63 @@
import { getJsonSetting, setJsonSetting } from '../db/settings.js';
const MAINTENANCE_KEY = 'maintenance_mode';
const DEFAULT_STATE = {
active: false,
message: null,
activatedAt: null,
updatedAt: null,
extra: null
};
let maintenanceState = loadState();
function loadState() {
const saved = getJsonSetting(MAINTENANCE_KEY, null);
if (!saved) {
const initial = { ...DEFAULT_STATE, updatedAt: new Date().toISOString() };
setJsonSetting(MAINTENANCE_KEY, initial);
return initial;
}
return {
...DEFAULT_STATE,
...saved
};
}
function persistState(state) {
maintenanceState = {
...DEFAULT_STATE,
...state,
updatedAt: new Date().toISOString()
};
setJsonSetting(MAINTENANCE_KEY, maintenanceState);
return maintenanceState;
}
export function getMaintenanceState() {
return { ...maintenanceState };
}
export function isMaintenanceModeActive() {
return Boolean(maintenanceState?.active);
}
export function activateMaintenanceMode({ message = null, extra = null } = {}) {
const now = new Date().toISOString();
return persistState({
active: true,
message,
extra,
activatedAt: now
});
}
export function deactivateMaintenanceMode({ message = null } = {}) {
return persistState({
active: false,
message,
extra: null,
activatedAt: null
});
}
+23 -1
View File
@@ -2,7 +2,9 @@ import React from "react";
import { NavLink, Route, Routes } from "react-router-dom";
import Stacks from "./Stacks.jsx";
import Logs from "./Logs.jsx";
import Maintenance from "./Maintenance.jsx";
import logo from "./assets/images/stackpulse.png";
import { useMaintenance } from "./context/MaintenanceContext.jsx";
const navLinkBase =
"px-4 py-2 rounded-md font-medium transition-colors duration-150";
@@ -11,19 +13,38 @@ const getNavClass = ({ isActive }) =>
`${navLinkBase} ${isActive ? "bg-purple-600 text-white" : "text-gray-300 hover:bg-gray-700"}`;
export default function App() {
const { maintenance, update } = useMaintenance();
const maintenanceActive = Boolean(maintenance?.active);
const maintenanceLabel = maintenance?.message || (update?.running ? "Portainer-Update läuft" : "Wartungsmodus aktiv");
return (
<div className="min-h-screen bg-gray-900 text-white">
<header className="bg-gray-800 shadow-md">
<div className="max-w-6xl mx-auto px-6 py-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div className="flex flex-col items-end gap-1">
<span className="text-xs text-gray-500">v0.2.1</span>
<span className="text-xs text-gray-500">v0.3</span>
{maintenanceActive && (
<span className="flex items-center gap-1 text-xs text-amber-300">
<span className="h-2 w-2 animate-pulse rounded-full bg-amber-400" />
{maintenanceLabel}
</span>
)}
<img src={logo} alt="StackPulse" className="h-10 w-auto" />
</div>
<nav className="flex gap-2 items-end">
<NavLink to="/" end className={getNavClass}>
Stacks
</NavLink>
<NavLink to="/maintenance" className={getNavClass}>
<span className="flex items-center gap-2">
Wartung
{maintenanceActive && (
<span className="h-2 w-2 animate-pulse rounded-full bg-amber-400" />
)}
</span>
</NavLink>
<NavLink to="/logs" className={getNavClass}>
Logs
</NavLink>
@@ -34,6 +55,7 @@ export default function App() {
<main className="max-w-6xl mx-auto px-6 py-6">
<Routes>
<Route path="/" element={<Stacks />} />
<Route path="/maintenance" element={<Maintenance />} />
<Route path="/logs" element={<Logs />} />
<Route path="*" element={<Stacks />} />
</Routes>
+14 -13
View File
@@ -547,22 +547,10 @@ export default function Logs() {
};
return (
<div className="space-y-4">
<div className="mx-auto max-w-6xl space-y-4 p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-xl font-semibold text-gray-100">Redeploy-Logs</h2>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2 text-sm text-gray-400">
<span>Einträge pro Seite</span>
<select
value={perPage}
onChange={handlePerPageChange}
className="rounded-md border border-gray-700 bg-gray-900/70 px-3 py-1.5 text-gray-200 focus:outline-none focus:ring-2 focus:ring-purple-500"
>
{PER_PAGE_OPTIONS.map(({ value, label }) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<button
onClick={() => handleExport('txt')}
disabled={actionLoading || loading}
@@ -830,6 +818,19 @@ export default function Logs() {
)}
</div>
<div className="flex flex-wrap items-center justify-end gap-2 text-sm text-gray-300">
<span>Einträge pro Seite</span>
<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"
>
{PER_PAGE_OPTIONS.map(({ value, label }) => (
<option key={value} value={value}>{label}</option>
))}
</select>
</div>
<div className="overflow-x-auto bg-gray-800/60 rounded-xl border border-gray-700">
<table className="min-w-full divide-y divide-gray-700">
<thead className="bg-gray-800">
File diff suppressed because it is too large Load Diff
+761 -97
View File
File diff suppressed because it is too large Load Diff
+121
View File
@@ -0,0 +1,121 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
const ToastContext = createContext(null);
const VARIANT_STYLES = {
success: {
container: "border-green-500/80 bg-green-950/90 text-green-100",
accent: "bg-green-400"
},
warning: {
container: "border-yellow-500/80 bg-yellow-950/90 text-yellow-100",
accent: "bg-yellow-400"
},
error: {
container: "border-red-500/80 bg-red-950/90 text-red-100",
accent: "bg-red-400"
},
info: {
container: "border-sky-500/60 bg-sky-950/90 text-sky-100",
accent: "bg-sky-400"
}
};
const DEFAULT_DURATION = 6000;
export default function ToastProvider({ children }) {
const [toasts, setToasts] = useState([]);
const timeoutsRef = useRef(new Map());
const dismissToast = useCallback((id) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
const handle = timeoutsRef.current.get(id);
if (handle && typeof window !== "undefined") {
window.clearTimeout(handle);
}
timeoutsRef.current.delete(id);
}, []);
const showToast = useCallback(({ id, title, description, variant = "info", duration = DEFAULT_DURATION } = {}) => {
const toastId = id ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const normalizedVariant = VARIANT_STYLES[variant] ? variant : "info";
setToasts((prev) => {
const withoutExisting = prev.filter((toast) => toast.id !== toastId);
return [
...withoutExisting,
{
id: toastId,
title,
description,
variant: normalizedVariant
}
];
});
if (duration !== null && duration !== Infinity && typeof window !== "undefined") {
const timeoutHandle = window.setTimeout(() => {
dismissToast(toastId);
}, duration);
const existingHandle = timeoutsRef.current.get(toastId);
if (existingHandle) {
window.clearTimeout(existingHandle);
}
timeoutsRef.current.set(toastId, timeoutHandle);
}
return toastId;
}, [dismissToast]);
useEffect(() => {
return () => {
if (typeof window === "undefined") return;
timeoutsRef.current.forEach((handle) => window.clearTimeout(handle));
timeoutsRef.current.clear();
};
}, []);
const contextValue = useMemo(() => ({ showToast, dismissToast }), [showToast, dismissToast]);
return (
<ToastContext.Provider value={contextValue}>
{children}
<div className="pointer-events-none fixed right-4 top-4 z-50 flex w-80 max-w-full flex-col gap-3">
{toasts.map((toast) => {
const styles = VARIANT_STYLES[toast.variant] ?? VARIANT_STYLES.info;
return (
<div
key={toast.id}
className={`pointer-events-auto overflow-hidden rounded-lg border px-4 py-3 shadow-xl backdrop-blur ${styles.container}`}
>
<div className="flex items-start gap-3">
<span className={`mt-1 h-2 flex-shrink-0 w-2 rounded-full ${styles.accent}`}></span>
<div className="flex-1">
{toast.title && <p className="text-sm font-semibold">{toast.title}</p>}
{toast.description && <p className="mt-1 text-sm leading-snug text-white/80">{toast.description}</p>}
</div>
<button
type="button"
onClick={() => dismissToast(toast.id)}
className="ml-2 rounded-md p-1 text-xs uppercase tracking-wide text-white/60 transition hover:text-white/90"
>
Schließen
</button>
</div>
</div>
);
})}
</div>
</ToastContext.Provider>
);
}
export function useToast() {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
}
+153
View File
@@ -0,0 +1,153 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import axios from "axios";
const MaintenanceContext = createContext(null);
const INITIAL_STATE = {
maintenance: null,
update: null,
script: null,
ssh: null,
loading: true,
error: "",
lastUpdated: null
};
export default function MaintenanceProvider({ children }) {
const [state, setState] = useState(INITIAL_STATE);
const pollingRef = useRef(null);
const wasRunningRef = useRef(false);
const applyState = useCallback((partial) => {
setState((prev) => ({
...prev,
...partial
}));
}, []);
const fetchConfig = useCallback(async () => {
applyState({ loading: true, error: "" });
try {
const response = await axios.get("/api/maintenance/config");
const data = response.data ?? {};
applyState({
maintenance: data.maintenance ?? null,
update: data.update ?? null,
script: data.script ?? null,
ssh: data.ssh ?? null,
loading: false,
error: "",
lastUpdated: new Date()
});
} catch (err) {
const message = err.response?.data?.error || err.message || "Fehler beim Laden der Wartungsdaten";
applyState({ loading: false, error: message });
}
}, [applyState]);
const refreshUpdateStatus = useCallback(async () => {
try {
const response = await axios.get("/api/maintenance/update-status");
const data = response.data ?? {};
setState((prev) => ({
...prev,
maintenance: data.maintenance ?? prev.maintenance,
update: data.update ?? prev.update,
error: ""
}));
} catch (err) {
const message = err.response?.data?.error || err.message || "Fehler beim Aktualisieren des Wartungsstatus";
setState((prev) => ({ ...prev, error: message }));
}
}, []);
useEffect(() => {
fetchConfig();
}, [fetchConfig]);
useEffect(() => {
if (state.update?.running) {
refreshUpdateStatus();
const intervalId = setInterval(() => {
refreshUpdateStatus();
}, 5000);
pollingRef.current = intervalId;
return () => clearInterval(intervalId);
}
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
return undefined;
}, [state.update?.running, refreshUpdateStatus]);
useEffect(() => {
const currentlyRunning = Boolean(state.update?.running);
if (!currentlyRunning && wasRunningRef.current) {
fetchConfig();
}
wasRunningRef.current = currentlyRunning;
}, [state.update?.running, fetchConfig]);
const triggerUpdate = useCallback(async (payload = {}) => {
await axios.post("/api/maintenance/portainer-update", payload);
await refreshUpdateStatus();
}, [refreshUpdateStatus]);
const saveScript = useCallback(async (script) => {
await axios.put("/api/maintenance/update-script", { script });
await fetchConfig();
}, [fetchConfig]);
const resetScript = useCallback(async () => {
await axios.delete("/api/maintenance/update-script");
await fetchConfig();
}, [fetchConfig]);
const saveSshConfig = useCallback(async (config) => {
await axios.put("/api/maintenance/ssh-config", config);
await fetchConfig();
}, [fetchConfig]);
const deleteSshConfig = useCallback(async () => {
await axios.delete("/api/maintenance/ssh-config");
await fetchConfig();
}, [fetchConfig]);
const testSshConnection = useCallback(async (config) => {
const response = await axios.post("/api/maintenance/test-ssh", config ?? {});
return response.data;
}, []);
const value = useMemo(() => ({
maintenance: state.maintenance,
update: state.update,
script: state.script,
ssh: state.ssh,
loading: state.loading,
error: state.error,
lastUpdated: state.lastUpdated,
refreshConfig: fetchConfig,
refreshUpdateStatus,
triggerUpdate,
saveScript,
resetScript,
saveSshConfig,
deleteSshConfig,
testSshConnection
}), [state, fetchConfig, refreshUpdateStatus, triggerUpdate, saveScript, resetScript, saveSshConfig, deleteSshConfig, testSshConnection]);
return (
<MaintenanceContext.Provider value={value}>
{children}
</MaintenanceContext.Provider>
);
}
export function useMaintenance() {
const context = useContext(MaintenanceContext);
if (!context) {
throw new Error('useMaintenance must be used within a MaintenanceProvider');
}
return context;
}
+6
View File
@@ -2,12 +2,18 @@ import React from "react";
import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App.jsx";
import ToastProvider from "./components/ToastProvider.jsx";
import MaintenanceProvider from "./context/MaintenanceContext.jsx";
import './index.css'; // Tailwind CSS
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<BrowserRouter>
<MaintenanceProvider>
<ToastProvider>
<App />
</ToastProvider>
</MaintenanceProvider>
</BrowserRouter>
</React.StrictMode>
);