Add: Entra Cronjob, Cronjob-Verwaltung, Profilfotos, PDF-Auth-Fix, Tickets-Demo

This commit is contained in:
2026-06-02 11:57:44 +02:00
parent d9d5e4f618
commit 73d672ba88
10 changed files with 1559 additions and 50 deletions

1074
Tickets-Demo.html Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -497,6 +497,18 @@ async function initializeDatabase() {
created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP 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) { for (const migration of migrations) {
try { try {

View File

@@ -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;

View File

@@ -177,6 +177,8 @@ const tvRoutes = require('./routes/tv.routes');
app.use('/api/tv', tvRoutes); app.use('/api/tv', tvRoutes);
const feedbackRoutes = require('./routes/feedback.routes'); const feedbackRoutes = require('./routes/feedback.routes');
app.use('/api/feedback', feedbackRoutes); app.use('/api/feedback', feedbackRoutes);
const cronRoutes = require('./routes/cron.routes');
app.use('/api/cron', cronRoutes);
// Static file serving for uploads (PDFs) // Static file serving for uploads (PDFs)
const path = require('path'); const path = require('path');
@@ -415,6 +417,16 @@ async function startServer() {
console.log('🖥️ Proxmox Monitoring gestartet (alle 5 Minuten)'); 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 // Start server
app.listen(PORT, () => { app.listen(PORT, () => {
console.log('═══════════════════════════════════════════'); console.log('═══════════════════════════════════════════');

View File

@@ -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 };

View File

@@ -53,6 +53,8 @@ services:
- PROXMOX_HOST=https://192.168.0.184:8006 - PROXMOX_HOST=https://192.168.0.184:8006
- PROXMOX_TOKEN=root@pam!monitoring=ca287965-3d33-49ec-a069-1bb80fd2e95d - PROXMOX_TOKEN=root@pam!monitoring=ca287965-3d33-49ec-a069-1bb80fd2e95d
- PROXMOX_NODE=hve-01 - PROXMOX_NODE=hve-01
# GitHub API (Feedback-System)
- GITHUB_TOKEN=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
volumes: volumes:
# Persistente Datenbank - bleibt auch nach Container-Neustart erhalten # Persistente Datenbank - bleibt auch nach Container-Neustart erhalten
- fido-data:/app/data - fido-data:/app/data

View File

@@ -96,8 +96,8 @@ const relativeTime = (d) => {
}; };
const isOnline = (agent) => { const isOnline = (agent) => {
if (!agent || !agent.last_seen) return false; if (!agent || !agent.last_checkin) return false;
const diff = Date.now() - new Date(agent.last_seen).getTime(); const diff = Date.now() - new Date(agent.last_checkin).getTime();
return diff < 5 * 60 * 1000; return diff < 5 * 60 * 1000;
}; };
@@ -345,7 +345,7 @@ const AssetsPage = () => {
try { try {
const [aRes, agRes, stRes] = await Promise.all([ const [aRes, agRes, stRes] = await Promise.all([
authFetch(`${API}/assets`), authFetch(`${API}/assets`),
authFetch(`${API}/monitoring/agents`), authFetch(`${API}/monitoring`),
authFetch(`${API}/assets/stats`), authFetch(`${API}/assets/stats`),
]); ]);
const aData = aRes.ok ? await aRes.json() : {}; 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}/history`).then(r => r.ok ? r.json() : []),
authFetch(`${API}/assets/${selectedId}/inspections`).then(r => r.ok ? r.json() : []), authFetch(`${API}/assets/${selectedId}/inspections`).then(r => r.ok ? r.json() : []),
]).then(([d, h, ins]) => { ]).then(([d, h, ins]) => {
setDetail(d); setDetail(d?.data ?? d);
setHistory(Array.isArray(h) ? h : (h?.history || [])); setHistory(Array.isArray(h) ? h : (h?.data || h?.history || []));
setInspections(Array.isArray(ins) ? ins : (ins?.inspections || [])); setInspections(Array.isArray(ins) ? ins : (ins?.data || ins?.inspections || []));
}).catch(() => toast.error('Fehler beim Laden der Details')) }).catch(() => toast.error('Fehler beim Laden der Details'))
.finally(() => setDetailLoading(false)); .finally(() => setDetailLoading(false));
}, [selectedId]); }, [selectedId]);
@@ -408,28 +408,40 @@ const AssetsPage = () => {
if (statusFilter !== 'all' && a.status !== statusFilter) return false; if (statusFilter !== 'all' && a.status !== statusFilter) return false;
if (!searchTerm) return true; if (!searchTerm) return true;
const q = searchTerm.toLowerCase(); const q = searchTerm.toLowerCase();
if (searchMode === 'asset') { return (
return ( a.name?.toLowerCase().includes(q) ||
a.name?.toLowerCase().includes(q) || a.serial_number?.toLowerCase().includes(q) ||
a.serial_number?.toLowerCase().includes(q) || a.inventory_number?.toLowerCase().includes(q) ||
a.inventory_number?.toLowerCase().includes(q) || a.model?.toLowerCase().includes(q) ||
a.model?.toLowerCase().includes(q) a.assigned_to_username?.toLowerCase().includes(q) ||
); a.assigned_to_name?.toLowerCase().includes(q) ||
} else { a.manufacturer?.toLowerCase().includes(q)
return a.assigned_to_username?.toLowerCase().includes(q) || );
a.assigned_to_name?.toLowerCase().includes(q);
}
}); });
/* ── Agent lookup by asset name ── */ /* ── Agent lookup: by serial, IP or hostname ── */
const getAgent = (assetName) => { const getAgent = (asset) => {
if (!assetName || !agents.length) return null; if (!asset || !agents.length) return null;
return agents.find(ag => return agents.find(ag =>
ag.hostname?.toLowerCase() === assetName.toLowerCase() || (asset.serial_number && ag.hardware_serial && ag.hardware_serial.toLowerCase() === asset.serial_number.toLowerCase()) ||
ag.computer_name?.toLowerCase() === assetName.toLowerCase() (asset.ip_address && ag.ip_address && ag.ip_address === asset.ip_address) ||
(asset.name && ag.hostname && ag.hostname.toLowerCase() === asset.name.toLowerCase())
) || null; ) || 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 ── */ /* ── Actions ── */
const handleUnassign = async () => { const handleUnassign = async () => {
if (!detail) return; if (!detail) return;
@@ -504,7 +516,7 @@ const AssetsPage = () => {
} }
if (!detail) return null; if (!detail) return null;
const agent = getAgent(detail.name); const agent = getAgent(detail);
const online = isOnline(agent); const online = isOnline(agent);
const bookValue = calcBookValue(detail); const bookValue = calcBookValue(detail);
const depPercent = calcDepPercent(detail); const depPercent = calcDepPercent(detail);
@@ -564,12 +576,12 @@ const AssetsPage = () => {
{showMoreMenu && ( {showMoreMenu && (
<div className="ap-more-menu"> <div className="ap-more-menu">
<button className="ap-more-item" onClick={() => { <button className="ap-more-item" onClick={() => {
window.open(`${API}/assets/${detail.id}/label`, '_blank'); openPdf(`${API}/assets/${detail.id}/label`);
setShowMoreMenu(false); setShowMoreMenu(false);
}}>🏷 Label drucken</button> }}>🏷 Label drucken</button>
{detail.status === 'zugewiesen' && ( {detail.status === 'zugewiesen' && (
<button className="ap-more-item" onClick={() => { <button className="ap-more-item" onClick={() => {
window.open(`${API}/assets/${detail.id}/handover-protocol`, '_blank'); openPdf(`${API}/assets/${detail.id}/handover-protocol`);
setShowMoreMenu(false); setShowMoreMenu(false);
}}>📄 Übergabeprotokoll</button> }}>📄 Übergabeprotokoll</button>
)} )}
@@ -620,7 +632,7 @@ const AssetsPage = () => {
<div className={`ap-kpi-card ${online ? 'ap-kpi-card--green' : ''}`}> <div className={`ap-kpi-card ${online ? 'ap-kpi-card--green' : ''}`}>
<div className="ap-kpi-label">🤖 Agent-Sync</div> <div className="ap-kpi-label">🤖 Agent-Sync</div>
<div className="ap-kpi-value" style={{ fontSize: 15 }}> <div className="ap-kpi-value" style={{ fontSize: 15 }}>
{agent ? relativeTime(agent.last_seen) : '—'} {agent ? relativeTime(agent.last_checkin) : '—'}
</div> </div>
<div className="ap-kpi-sub"> <div className="ap-kpi-sub">
{agent ? `v${agent.agent_version || '?'} · ${online ? 'aktiv' : 'offline'}` : 'Kein Agent'} {agent ? `v${agent.agent_version || '?'} · ${online ? 'aktiv' : 'offline'}` : 'Kein Agent'}
@@ -783,7 +795,7 @@ const AssetsPage = () => {
)} )}
<div style={{ height: 8 }} /> <div style={{ height: 8 }} />
{[ {[
['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}` : '—'], ['Agent-Version', agent.agent_version ? `v${agent.agent_version}` : '—'],
['Hostname', agent.hostname || agent.computer_name || '—'], ['Hostname', agent.hostname || agent.computer_name || '—'],
].map(([label, val, color]) => ( ].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'], ['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], ['Letztes Update', agent.last_update_date ? fmtDate(agent.last_update_date) : undefined],
['Agent-Version', agent.agent_version ? `v${agent.agent_version}` : 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 ? ( ].map(([label, val, color]) => val !== undefined ? (
<div key={label} className="ap-info-row"> <div key={label} className="ap-info-row">
<span className="ap-info-label">{label}</span> <span className="ap-info-label">{label}</span>
@@ -1030,7 +1042,7 @@ const AssetsPage = () => {
{detail.status === 'zugewiesen' && ( {detail.status === 'zugewiesen' && (
<div <div
className="ap-doc-item" className="ap-doc-item"
onClick={() => window.open(`${API}/assets/${detail.id}/handover-protocol`, '_blank')} onClick={() => openPdf(`${API}/assets/${detail.id}/handover-protocol`)}
> >
<div className="ap-doc-icon" style={{ background: 'rgba(52,211,153,0.1)' }}>📋</div> <div className="ap-doc-icon" style={{ background: 'rgba(52,211,153,0.1)' }}>📋</div>
<div> <div>
@@ -1099,25 +1111,13 @@ const AssetsPage = () => {
<span className="ap-panel-badge">{assets.length}</span> <span className="ap-panel-badge">{assets.length}</span>
</div> </div>
{/* Suchmodus Toggle */}
<div className="ap-search-toggle">
<button
className={`ap-search-toggle-btn ${searchMode === 'asset' ? 'ap-search-toggle-btn--active' : ''}`}
onClick={() => { setSearchMode('asset'); setSearchTerm(''); }}
>🔍 Asset suchen</button>
<button
className={`ap-search-toggle-btn ${searchMode === 'user' ? 'ap-search-toggle-btn--active' : ''}`}
onClick={() => { setSearchMode('user'); setSearchTerm(''); }}
>👤 Benutzer suchen</button>
</div>
{/* Suchfeld */} {/* Suchfeld */}
<div className="ap-search-box"> <div className="ap-search-box">
<span className="ap-search-icon">{searchMode === 'user' ? '👤' : '🔍'}</span> <span className="ap-search-icon">🔍</span>
<input <input
className="ap-search-input" className="ap-search-input"
type="text" type="text"
placeholder={searchMode === 'user' ? 'Benutzername…' : 'Suchen…'} placeholder="Asset, Benutzer, Seriennummer…"
value={searchTerm} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} onChange={e => setSearchTerm(e.target.value)}
/> />
@@ -1200,7 +1200,7 @@ const AssetsPage = () => {
</div> </div>
)} )}
{filteredAssets.map(asset => { {filteredAssets.map(asset => {
const agent = getAgent(asset.name); const agent = getAgent(asset);
const online = isOnline(agent); const online = isOnline(agent);
return ( return (
<div <div

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef } from 'react';
import api from '../services/api'; import api from '../services/api';
import assetService from '../services/assetService'; import assetService from '../services/assetService';
import { toast } from 'react-toastify';
const EMAIL_TEMPLATE_LABELS = { const EMAIL_TEMPLATE_LABELS = {
comment_added: { label: '💬 Neuer Kommentar', desc: 'Gesendet an den Ersteller wenn Support antwortet' }, comment_added: { label: '💬 Neuer Kommentar', desc: 'Gesendet an den Ersteller wenn Support antwortet' },
@@ -311,6 +312,7 @@ export default function SettingsPage() {
{ key: 'asset-types', label: '🖥️ Asset-Typen' }, { key: 'asset-types', label: '🖥️ Asset-Typen' },
{ key: 'email-templates', label: '📧 E-Mail-Vorlagen' }, { key: 'email-templates', label: '📧 E-Mail-Vorlagen' },
{ key: 'email-design', label: '🎨 E-Mail-Design' }, { key: 'email-design', label: '🎨 E-Mail-Design' },
{ key: 'cronjobs', label: '⏰ Cronjobs' },
].map(t => ( ].map(t => (
<button key={t.key} onClick={() => setTab(t.key)} style={{ <button key={t.key} onClick={() => setTab(t.key)} style={{
background: 'none', background: 'none',
@@ -332,7 +334,7 @@ export default function SettingsPage() {
</div>{/* end constrained header/tabs */} </div>{/* end constrained header/tabs */}
{/* ── Constrained tab content (all except email-design) ─────────── */} {/* ── Constrained tab content (all except email-design) ─────────── */}
<div style={{ maxWidth: '860px', margin: '0 auto', display: tab === 'email-design' ? 'none' : 'block' }}> <div style={{ maxWidth: '860px', margin: '0 auto', display: (tab === 'email-design') ? 'none' : 'block' }}>
{/* ── CATEGORIES TAB ────────────────────────────────────────────── */} {/* ── CATEGORIES TAB ────────────────────────────────────────────── */}
{tab === 'categories' && ( {tab === 'categories' && (
@@ -430,6 +432,9 @@ export default function SettingsPage() {
</div> </div>
)} )}
{/* ── CRONJOBS TAB ──────────────────────────────────────────────── */}
{tab === 'cronjobs' && <CronJobsTab />}
</div>{/* end constrained tab content */} </div>{/* end constrained tab content */}
{/* ── EMAIL DESIGN TAB full width ─────────────────────────────── */} {/* ── 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 <div style={{ padding: '40px', textAlign: 'center', color: 'var(--text-muted)' }}>Lade Cronjobs</div>;
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<p style={{ margin: 0, color: 'var(--text-muted)', fontSize: '13px' }}>
{jobs.length} Hintergrund-Jobs · Klick auf Logs" für Details
</p>
<button
style={{ background: 'transparent', color: 'var(--text-secondary)', border: '1px solid var(--border-color)', borderRadius: 'var(--radius-md)', padding: '6px 14px', cursor: 'pointer', fontSize: '13px' }}
onClick={loadJobs}
>
↻ Aktualisieren
</button>
</div>
{jobs.map(job => {
const badge = statusBadge(job.lastRun);
const isRunning = running[job.name];
return (
<div key={job.name} style={card}>
<div style={{ display: 'flex', alignItems: 'center', gap: '14px' }}>
<span style={{ fontSize: '22px', flexShrink: 0 }}>{job.icon}</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
<span style={{ fontWeight: 700, fontSize: '14px', color: 'var(--text-primary)' }}>{job.label}</span>
<span style={{
fontSize: '11px', fontWeight: 600, padding: '2px 8px', borderRadius: 999,
background: 'var(--bg-secondary)', color: 'var(--text-muted)', border: '1px solid var(--border-color)',
}}>{job.schedule}</span>
<span style={{
fontSize: '11px', fontWeight: 600, padding: '2px 8px', borderRadius: 999,
background: badge.bg, color: badge.color, border: `1px solid ${badge.border}`,
}}>{badge.label}</span>
</div>
<div style={{ color: 'var(--text-muted)', fontSize: '12px', marginTop: '3px' }}>{job.description}</div>
{job.lastRun && (
<div style={{ color: 'var(--text-muted)', fontSize: '11px', marginTop: '4px', display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
<span>Letzter Lauf: {fmtDateTime(job.lastRun.started_at)}</span>
<span>Dauer: {fmtDuration(job.lastRun.started_at, job.lastRun.finished_at)}</span>
{job.lastRun.message && <span style={{ color: job.lastRun.status === 'error' ? '#ef4444' : 'var(--text-muted)' }}>{job.lastRun.message}</span>}
</div>
)}
{!job.lastRun && (
<div style={{ color: 'var(--text-muted)', fontSize: '11px', marginTop: '4px' }}>Noch kein Lauf protokolliert</div>
)}
</div>
<div style={{ display: 'flex', gap: '8px', flexShrink: 0 }}>
<button
style={{ background: 'transparent', color: 'var(--text-secondary)', border: '1px solid var(--border-color)', borderRadius: 'var(--radius-md)', padding: '6px 12px', cursor: 'pointer', fontSize: '12px' }}
onClick={() => openLogs(job)}
>
Logs
</button>
{job.canRun && (
<button
disabled={isRunning}
style={{ background: isRunning ? 'var(--bg-secondary)' : 'var(--cereda-primary)', color: isRunning ? 'var(--text-muted)' : '#fff', border: 'none', borderRadius: 'var(--radius-md)', padding: '6px 14px', cursor: isRunning ? 'not-allowed' : 'pointer', fontSize: '12px', fontWeight: 600 }}
onClick={() => runJob(job)}
>
{isRunning ? '⏳ Läuft…' : '▶ Ausführen'}
</button>
)}
</div>
</div>
</div>
);
})}
{/* Log Modal */}
{logModal && (
<div className="modal-overlay" onClick={() => setLogModal(null)}>
<div className="modal-content modal-large" style={{ maxWidth: 700 }} onClick={e => e.stopPropagation()}>
<div className="modal-header">
<h2 className="modal-title">{logModal.icon} {logModal.label} — Logs</h2>
<button className="modal-close" onClick={() => setLogModal(null)}>×</button>
</div>
{logsLoading ? (
<div style={{ padding: '32px', textAlign: 'center', color: 'var(--text-muted)' }}>Lade Logs…</div>
) : logs.length === 0 ? (
<div style={{ padding: '32px', textAlign: 'center', color: 'var(--text-muted)' }}>Keine Logs vorhanden</div>
) : (
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--border-color)' }}>
{['Datum', 'Status', 'Dauer', 'Meldung'].map(h => (
<th key={h} style={{ padding: '8px 12px', textAlign: 'left', color: 'var(--text-muted)', fontWeight: 600, fontSize: '12px' }}>{h}</th>
))}
</tr>
</thead>
<tbody>
{logs.map(log => {
const s = logStatusBadge(log.status);
return (
<tr key={log.id} style={{ borderBottom: '1px solid var(--border-color)' }}>
<td style={{ padding: '8px 12px', color: 'var(--text-secondary)', whiteSpace: 'nowrap' }}>{fmtDateTime(log.started_at)}</td>
<td style={{ padding: '8px 12px' }}>
<span style={{ fontSize: '11px', fontWeight: 600, padding: '2px 8px', borderRadius: 999, background: s.bg, color: s.color }}>{s.label}</span>
</td>
<td style={{ padding: '8px 12px', color: 'var(--text-muted)', whiteSpace: 'nowrap' }}>{fmtDuration(log.started_at, log.finished_at)}</td>
<td style={{ padding: '8px 12px', color: log.status === 'error' ? '#ef4444' : 'var(--text-secondary)', maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{log.message || '—'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
)}
</div>
);
}
// ── Asset Types Tab ─────────────────────────────────────────────────────────── // ── Asset Types Tab ───────────────────────────────────────────────────────────
function AssetTypesTab() { function AssetTypesTab() {
const [types, setTypes] = useState([]); const [types, setTypes] = useState([]);

View File

@@ -40,6 +40,13 @@ const SystemPage = () => {
path: '/docs', path: '/docs',
color: 'var(--warning)', color: 'var(--warning)',
}, },
{
icon: '⏰',
title: 'Cronjobs',
description: 'Geplante Hintergrundaufgaben verwalten — Status, Logs und manuelle Ausführung',
path: '/settings',
color: 'var(--cereda-primary)',
},
]; ];
return ( return (

View File

@@ -105,6 +105,16 @@ const BarIcon = () => <Ico d="M22 12h-4l-3 9L9 3l-3 9H2"/>;
const Avatar = ({ user, size = 36, radius = 10, fontSize = 13 }) => { 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 initials = ((user.first_name?.[0] || '') + (user.last_name?.[0] || '')).toUpperCase() || (user.username?.[0] || '?').toUpperCase();
const bg = ROLE_AV_COLORS[user.role_name] || '#475569'; const bg = ROLE_AV_COLORS[user.role_name] || '#475569';
if (user.avatar_url) {
return (
<img
src={user.avatar_url}
alt={initials}
style={{ width: size, height: size, borderRadius: radius, objectFit: 'cover', flexShrink: 0 }}
onError={e => { e.target.style.display = 'none'; e.target.nextSibling && (e.target.nextSibling.style.display = 'flex'); }}
/>
);
}
return ( return (
<div style={{ width: size, height: size, borderRadius: radius, background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize, fontWeight: 700, color: '#fff', flexShrink: 0 }}> <div style={{ width: size, height: size, borderRadius: radius, background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize, fontWeight: 700, color: '#fff', flexShrink: 0 }}>
{initials} {initials}
@@ -1152,9 +1162,13 @@ const UserManagementPage = () => {
const init = ((u.first_name?.[0] || '') + (u.last_name?.[0] || '')).toUpperCase() || u.username?.[0]?.toUpperCase() || '?'; const init = ((u.first_name?.[0] || '') + (u.last_name?.[0] || '')).toUpperCase() || u.username?.[0]?.toUpperCase() || '?';
return ( return (
<div className={`bv-user-row ${u.id === selectedId ? 'active' : ''}`} onClick={() => handleSelect(u.id)}> <div className={`bv-user-row ${u.id === selectedId ? 'active' : ''}`} onClick={() => handleSelect(u.id)}>
<div className="bv-ur-av" style={{background: avatarBg, width:36, height:36, borderRadius:10, display:'flex', alignItems:'center', justifyContent:'center', fontSize:13, fontWeight:700, color:'#fff', flexShrink:0}}> {u.avatar_url ? (
{init} <img src={u.avatar_url} alt={init} style={{width:36, height:36, borderRadius:10, objectFit:'cover', flexShrink:0}} onError={e => { e.target.style.display='none'; }} />
</div> ) : (
<div className="bv-ur-av" style={{background: avatarBg, width:36, height:36, borderRadius:10, display:'flex', alignItems:'center', justifyContent:'center', fontSize:13, fontWeight:700, color:'#fff', flexShrink:0}}>
{init}
</div>
)}
<div className={`bv-presence ${p}`} /> <div className={`bv-presence ${p}`} />
<div className="bv-ur-body"> <div className="bv-ur-body">
<div className="bv-ur-name">{u.first_name} {u.last_name}</div> <div className="bv-ur-name">{u.first_name} {u.last_name}</div>
@@ -1222,8 +1236,12 @@ const UserManagementPage = () => {
<div className="bv-detail-inner"> <div className="bv-detail-inner">
{/* Header */} {/* Header */}
<div className="bv-detail-head"> <div className="bv-detail-head">
<div className="bv-dh-avatar" style={{background: ROLE_AV_COLORS[selectedUser.role_name] || '#475569'}}> <div className="bv-dh-avatar" style={{background: selectedUser.avatar_url ? 'transparent' : (ROLE_AV_COLORS[selectedUser.role_name] || '#475569'), padding: 0, overflow: 'hidden'}}>
{initials} {selectedUser.avatar_url ? (
<img src={selectedUser.avatar_url} alt={initials} style={{width:'100%',height:'100%',objectFit:'cover',display:'block'}} onError={e => { e.target.style.display='none'; e.target.closest('.bv-dh-avatar').style.background = ROLE_AV_COLORS[selectedUser.role_name] || '#475569'; }} />
) : (
initials
)}
<div className={`bv-dh-presence ${presence}`} /> <div className={`bv-dh-presence ${presence}`} />
</div> </div>
<div className="bv-dh-body"> <div className="bv-dh-body">