Add: Entra Cronjob, Cronjob-Verwaltung, Profilfotos, PDF-Auth-Fix, Tickets-Demo
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import api from '../services/api';
|
||||
import assetService from '../services/assetService';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
const EMAIL_TEMPLATE_LABELS = {
|
||||
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: 'email-templates', label: '📧 E-Mail-Vorlagen' },
|
||||
{ key: 'email-design', label: '🎨 E-Mail-Design' },
|
||||
{ key: 'cronjobs', label: '⏰ Cronjobs' },
|
||||
].map(t => (
|
||||
<button key={t.key} onClick={() => setTab(t.key)} style={{
|
||||
background: 'none',
|
||||
@@ -332,7 +334,7 @@ export default function SettingsPage() {
|
||||
</div>{/* end constrained header/tabs */}
|
||||
|
||||
{/* ── 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 ────────────────────────────────────────────── */}
|
||||
{tab === 'categories' && (
|
||||
@@ -430,6 +432,9 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── CRONJOBS TAB ──────────────────────────────────────────────── */}
|
||||
{tab === 'cronjobs' && <CronJobsTab />}
|
||||
|
||||
</div>{/* 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 <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 ───────────────────────────────────────────────────────────
|
||||
function AssetTypesTab() {
|
||||
const [types, setTypes] = useState([]);
|
||||
|
||||
Reference in New Issue
Block a user