diff --git a/Tickets-Demo.html b/Tickets-Demo.html new file mode 100644 index 0000000..5ab507a --- /dev/null +++ b/Tickets-Demo.html @@ -0,0 +1,1074 @@ + + + + + +IT Nexus — Tickets + + + + + +
+ +
+
Tickets
+
+
SG
+
+
+ + +
+ + +
+
+
+ Tickets + 851 +
+ + + +
+ Alle + Offen + In Bearbeitung + Gelöst + Geschlossen +
+ +
+ Alle + Kritisch + Hoch + Mittel + Niedrig +
+
+ + +
+
+
Gesamt
+
851
+
+
+
Offen
+
1
+
+
+
In Bearbeitung
+
0
+
+
+
Kritisch
+
0
+
+
+ + +
+
+ + +
+ + + + + +
+ + +
+
🎫
+
+
Zugangsdaten - Drucker Zugänge und Passwort Pflege
+
TK-2026-0001 · Allgemein · Erstellt vor 78 Tagen
+
+ Hoch + Offen + Web +
+
+
+ +
+ +
+
Offen
+
In Bearbeitung
+
Gelöst
+
Geschlossen
+
+
+ +
+
+ + +
+
+
Erstellt
+
15.03.2026
+
von Simon Grüßing
+
+
+
Priorität
+
Hoch
+
+
+
Zugewiesen an
+
? Nicht zugewiesen
+
+
+
SLA
+
78 Tage offen
+
⚠ Überfällig
+
+
+ + +
+
Beschreibung
+
Kommentare 2
+
Verlauf
+
Verknüpfte Assets
+
+ + +
+
+
Beschreibung
+
+ Zugangsdaten für Drucker müssen gepflegt werden. Aktuelle Passwörter sind nicht dokumentiert. + Bitte alle Drucker-Zugänge sammeln und im Passwort-Manager hinterlegen. +
+
+ +
+
+
Details
+
+ Kategorie + Allgemein +
+
+ Quelle + Web +
+
+ Erstellt am + 15.03.2026 +
+
+ Zuletzt aktualisiert + 12.04.2026 +
+
+ Ticket-Nummer + TK-2026-0001 +
+
+ +
+
Zuweisung & SLA
+
+ Zugewiesen an + +
+
+ Ersteller + Simon Grüßing +
+
+ SLA-Status + ⚠ 78 Tage überfällig +
+
+
SLA-Auslastung
+
+
+
+
100% — Überschritten
+
+
+
+
+ + +
+
+
Kommentare (2)
+ +
+
SG
+
+
Simon Grüßing · vor 45 Tagen
+
Habe die Drucker in der Produktion bereits überprüft. Passwörter werden noch gesammelt.
+
+
+ +
+
SG
+
+
Simon Grüßing · vor 20 Tagen
+
Drucker im Büro sind dokumentiert. Produktion steht noch aus.
+
+
+ +
+ + +
+
+
+ + +
+
+
Aktivitätsverlauf
+
+
+
Ticket erstellt
+
15.03.2026 · von Simon Grüßing
+
+
+
Status geändert: OffenIn Bearbeitung
+
01.04.2026 · von Simon Grüßing
+
+
+
Status geändert: In BearbeitungOffen (zurückgesetzt)
+
15.04.2026 · von Simon Grüßing
+
+
+
+
+ + +
+
+
Verknüpfte Assets
+
+
🖥️
+
Keine Assets verknüpft
+ +
+
+
+ +
+
+
+ + + + diff --git a/backend/src/db/seed.js b/backend/src/db/seed.js index c54b802..a9425fd 100644 --- a/backend/src/db/seed.js +++ b/backend/src/db/seed.js @@ -497,6 +497,18 @@ async function initializeDatabase() { created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP )`, + // Entra Profil-Sync: Cronjob-Logs + Avatar-URL + `CREATE TABLE IF NOT EXISTS cron_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_name TEXT NOT NULL, + started_at DATETIME DEFAULT CURRENT_TIMESTAMP, + finished_at DATETIME, + status TEXT DEFAULT 'running', + message TEXT, + details TEXT + )`, + `CREATE INDEX IF NOT EXISTS idx_cron_logs_job ON cron_logs(job_name, started_at)`, + `ALTER TABLE users ADD COLUMN avatar_url TEXT`, ]; for (const migration of migrations) { try { diff --git a/backend/src/routes/cron.routes.js b/backend/src/routes/cron.routes.js new file mode 100644 index 0000000..cf3e40f --- /dev/null +++ b/backend/src/routes/cron.routes.js @@ -0,0 +1,60 @@ +const express = require('express'); +const router = express.Router(); +const { authenticateToken } = require('../middleware/auth'); +const { requireSuperAdmin } = require('../middleware/roleCheck'); +const { asyncHandler } = require('../middleware/errorHandler'); +const Database = require('better-sqlite3'); +const DB_PATH = process.env.DATABASE_PATH || './database.sqlite'; + +router.use(authenticateToken); +router.use(requireSuperAdmin); + +// Alle Cronjob-Definitionen (statisch) +const CRON_JOBS = [ + { name: 'entra_profile_sync', label: 'Entra Profil-Sync', description: 'Synchronisiert Profildaten und Fotos aus Azure AD', schedule: 'Täglich 02:00 Uhr', icon: '👤', canRun: true }, + { name: 'imap_ticket_import', label: 'E-Mail Ticket-Import', description: 'Importiert eingehende E-Mails als Tickets', schedule: 'Alle 30 Sekunden', icon: '📧', canRun: false }, + { name: 'mdo_alert_poll', label: 'MDO Alert-Polling', description: 'Holt neue Security-Alerts aus Microsoft Defender', schedule: 'Alle 5 Minuten', icon: '🛡️', canRun: false }, + { name: 'proxmox_sync', label: 'Proxmox Sync', description: 'Synchronisiert VM/CT-Status von Proxmox', schedule: 'Alle 5 Minuten', icon: '🖥️', canRun: false }, + { name: 'monitoring_status', label: 'Monitoring Status', description: 'Aktualisiert Online/Offline-Status der Agents', schedule: 'Kontinuierlich', icon: '📡', canRun: false }, + { name: 'health_history', label: 'Health-History', description: 'Zeichnet API- und DB-Status auf', schedule: 'Alle 5 Minuten', icon: '💚', canRun: false }, + { name: 'escalation_check', label: 'Eskalations-Check', description: 'Prüft überfällige Tickets und sendet Eskalations-Mails', schedule: 'Stündlich', icon: '🚨', canRun: false }, + { name: 'shares_cleanup', label: 'Shares-Cleanup', description: 'Löscht abgelaufene / verbrauchte sichere Links', schedule: 'Alle 15 Minuten', icon: '🔗', canRun: false }, + { name: 'stock_threshold_check', label: 'Mindestbestand-Check', description: 'Prüft Lagerbestände und sendet Warnungen', schedule: 'Täglich 07:00 Uhr', icon: '📦', canRun: false }, + { name: 'weekly_report', label: 'Wöchentlicher Bericht', description: 'Versendet den wöchentlichen Ticket-Report', schedule: 'Montag 08:00 Uhr', icon: '📊', canRun: false }, +]; + +// GET /api/cron — alle Jobs mit letztem Log +router.get('/', asyncHandler(async (req, res) => { + const db = new Database(DB_PATH); + const jobs = CRON_JOBS.map(job => { + const lastLog = db.prepare(` + SELECT * FROM cron_logs WHERE job_name = ? ORDER BY started_at DESC LIMIT 1 + `).get(job.name); + return { ...job, lastRun: lastLog || null }; + }); + db.close(); + res.json({ status: 'success', data: jobs }); +})); + +// GET /api/cron/:name/logs — Logs für einen Job +router.get('/:name/logs', asyncHandler(async (req, res) => { + const db = new Database(DB_PATH); + const logs = db.prepare(` + SELECT * FROM cron_logs WHERE job_name = ? ORDER BY started_at DESC LIMIT 50 + `).all(req.params.name); + db.close(); + res.json({ status: 'success', data: logs }); +})); + +// POST /api/cron/:name/run — Job manuell auslösen +router.post('/:name/run', asyncHandler(async (req, res) => { + const { name } = req.params; + if (name === 'entra_profile_sync') { + const { syncEntraProfiles } = require('../services/entraSync.service'); + syncEntraProfiles().catch(console.error); // async, nicht awaiten + return res.json({ status: 'success', message: 'Job gestartet' }); + } + res.status(404).json({ status: 'error', message: 'Job nicht gefunden oder nicht manuell auslösbar' }); +})); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index b9652e9..e561c43 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -177,6 +177,8 @@ const tvRoutes = require('./routes/tv.routes'); app.use('/api/tv', tvRoutes); const feedbackRoutes = require('./routes/feedback.routes'); app.use('/api/feedback', feedbackRoutes); +const cronRoutes = require('./routes/cron.routes'); +app.use('/api/cron', cronRoutes); // Static file serving for uploads (PDFs) const path = require('path'); @@ -415,6 +417,16 @@ async function startServer() { console.log('🖥️ Proxmox Monitoring gestartet (alle 5 Minuten)'); } + // Entra Profil-Sync (täglich 02:00 Uhr) + if (process.env.AZURE_TENANT_ID && process.env.AZURE_CLIENT_ID) { + const { syncEntraProfiles } = require('./services/entraSync.service'); + cron.schedule('0 2 * * *', async () => { + console.log('[Cron] Entra Profil-Sync gestartet'); + try { await syncEntraProfiles(); } catch (e) { console.error('[Cron] Entra Sync Fehler:', e.message); } + }, { timezone: 'Europe/Berlin' }); + console.log('👤 Entra Profil-Sync konfiguriert (täglich 02:00 Uhr)'); + } + // Start server app.listen(PORT, () => { console.log('═══════════════════════════════════════════'); diff --git a/backend/src/services/entraSync.service.js b/backend/src/services/entraSync.service.js new file mode 100644 index 0000000..6e40b4a --- /dev/null +++ b/backend/src/services/entraSync.service.js @@ -0,0 +1,99 @@ +const { getClientToken } = require('./graph.service'); +const Database = require('better-sqlite3'); +const path = require('path'); +const fs = require('fs'); + +const DB_PATH = process.env.DATABASE_PATH || './database.sqlite'; + +async function syncEntraProfiles() { + const db = new Database(DB_PATH); + const startedAt = new Date().toISOString(); + + // Log starten + const logId = db.prepare(` + INSERT INTO cron_logs (job_name, started_at, status) VALUES (?, ?, 'running') + `).run('entra_profile_sync', startedAt).lastInsertRowid; + + try { + const token = await getClientToken(); + const users = db.prepare(`SELECT id, azure_id, username FROM users WHERE azure_id IS NOT NULL AND azure_id != ''`).all(); + + let synced = 0, errors = 0, photos = 0; + + for (const user of users) { + try { + // Profildaten holen + const profileRes = await fetch( + `https://graph.microsoft.com/v1.0/users/${user.azure_id}?$select=jobTitle,department,mobilePhone,businessPhones,officeLocation,employeeHireDate,displayName,mail,givenName,surname`, + { headers: { Authorization: `Bearer ${token}` } } + ); + + if (profileRes.ok) { + const p = await profileRes.json(); + const phone = p.mobilePhone || (p.businessPhones?.[0]) || null; + + db.prepare(` + UPDATE users SET + position = COALESCE(?, position), + department = COALESCE(?, department), + phone = COALESCE(?, phone), + location = COALESCE(?, location), + joined_date = COALESCE(?, joined_date), + first_name = COALESCE(?, first_name), + last_name = COALESCE(?, last_name) + WHERE id = ? + `).run( + p.jobTitle || null, + p.department || null, + phone, + p.officeLocation || null, + p.employeeHireDate ? p.employeeHireDate.split('T')[0] : null, + p.givenName || null, + p.surname || null, + user.id + ); + synced++; + } + + // Foto holen + const photoRes = await fetch( + `https://graph.microsoft.com/v1.0/users/${user.azure_id}/photo/$value`, + { headers: { Authorization: `Bearer ${token}` } } + ); + + if (photoRes.ok) { + const avatarDir = path.join(process.env.UPLOAD_PATH || path.join(__dirname, '../../uploads'), 'avatars'); + if (!fs.existsSync(avatarDir)) fs.mkdirSync(avatarDir, { recursive: true }); + + const buffer = Buffer.from(await photoRes.arrayBuffer()); + const filename = `${user.azure_id}.jpg`; + fs.writeFileSync(path.join(avatarDir, filename), buffer); + + db.prepare(`UPDATE users SET avatar_url = ? WHERE id = ?`) + .run(`/uploads/avatars/${filename}`, user.id); + photos++; + } + + } catch (err) { + console.error(`[Entra Sync] Fehler bei User ${user.username}:`, err.message); + errors++; + } + } + + const message = `${synced} Profile synchronisiert, ${photos} Fotos gespeichert, ${errors} Fehler`; + db.prepare(`UPDATE cron_logs SET status='success', finished_at=?, message=?, details=? WHERE id=?`) + .run(new Date().toISOString(), message, JSON.stringify({ synced, photos, errors, total: users.length }), logId); + + console.log(`[Entra Sync] ${message}`); + return { synced, photos, errors }; + + } catch (err) { + db.prepare(`UPDATE cron_logs SET status='error', finished_at=?, message=? WHERE id=?`) + .run(new Date().toISOString(), err.message, logId); + throw err; + } finally { + db.close(); + } +} + +module.exports = { syncEntraProfiles }; diff --git a/docker-compose.yml b/docker-compose.yml index 2d9c7ad..d9dcadc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -53,6 +53,8 @@ services: - PROXMOX_HOST=https://192.168.0.184:8006 - PROXMOX_TOKEN=root@pam!monitoring=ca287965-3d33-49ec-a069-1bb80fd2e95d - PROXMOX_NODE=hve-01 + # GitHub API (Feedback-System) + - GITHUB_TOKEN=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 volumes: # Persistente Datenbank - bleibt auch nach Container-Neustart erhalten - fido-data:/app/data diff --git a/frontend/src/pages/AssetsPage.jsx b/frontend/src/pages/AssetsPage.jsx index 6e740b9..07f7ef3 100644 --- a/frontend/src/pages/AssetsPage.jsx +++ b/frontend/src/pages/AssetsPage.jsx @@ -96,8 +96,8 @@ const relativeTime = (d) => { }; const isOnline = (agent) => { - if (!agent || !agent.last_seen) return false; - const diff = Date.now() - new Date(agent.last_seen).getTime(); + if (!agent || !agent.last_checkin) return false; + const diff = Date.now() - new Date(agent.last_checkin).getTime(); return diff < 5 * 60 * 1000; }; @@ -345,7 +345,7 @@ const AssetsPage = () => { try { const [aRes, agRes, stRes] = await Promise.all([ authFetch(`${API}/assets`), - authFetch(`${API}/monitoring/agents`), + authFetch(`${API}/monitoring`), authFetch(`${API}/assets/stats`), ]); const aData = aRes.ok ? await aRes.json() : {}; @@ -383,9 +383,9 @@ const AssetsPage = () => { authFetch(`${API}/assets/${selectedId}/history`).then(r => r.ok ? r.json() : []), authFetch(`${API}/assets/${selectedId}/inspections`).then(r => r.ok ? r.json() : []), ]).then(([d, h, ins]) => { - setDetail(d); - setHistory(Array.isArray(h) ? h : (h?.history || [])); - setInspections(Array.isArray(ins) ? ins : (ins?.inspections || [])); + setDetail(d?.data ?? d); + setHistory(Array.isArray(h) ? h : (h?.data || h?.history || [])); + setInspections(Array.isArray(ins) ? ins : (ins?.data || ins?.inspections || [])); }).catch(() => toast.error('Fehler beim Laden der Details')) .finally(() => setDetailLoading(false)); }, [selectedId]); @@ -408,28 +408,40 @@ const AssetsPage = () => { if (statusFilter !== 'all' && a.status !== statusFilter) return false; if (!searchTerm) return true; const q = searchTerm.toLowerCase(); - if (searchMode === 'asset') { - return ( - a.name?.toLowerCase().includes(q) || - a.serial_number?.toLowerCase().includes(q) || - a.inventory_number?.toLowerCase().includes(q) || - a.model?.toLowerCase().includes(q) - ); - } else { - return a.assigned_to_username?.toLowerCase().includes(q) || - a.assigned_to_name?.toLowerCase().includes(q); - } + return ( + a.name?.toLowerCase().includes(q) || + a.serial_number?.toLowerCase().includes(q) || + a.inventory_number?.toLowerCase().includes(q) || + a.model?.toLowerCase().includes(q) || + a.assigned_to_username?.toLowerCase().includes(q) || + a.assigned_to_name?.toLowerCase().includes(q) || + a.manufacturer?.toLowerCase().includes(q) + ); }); - /* ── Agent lookup by asset name ── */ - const getAgent = (assetName) => { - if (!assetName || !agents.length) return null; + /* ── Agent lookup: by serial, IP or hostname ── */ + const getAgent = (asset) => { + if (!asset || !agents.length) return null; return agents.find(ag => - ag.hostname?.toLowerCase() === assetName.toLowerCase() || - ag.computer_name?.toLowerCase() === assetName.toLowerCase() + (asset.serial_number && ag.hardware_serial && ag.hardware_serial.toLowerCase() === asset.serial_number.toLowerCase()) || + (asset.ip_address && ag.ip_address && ag.ip_address === asset.ip_address) || + (asset.name && ag.hostname && ag.hostname.toLowerCase() === asset.name.toLowerCase()) ) || null; }; + /* ── PDF öffnen mit Auth-Token ── */ + const openPdf = async (url) => { + try { + const res = await authFetch(url); + if (!res.ok) throw new Error('Fehler beim Laden'); + const blob = await res.blob(); + const blobUrl = URL.createObjectURL(blob); + window.open(blobUrl, '_blank'); + } catch (e) { + toast.error('PDF konnte nicht geöffnet werden: ' + e.message); + } + }; + /* ── Actions ── */ const handleUnassign = async () => { if (!detail) return; @@ -504,7 +516,7 @@ const AssetsPage = () => { } if (!detail) return null; - const agent = getAgent(detail.name); + const agent = getAgent(detail); const online = isOnline(agent); const bookValue = calcBookValue(detail); const depPercent = calcDepPercent(detail); @@ -564,12 +576,12 @@ const AssetsPage = () => { {showMoreMenu && (
{detail.status === 'zugewiesen' && ( )} @@ -620,7 +632,7 @@ const AssetsPage = () => {
🤖 Agent-Sync
- {agent ? relativeTime(agent.last_seen) : '—'} + {agent ? relativeTime(agent.last_checkin) : '—'}
{agent ? `v${agent.agent_version || '?'} · ${online ? 'aktiv' : 'offline'}` : 'Kein Agent'} @@ -783,7 +795,7 @@ const AssetsPage = () => { )}
{[ - ['Letzter Check-in', relativeTime(agent.last_seen), online ? '#34d399' : null], + ['Letzter Check-in', relativeTime(agent.last_checkin), online ? '#34d399' : null], ['Agent-Version', agent.agent_version ? `v${agent.agent_version}` : '—'], ['Hostname', agent.hostname || agent.computer_name || '—'], ].map(([label, val, color]) => ( @@ -1001,7 +1013,7 @@ const AssetsPage = () => { ['Ausstehend', agent.pending_updates !== undefined ? `${agent.pending_updates} Updates` : undefined, agent.pending_updates > 0 ? '#f59e0b' : '#34d399'], ['Letztes Update', agent.last_update_date ? fmtDate(agent.last_update_date) : undefined], ['Agent-Version', agent.agent_version ? `v${agent.agent_version}` : undefined], - ['Letzter Check-in', relativeTime(agent.last_seen), online ? '#34d399' : null], + ['Letzter Check-in', relativeTime(agent.last_checkin), online ? '#34d399' : null], ].map(([label, val, color]) => val !== undefined ? (
{label} @@ -1030,7 +1042,7 @@ const AssetsPage = () => { {detail.status === 'zugewiesen' && (
window.open(`${API}/assets/${detail.id}/handover-protocol`, '_blank')} + onClick={() => openPdf(`${API}/assets/${detail.id}/handover-protocol`)} >
📋
@@ -1099,25 +1111,13 @@ const AssetsPage = () => { {assets.length}
- {/* Suchmodus Toggle */} -
- - -
- {/* Suchfeld */}
- {searchMode === 'user' ? '👤' : '🔍'} + 🔍 setSearchTerm(e.target.value)} /> @@ -1200,7 +1200,7 @@ const AssetsPage = () => {
)} {filteredAssets.map(asset => { - const agent = getAgent(asset.name); + const agent = getAgent(asset); const online = isOnline(agent); return (
(
{/* end constrained header/tabs */} {/* ── Constrained tab content (all except email-design) ─────────── */} -
+
{/* ── CATEGORIES TAB ────────────────────────────────────────────── */} {tab === 'categories' && ( @@ -430,6 +432,9 @@ export default function SettingsPage() {
)} + {/* ── CRONJOBS TAB ──────────────────────────────────────────────── */} + {tab === 'cronjobs' && } +
{/* end constrained tab content */} {/* ── EMAIL DESIGN TAB – full width ─────────────────────────────── */} @@ -797,6 +802,226 @@ export default function SettingsPage() { ); } +// ── Cronjobs Tab ───────────────────────────────────────────────────────────── +const authFetchCron = (url, opts = {}) => { + const token = localStorage.getItem('token'); + const apiBase = process.env.REACT_APP_API_URL || '/api'; + return fetch(`${apiBase}${url}`, { + ...opts, + headers: { ...(opts.headers || {}), Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + }); +}; + +const fmtDuration = (started, finished) => { + if (!started || !finished) return '—'; + const ms = new Date(finished) - new Date(started); + if (ms < 1000) return `${ms}ms`; + if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`; + return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`; +}; + +const fmtDateTime = (d) => { + if (!d) return '—'; + return new Date(d).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); +}; + +function CronJobsTab() { + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(true); + const [logModal, setLogModal] = useState(null); // { job } | null + const [logs, setLogs] = useState([]); + const [logsLoading, setLogsLoading] = useState(false); + const [running, setRunning] = useState({}); // { [jobName]: true } + + const loadJobs = useCallback(async () => { + try { + const r = await authFetchCron('/cron'); + const d = await r.json(); + setJobs(d.data || []); + } catch (e) { + toast.error('Fehler beim Laden der Cronjobs'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { loadJobs(); }, [loadJobs]); + + const openLogs = async (job) => { + setLogModal(job); + setLogsLoading(true); + setLogs([]); + try { + const r = await authFetchCron(`/cron/${job.name}/logs`); + const d = await r.json(); + setLogs(d.data || []); + } catch { + setLogs([]); + } finally { + setLogsLoading(false); + } + }; + + const runJob = async (job) => { + setRunning(p => ({ ...p, [job.name]: true })); + try { + const r = await authFetchCron(`/cron/${job.name}/run`, { method: 'POST' }); + const d = await r.json(); + if (r.ok) { + toast.success(`${job.label} gestartet`); + setTimeout(() => loadJobs(), 3000); // Nach 3s neu laden + } else { + toast.error(d.message || 'Fehler beim Starten'); + } + } catch { + toast.error('Fehler beim Starten des Jobs'); + } finally { + setTimeout(() => setRunning(p => ({ ...p, [job.name]: false })), 2000); + } + }; + + const statusBadge = (lastRun) => { + if (!lastRun) return { label: 'Noch nie', color: '#64748b', bg: 'rgba(100,116,139,0.1)', border: 'rgba(100,116,139,0.2)' }; + if (lastRun.status === 'success') return { label: 'Erfolgreich', color: '#10b981', bg: 'rgba(16,185,129,0.1)', border: 'rgba(16,185,129,0.2)' }; + if (lastRun.status === 'error') return { label: 'Fehler', color: '#ef4444', bg: 'rgba(239,68,68,0.1)', border: 'rgba(239,68,68,0.2)' }; + if (lastRun.status === 'running') return { label: 'Läuft', color: '#f59e0b', bg: 'rgba(245,158,11,0.1)', border: 'rgba(245,158,11,0.2)' }; + return { label: lastRun.status, color: '#64748b', bg: 'rgba(100,116,139,0.1)', border: 'rgba(100,116,139,0.2)' }; + }; + + const logStatusBadge = (status) => { + if (status === 'success') return { label: 'Erfolgreich', color: '#10b981', bg: 'rgba(16,185,129,0.1)' }; + if (status === 'error') return { label: 'Fehler', color: '#ef4444', bg: 'rgba(239,68,68,0.1)' }; + if (status === 'running') return { label: 'Läuft', color: '#f59e0b', bg: 'rgba(245,158,11,0.1)' }; + return { label: status, color: '#64748b', bg: 'rgba(100,116,139,0.1)' }; + }; + + const card = { + background: 'var(--bg-card)', + border: '1px solid var(--border-color)', + borderRadius: 'var(--radius-xl)', + padding: '18px 20px', + marginBottom: '12px', + }; + + if (loading) return
Lade Cronjobs…
; + + return ( +
+
+

+ {jobs.length} Hintergrund-Jobs · Klick auf „Logs" für Details +

+ +
+ + {jobs.map(job => { + const badge = statusBadge(job.lastRun); + const isRunning = running[job.name]; + return ( +
+
+ {job.icon} +
+
+ {job.label} + {job.schedule} + {badge.label} +
+
{job.description}
+ {job.lastRun && ( +
+ Letzter Lauf: {fmtDateTime(job.lastRun.started_at)} + Dauer: {fmtDuration(job.lastRun.started_at, job.lastRun.finished_at)} + {job.lastRun.message && {job.lastRun.message}} +
+ )} + {!job.lastRun && ( +
Noch kein Lauf protokolliert
+ )} +
+
+ + {job.canRun && ( + + )} +
+
+
+ ); + })} + + {/* Log Modal */} + {logModal && ( +
setLogModal(null)}> +
e.stopPropagation()}> +
+

{logModal.icon} {logModal.label} — Logs

+ +
+ + {logsLoading ? ( +
Lade Logs…
+ ) : logs.length === 0 ? ( +
Keine Logs vorhanden
+ ) : ( +
+ + + + {['Datum', 'Status', 'Dauer', 'Meldung'].map(h => ( + + ))} + + + + {logs.map(log => { + const s = logStatusBadge(log.status); + return ( + + + + + + + ); + })} + +
{h}
{fmtDateTime(log.started_at)} + {s.label} + {fmtDuration(log.started_at, log.finished_at)} + {log.message || '—'} +
+
+ )} +
+
+ )} +
+ ); +} + // ── Asset Types Tab ─────────────────────────────────────────────────────────── function AssetTypesTab() { const [types, setTypes] = useState([]); diff --git a/frontend/src/pages/SystemPage.jsx b/frontend/src/pages/SystemPage.jsx index e443f0b..79a81c0 100644 --- a/frontend/src/pages/SystemPage.jsx +++ b/frontend/src/pages/SystemPage.jsx @@ -40,6 +40,13 @@ const SystemPage = () => { path: '/docs', color: 'var(--warning)', }, + { + icon: '⏰', + title: 'Cronjobs', + description: 'Geplante Hintergrundaufgaben verwalten — Status, Logs und manuelle Ausführung', + path: '/settings', + color: 'var(--cereda-primary)', + }, ]; return ( diff --git a/frontend/src/pages/UserManagementPage.jsx b/frontend/src/pages/UserManagementPage.jsx index aa3588f..6f8f2d2 100644 --- a/frontend/src/pages/UserManagementPage.jsx +++ b/frontend/src/pages/UserManagementPage.jsx @@ -105,6 +105,16 @@ const BarIcon = () => ; const Avatar = ({ user, size = 36, radius = 10, fontSize = 13 }) => { const initials = ((user.first_name?.[0] || '') + (user.last_name?.[0] || '')).toUpperCase() || (user.username?.[0] || '?').toUpperCase(); const bg = ROLE_AV_COLORS[user.role_name] || '#475569'; + if (user.avatar_url) { + return ( + {initials} { e.target.style.display = 'none'; e.target.nextSibling && (e.target.nextSibling.style.display = 'flex'); }} + /> + ); + } return (
{initials} @@ -1152,9 +1162,13 @@ const UserManagementPage = () => { const init = ((u.first_name?.[0] || '') + (u.last_name?.[0] || '')).toUpperCase() || u.username?.[0]?.toUpperCase() || '?'; return (
handleSelect(u.id)}> -
- {init} -
+ {u.avatar_url ? ( + {init} { e.target.style.display='none'; }} /> + ) : ( +
+ {init} +
+ )}
{u.first_name} {u.last_name}
@@ -1222,8 +1236,12 @@ const UserManagementPage = () => {
{/* Header */}
-
- {initials} +
+ {selectedUser.avatar_url ? ( + {initials} { e.target.style.display='none'; e.target.closest('.bv-dh-avatar').style.background = ROLE_AV_COLORS[selectedUser.role_name] || '#475569'; }} /> + ) : ( + initials + )}