Add: Entra Cronjob, Cronjob-Verwaltung, Profilfotos, PDF-Auth-Fix, Tickets-Demo
This commit is contained in:
@@ -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 && (
|
||||
<div className="ap-more-menu">
|
||||
<button className="ap-more-item" onClick={() => {
|
||||
window.open(`${API}/assets/${detail.id}/label`, '_blank');
|
||||
openPdf(`${API}/assets/${detail.id}/label`);
|
||||
setShowMoreMenu(false);
|
||||
}}>🏷️ Label drucken</button>
|
||||
{detail.status === 'zugewiesen' && (
|
||||
<button className="ap-more-item" onClick={() => {
|
||||
window.open(`${API}/assets/${detail.id}/handover-protocol`, '_blank');
|
||||
openPdf(`${API}/assets/${detail.id}/handover-protocol`);
|
||||
setShowMoreMenu(false);
|
||||
}}>📄 Übergabeprotokoll</button>
|
||||
)}
|
||||
@@ -620,7 +632,7 @@ const AssetsPage = () => {
|
||||
<div className={`ap-kpi-card ${online ? 'ap-kpi-card--green' : ''}`}>
|
||||
<div className="ap-kpi-label">🤖 Agent-Sync</div>
|
||||
<div className="ap-kpi-value" style={{ fontSize: 15 }}>
|
||||
{agent ? relativeTime(agent.last_seen) : '—'}
|
||||
{agent ? relativeTime(agent.last_checkin) : '—'}
|
||||
</div>
|
||||
<div className="ap-kpi-sub">
|
||||
{agent ? `v${agent.agent_version || '?'} · ${online ? 'aktiv' : 'offline'}` : 'Kein Agent'}
|
||||
@@ -783,7 +795,7 @@ const AssetsPage = () => {
|
||||
)}
|
||||
<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}` : '—'],
|
||||
['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 ? (
|
||||
<div key={label} className="ap-info-row">
|
||||
<span className="ap-info-label">{label}</span>
|
||||
@@ -1030,7 +1042,7 @@ const AssetsPage = () => {
|
||||
{detail.status === 'zugewiesen' && (
|
||||
<div
|
||||
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>
|
||||
@@ -1099,25 +1111,13 @@ const AssetsPage = () => {
|
||||
<span className="ap-panel-badge">{assets.length}</span>
|
||||
</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 */}
|
||||
<div className="ap-search-box">
|
||||
<span className="ap-search-icon">{searchMode === 'user' ? '👤' : '🔍'}</span>
|
||||
<span className="ap-search-icon">🔍</span>
|
||||
<input
|
||||
className="ap-search-input"
|
||||
type="text"
|
||||
placeholder={searchMode === 'user' ? 'Benutzername…' : 'Suchen…'}
|
||||
placeholder="Asset, Benutzer, Seriennummer…"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
@@ -1200,7 +1200,7 @@ const AssetsPage = () => {
|
||||
</div>
|
||||
)}
|
||||
{filteredAssets.map(asset => {
|
||||
const agent = getAgent(asset.name);
|
||||
const agent = getAgent(asset);
|
||||
const online = isOnline(agent);
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 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 (
|
||||
<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 (
|
||||
<div style={{ width: size, height: size, borderRadius: radius, background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize, fontWeight: 700, color: '#fff', flexShrink: 0 }}>
|
||||
{initials}
|
||||
@@ -1152,9 +1162,13 @@ const UserManagementPage = () => {
|
||||
const init = ((u.first_name?.[0] || '') + (u.last_name?.[0] || '')).toUpperCase() || u.username?.[0]?.toUpperCase() || '?';
|
||||
return (
|
||||
<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}}>
|
||||
{init}
|
||||
</div>
|
||||
{u.avatar_url ? (
|
||||
<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 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-ur-body">
|
||||
<div className="bv-ur-name">{u.first_name} {u.last_name}</div>
|
||||
@@ -1222,8 +1236,12 @@ const UserManagementPage = () => {
|
||||
<div className="bv-detail-inner">
|
||||
{/* Header */}
|
||||
<div className="bv-detail-head">
|
||||
<div className="bv-dh-avatar" style={{background: ROLE_AV_COLORS[selectedUser.role_name] || '#475569'}}>
|
||||
{initials}
|
||||
<div className="bv-dh-avatar" style={{background: selectedUser.avatar_url ? 'transparent' : (ROLE_AV_COLORS[selectedUser.role_name] || '#475569'), padding: 0, overflow: 'hidden'}}>
|
||||
{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>
|
||||
<div className="bv-dh-body">
|
||||
|
||||
Reference in New Issue
Block a user