Files
IMAPSYNC/backend/static/index.html
T
Sebastian Serfling f600cd52f3 fix: recover stale jobs on worker restart, persist active page on reload
- Worker resets running/cancelling jobs to idle on startup to fix jobs stuck after Docker restart
- Frontend saves current page to localStorage so reload returns to last visited page instead of always dashboard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 14:07:13 +02:00

1545 lines
56 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ImapSync Manager</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@300;400;500;600&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap');
:root {
--bg: #0d1117;
--surface: #161b22;
--surface2: #1c2128;
--border: #30363d;
--border2: #21262d;
--text: #e6edf3;
--muted: #7d8590;
--accent: #2ea043;
--accent-dim:#1a6629;
--blue: #388bfd;
--blue-dim: #1b3d6e;
--warn: #d29922;
--danger: #f85149;
--danger-dim:#5c1a18;
--mono: 'IBM Plex Mono', monospace;
--sans: 'IBM Plex Sans', sans-serif;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: var(--sans);
background: var(--bg);
color: var(--text);
min-height: 100vh;
font-size: 15px;
}
/* ── Login ── */
#login-screen {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
background: var(--bg);
}
.login-box {
width: 360px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 40px;
}
.login-logo {
text-align: center;
margin-bottom: 32px;
}
.login-logo .brand {
font-family: var(--mono);
font-size: 11px;
letter-spacing: 4px;
text-transform: uppercase;
color: var(--muted);
display: block;
margin-bottom: 8px;
}
.login-logo h1 {
font-family: var(--mono);
font-size: 22px;
font-weight: 600;
color: var(--accent);
}
.login-logo .pulse {
display: inline-block;
width: 8px; height: 8px;
background: var(--accent);
border-radius: 50%;
margin-left: 8px;
animation: pulse 2s infinite;
}
@keyframes pulse {
0%,100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.4; transform: scale(0.8); }
}
/* ── App Shell ── */
#app { display: none; min-height: 100vh; }
#app.visible { display: flex; }
.sidebar {
width: 220px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 0;
flex-shrink: 0;
}
.sidebar-header {
padding: 20px 20px 16px;
border-bottom: 1px solid var(--border2);
}
.sidebar-header .brand {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 3px;
color: var(--muted);
text-transform: uppercase;
}
.sidebar-header h2 {
font-family: var(--mono);
font-size: 16px;
font-weight: 600;
color: var(--accent);
margin-top: 4px;
}
.sidebar-header h2 .dot {
display: inline-block;
width: 6px; height: 6px;
background: var(--accent);
border-radius: 50%;
margin-left: 6px;
animation: pulse 2s infinite;
vertical-align: middle;
margin-bottom: 2px;
}
nav { flex: 1; padding: 12px 0; }
nav a {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
color: var(--muted);
text-decoration: none;
font-size: 14px;
font-weight: 500;
border-left: 2px solid transparent;
transition: all 0.15s;
cursor: pointer;
}
nav a:hover { color: var(--text); background: var(--surface2); }
nav a.active { color: var(--text); border-left-color: var(--accent); background: var(--surface2); }
nav a .icon { font-size: 15px; width: 18px; text-align: center; }
nav .nav-section {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 2px;
color: var(--muted);
padding: 16px 20px 6px;
text-transform: uppercase;
}
.sidebar-footer {
padding: 16px 20px;
border-top: 1px solid var(--border2);
display: flex;
align-items: center;
gap: 10px;
}
.user-avatar {
width: 28px; height: 28px;
background: var(--accent-dim);
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-family: var(--mono);
font-size: 11px;
color: var(--accent);
font-weight: 600;
flex-shrink: 0;
}
.user-info { flex: 1; min-width: 0; }
.user-info .name { font-size: 14px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.user-info .role { font-size: 10px; font-family: var(--mono); color: var(--muted); text-transform: uppercase; letter-spacing: 1px; }
.logout-btn {
background: none; border: none; color: var(--muted); cursor: pointer;
font-size: 14px; padding: 4px; border-radius: 4px;
transition: color 0.15s;
}
.logout-btn:hover { color: var(--danger); }
/* ── Main Content ── */
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.topbar {
height: 54px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 24px;
gap: 12px;
background: var(--surface);
}
.page-title {
flex: 1;
font-size: 15px;
font-weight: 600;
font-family: var(--mono);
letter-spacing: 0.5px;
}
.content { flex: 1; overflow-y: auto; padding: 24px; }
/* ── Cards / Grid ── */
.stat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 24px;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 16px 18px;
}
.stat-card .label {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 2px;
color: var(--muted);
text-transform: uppercase;
margin-bottom: 8px;
}
.stat-card .value {
font-family: var(--mono);
font-size: 30px;
font-weight: 600;
line-height: 1;
}
.stat-card .sub { font-size: 11px; color: var(--muted); margin-top: 4px; }
.stat-card.green .value { color: var(--accent); }
.stat-card.blue .value { color: var(--blue); }
.stat-card.yellow .value { color: var(--warn); }
.stat-card.red .value { color: var(--danger); }
/* ── Panel ── */
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 20px;
}
.panel-header {
padding: 14px 18px;
border-bottom: 1px solid var(--border2);
display: flex;
align-items: center;
gap: 10px;
}
.panel-header h3 {
flex: 1;
font-family: var(--mono);
font-size: 12px;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--muted);
}
.panel-body { padding: 0; }
/* ── Table ── */
table { width: 100%; border-collapse: collapse; }
th {
text-align: left;
padding: 9px 18px;
font-family: var(--mono);
font-size: 10px;
letter-spacing: 1.5px;
color: var(--muted);
text-transform: uppercase;
border-bottom: 1px solid var(--border2);
font-weight: 500;
}
td { padding: 10px 18px; border-bottom: 1px solid var(--border2); vertical-align: middle; }
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--surface2); }
/* ── Badges ── */
.badge {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 8px;
border-radius: 4px;
font-family: var(--mono);
font-size: 10px;
font-weight: 500;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.badge-dot { width: 5px; height: 5px; border-radius: 50%; }
.badge.idle { background: rgba(125,133,144,0.15); color: var(--muted); }
.badge.idle .badge-dot { background: var(--muted); }
.badge.queued { background: rgba(210,153,34,0.15); color: var(--warn); }
.badge.queued .badge-dot { background: var(--warn); animation: pulse 1s infinite; }
.badge.running { background: rgba(56,139,253,0.15); color: var(--blue); }
.badge.running .badge-dot { background: var(--blue); animation: pulse 0.8s infinite; }
.badge.done { background: rgba(46,160,67,0.15); color: var(--accent); }
.badge.done .badge-dot { background: var(--accent); }
.badge.failed { background: rgba(248,81,73,0.15); color: var(--danger); }
.badge.failed .badge-dot { background: var(--danger); }
.badge.cancelled { background: rgba(210,153,34,0.15); color: var(--warn); }
.badge.cancelled .badge-dot { background: var(--warn); }
.badge.cancelling { background: rgba(210,153,34,0.15); color: var(--warn); }
.badge.cancelling .badge-dot { background: var(--warn); animation: pulse 0.8s infinite; }
.badge.admin { background: rgba(56,139,253,0.15); color: var(--blue); }
.badge.operator { background: rgba(210,153,34,0.15); color: var(--warn); }
.badge.viewer { background: rgba(125,133,144,0.15); color: var(--muted); }
.badge.enabled { background: rgba(46,160,67,0.15); color: var(--accent); }
.badge.disabled { background: rgba(125,133,144,0.15); color: var(--muted); }
.badge.source { background: rgba(56,139,253,0.15); color: var(--blue); }
.badge.destination { background: rgba(210,153,34,0.15); color: var(--warn); }
.badge.both { background: rgba(46,160,67,0.15); color: var(--accent); }
/* ── Buttons ── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
border-radius: 5px;
border: 1px solid transparent;
font-family: var(--sans);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
text-decoration: none;
}
.btn-primary { background: var(--accent); color: #fff; border-color: var(--accent); }
.btn-primary:hover { background: #3fb950; }
.btn-outline { background: transparent; color: var(--text); border-color: var(--border); }
.btn-outline:hover { background: var(--surface2); border-color: var(--muted); }
.btn-danger { background: transparent; color: var(--danger); border-color: var(--danger-dim); }
.btn-danger:hover { background: rgba(248,81,73,0.1); }
.btn-sm { padding: 4px 10px; font-size: 13px; }
.btn-icon { padding: 5px 8px; font-size: 14px; }
/* ── Forms ── */
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.form-group { display: flex; flex-direction: column; gap: 6px; }
.form-group.full { grid-column: 1 / -1; }
label { font-size: 13px; font-weight: 500; color: var(--muted); font-family: var(--mono); letter-spacing: 0.5px; }
input, select, textarea {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 5px;
color: var(--text);
padding: 8px 12px;
font-family: var(--sans);
font-size: 15px;
transition: border-color 0.15s;
width: 100%;
}
input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--blue);
}
input[type="checkbox"] { width: auto; }
textarea { resize: vertical; min-height: 80px; font-family: var(--mono); font-size: 14px; }
select option { background: var(--surface); }
.form-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--border2); }
.section-divider {
grid-column: 1 / -1;
padding-top: 8px;
border-top: 1px solid var(--border2);
font-family: var(--mono);
font-size: 10px;
letter-spacing: 2px;
color: var(--muted);
text-transform: uppercase;
}
.section-divider-prominent {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 0;
margin-top: 8px;
}
.section-divider-prominent::before,
.section-divider-prominent::after {
content: '';
flex: 1;
height: 3px;
border-radius: 2px;
}
.section-divider-prominent.src::before,
.section-divider-prominent.src::after { background: var(--blue); }
.section-divider-prominent.dst::before,
.section-divider-prominent.dst::after { background: var(--danger); }
.section-divider-prominent .section-title {
padding: 0 14px;
font-family: var(--mono);
font-size: 14px;
font-weight: 600;
letter-spacing: 2px;
text-transform: uppercase;
white-space: nowrap;
}
.section-divider-prominent.src .section-title { color: var(--blue); }
.section-divider-prominent.dst .section-title { color: var(--danger); }
.server-info {
background: var(--surface2);
border: 1px solid var(--border2);
border-radius: 4px;
padding: 8px 12px;
font-family: var(--mono);
font-size: 11px;
color: var(--muted);
line-height: 1.6;
}
/* ── Modal ── */
.modal-overlay {
position: fixed; inset: 0;
background: rgba(0,0,0,0.7);
display: flex; align-items: center; justify-content: center;
z-index: 100;
backdrop-filter: blur(2px);
}
.modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
width: 620px;
max-width: 95vw;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
padding: 18px 22px;
border-bottom: 1px solid var(--border2);
display: flex; align-items: center; gap: 12px;
}
.modal-header h3 { flex: 1; font-family: var(--mono); font-size: 15px; }
.modal-body { padding: 22px; }
.modal-close { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 18px; padding: 4px; }
.modal-close:hover { color: var(--text); }
/* ── Log viewer ── */
.log-viewer {
background: #0a0d10;
border: 1px solid var(--border);
border-radius: 5px;
padding: 16px;
font-family: var(--mono);
font-size: 11px;
line-height: 1.7;
color: #8b949e;
max-height: 500px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
}
.log-viewer .log-ok { color: var(--accent); }
.log-viewer .log-err { color: var(--danger); }
.log-viewer .log-warn { color: var(--warn); }
.log-viewer .log-info { color: var(--blue); }
/* ── Chart ── */
.chart-area { padding: 16px 18px; }
.bar-chart { display: flex; align-items: flex-end; gap: 4px; height: 80px; }
.bar-col { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 3px; }
.bar { width: 100%; border-radius: 2px 2px 0 0; min-height: 2px; background: var(--accent-dim); transition: background 0.15s; }
.bar:hover { background: var(--accent); }
.bar-label { font-family: var(--mono); font-size: 9px; color: var(--muted); white-space: nowrap; }
.chart-legend { display: flex; gap: 16px; margin-top: 10px; }
.legend-item { display: flex; align-items: center; gap: 5px; font-size: 11px; color: var(--muted); }
.legend-dot { width: 8px; height: 8px; border-radius: 2px; }
/* ── Toast ── */
.toast-container {
position: fixed; bottom: 24px; right: 24px; z-index: 200;
display: flex; flex-direction: column; gap: 8px;
}
.toast {
padding: 12px 16px;
border-radius: 6px;
font-size: 13px;
border: 1px solid;
max-width: 320px;
animation: slideIn 0.2s ease;
}
@keyframes slideIn { from { transform: translateX(20px); opacity: 0; } }
.toast.success { background: rgba(46,160,67,0.2); border-color: var(--accent); color: var(--accent); }
.toast.error { background: rgba(248,81,73,0.2); border-color: var(--danger); color: var(--danger); }
.toast.info { background: rgba(56,139,253,0.2); border-color: var(--blue); color: var(--blue); }
/* ── Misc ── */
.mono { font-family: var(--mono); }
.text-muted { color: var(--muted); }
.text-sm { font-size: 13px; }
.flex { display: flex; align-items: center; }
.gap-8 { gap: 8px; }
.gap-12 { gap: 12px; }
.ml-auto { margin-left: auto; }
.empty-state { text-align: center; padding: 48px; color: var(--muted); }
.empty-state .icon { font-size: 36px; margin-bottom: 12px; }
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.6s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Activity feed ── */
.activity-item { display: flex; align-items: flex-start; gap: 10px; padding: 10px 18px; border-bottom: 1px solid var(--border2); }
.activity-item:last-child { border-bottom: none; }
.activity-dot { width: 6px; height: 6px; border-radius: 50%; margin-top: 5px; flex-shrink: 0; }
.activity-dot.done { background: var(--accent); }
.activity-dot.failed { background: var(--danger); }
.activity-dot.running { background: var(--blue); animation: pulse 0.8s infinite; }
.activity-meta { font-size: 11px; color: var(--muted); font-family: var(--mono); margin-top: 2px; }
</style>
</head>
<body>
<!-- Login -->
<div id="login-screen">
<div class="login-box">
<div class="login-logo">
<span class="brand">IMAP Sync Manager</span>
<h1>▸ IMS <span class="pulse"></span></h1>
</div>
<div class="form-group" style="margin-bottom:14px">
<label>BENUTZERNAME</label>
<input id="login-user" type="text" placeholder="admin" autocomplete="username">
</div>
<div class="form-group" style="margin-bottom:20px">
<label>PASSWORT</label>
<input id="login-pass" type="password" placeholder="••••••••" autocomplete="current-password">
</div>
<button class="btn btn-primary" style="width:100%;justify-content:center" onclick="doLogin()">Anmelden</button>
<div id="login-error" style="color:var(--danger);font-size:13px;text-align:center;margin-top:12px;display:none"></div>
<div style="text-align:center;margin-top:20px;font-size:12px;color:var(--muted);font-family:var(--mono)">
Standard: admin / admin
</div>
</div>
</div>
<!-- App -->
<div id="app">
<aside class="sidebar">
<div class="sidebar-header">
<div class="brand">IMAP Sync</div>
<h2>Manager <span class="dot"></span></h2>
</div>
<nav>
<div class="nav-section">Übersicht</div>
<a onclick="navigate('dashboard')" data-page="dashboard" class="active">
<span class="icon"></span> Dashboard
</a>
<div class="nav-section">Aufträge</div>
<a onclick="navigate('servers')" data-page="servers">
<span class="icon"></span> Server
</a>
<a onclick="navigate('jobs')" data-page="jobs">
<span class="icon"></span> Sync-Jobs
</a>
<a onclick="navigate('runs')" data-page="runs">
<span class="icon"></span> Verlauf
</a>
<div class="nav-section" id="admin-nav" style="display:none">Administration</div>
<a onclick="navigate('users')" data-page="users" id="admin-link" style="display:none">
<span class="icon"></span> Benutzer
</a>
</nav>
<div class="sidebar-footer">
<div class="user-avatar" id="user-avatar">A</div>
<div class="user-info">
<div class="name" id="sidebar-username">-</div>
<div class="role" id="sidebar-role">-</div>
</div>
<button class="logout-btn" onclick="logout()" title="Abmelden"></button>
</div>
</aside>
<div class="main">
<div class="topbar">
<span class="page-title" id="page-title">◈ DASHBOARD</span>
<div id="topbar-actions"></div>
</div>
<div class="content" id="content"></div>
</div>
</div>
<!-- Modals -->
<div id="modal-container"></div>
<!-- Toasts -->
<div class="toast-container" id="toasts"></div>
<script>
let state = { token: null, user: null, refreshTimer: null, serversCache: null };
const api = {
async req(method, path, body = null) {
const opts = {
method,
headers: { 'Content-Type': 'application/json' }
};
if (state.token) opts.headers['Authorization'] = 'Bearer ' + state.token;
if (body) opts.body = JSON.stringify(body);
const res = await fetch('/api' + path, opts);
if (res.status === 401) { logout(); return; }
const data = await res.json();
if (!res.ok) throw new Error(data.detail || 'API-Fehler');
return data;
},
get: (p) => api.req('GET', p),
post: (p, b) => api.req('POST', p, b),
put: (p, b) => api.req('PUT', p, b),
delete: (p) => api.req('DELETE', p),
};
async function doLogin() {
const u = document.getElementById('login-user').value.trim();
const p = document.getElementById('login-pass').value;
if (!u || !p) return;
try {
const data = await api.post('/auth/login', { username: u, password: p });
state.token = data.token;
state.user = { username: data.username, role: data.role };
localStorage.setItem('ims_token', data.token);
startApp();
} catch(e) {
const el = document.getElementById('login-error');
el.textContent = e.message;
el.style.display = 'block';
}
}
document.getElementById('login-pass').addEventListener('keydown', e => {
if (e.key === 'Enter') doLogin();
});
function logout() {
state.token = null; state.user = null;
localStorage.removeItem('ims_token');
if (state.refreshTimer) clearInterval(state.refreshTimer);
document.getElementById('app').classList.remove('visible');
document.getElementById('login-screen').style.display = 'flex';
}
function startApp() {
document.getElementById('login-screen').style.display = 'none';
document.getElementById('app').classList.add('visible');
document.getElementById('sidebar-username').textContent = state.user.username;
document.getElementById('sidebar-role').textContent = state.user.role;
document.getElementById('user-avatar').textContent = state.user.username[0].toUpperCase();
if (state.user.role === 'admin') {
document.getElementById('admin-nav').style.display = '';
document.getElementById('admin-link').style.display = '';
}
const lastPage = localStorage.getItem('ims_page') || 'dashboard';
navigate(lastPage);
}
(async () => {
const saved = localStorage.getItem('ims_token');
if (saved) {
state.token = saved;
try {
const me = await api.get('/auth/me');
state.user = { username: me.sub, role: me.role };
startApp();
} catch { logout(); }
}
})();
function navigate(page) {
document.querySelectorAll('nav a').forEach(a => a.classList.remove('active'));
const link = document.querySelector(`nav a[data-page="${page}"]`);
if (link) link.classList.add('active');
if (state.refreshTimer) clearInterval(state.refreshTimer);
currentPageInit = {};
localStorage.setItem('ims_page', page);
const titles = {
dashboard:'◈ DASHBOARD',
servers:'⊞ SERVER',
jobs:'⟳ SYNC-JOBS',
runs:'◎ VERLAUF',
users:'◉ BENUTZER'
};
document.getElementById('page-title').textContent = titles[page] || page.toUpperCase();
document.getElementById('topbar-actions').innerHTML = '';
const pages = {
dashboard: renderDashboard,
servers: renderServers,
jobs: renderJobs,
runs: renderRuns,
users: renderUsers
};
if (pages[page]) pages[page]();
}
function toast(msg, type = 'success') {
const t = document.createElement('div');
t.className = `toast ${type}`;
t.textContent = msg;
document.getElementById('toasts').appendChild(t);
setTimeout(() => t.remove(), 3500);
}
function openModal(html) {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = html;
let mouseDownTarget = null;
overlay.addEventListener('mousedown', e => { mouseDownTarget = e.target; });
overlay.addEventListener('mouseup', e => {
if (e.target === overlay && mouseDownTarget === overlay) overlay.remove();
mouseDownTarget = null;
});
document.getElementById('modal-container').appendChild(overlay);
return overlay;
}
function closeModal() {
document.querySelector('.modal-overlay')?.remove();
}
async function loadServers() {
if (!state.serversCache) {
state.serversCache = await api.get('/servers');
}
return state.serversCache;
}
function invalidateServersCache() {
state.serversCache = null;
}
let currentPageInit = {};
// ── Dashboard ──────────────────────────────────────────────────────────────
async function renderDashboard() {
const content = document.getElementById('content');
const isFirst = !currentPageInit.dashboard;
currentPageInit.dashboard = true;
if (isFirst) content.innerHTML = '<div class="empty-state"><div class="spinner"></div></div>';
try {
const s = await api.get('/stats');
let dailyChart = '';
if (s.daily && s.daily.length > 0) {
const maxVal = Math.max(...s.daily.map(d => d.synced), 1);
dailyChart = s.daily.map(d => {
const h = Math.max(4, Math.round((d.synced / maxVal) * 76));
const label = d.day.substring(5);
return `<div class="bar-col">
<div class="bar" style="height:${h}px" title="${d.day}: ${d.synced} msgs, ${d.runs} runs"></div>
<div class="bar-label">${label}</div>
</div>`;
}).join('');
} else {
dailyChart = '<div style="color:var(--muted);font-size:13px;padding:24px 0">Noch keine Daten</div>';
}
const recentRows = (s.recent_runs || []).map(r => `
<div class="activity-item">
<div class="activity-dot ${r.status}"></div>
<div style="flex:1;min-width:0">
<div class="flex gap-8">
<span style="font-weight:500;font-size:13px">${esc(r.job_name)}</span>
<span class="badge ${r.status}">${r.status}</span>
</div>
<div class="activity-meta">
${fmtDate(r.started_at)} &nbsp;·&nbsp;
${r.messages_synced} Nachrichten
${r.duration_sec ? '&nbsp;·&nbsp;' + fmtDuration(r.duration_sec) : ''}
</div>
</div>
</div>
`).join('') || '<div class="empty-state" style="padding:24px">Keine Ausführungen</div>';
content.innerHTML = `
<div class="stat-grid">
<div class="stat-card blue">
<div class="label">Server</div>
<div class="value">${s.servers.total}</div>
<div class="sub">Konfiguriert</div>
</div>
<div class="stat-card blue">
<div class="label">Sync-Jobs</div>
<div class="value">${s.jobs.total}</div>
<div class="sub">${s.jobs.active} aktiv</div>
</div>
<div class="stat-card ${s.jobs.running > 0 ? 'blue' : ''}">
<div class="label">Laufend</div>
<div class="value">${s.jobs.running}</div>
<div class="sub">${s.jobs.queued} in Warteschlange</div>
</div>
<div class="stat-card green">
<div class="label">Nachr. synchronisiert</div>
<div class="value">${fmtNum(s.messages.synced)}</div>
<div class="sub">${s.runs.total} Ausführungen gesamt</div>
</div>
<div class="stat-card ${s.runs.failed > 0 ? 'red' : ''}">
<div class="label">Fehlgeschlagen</div>
<div class="value">${s.runs.failed}</div>
<div class="sub">Ausführungen</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div class="panel">
<div class="panel-header"><h3>Aktivität (14 Tage)</h3></div>
<div class="chart-area">
<div class="bar-chart">${dailyChart}</div>
<div class="chart-legend">
<div class="legend-item"><div class="legend-dot" style="background:var(--accent)"></div> Sync. Nachrichten</div>
</div>
</div>
</div>
<div class="panel">
<div class="panel-header">
<h3>Letzte Ausführungen</h3>
<button class="btn btn-outline btn-sm" onclick="navigate('runs')">Alle →</button>
</div>
<div class="panel-body">${recentRows}</div>
</div>
</div>
`;
state.refreshTimer = setInterval(renderDashboard, 60000);
} catch(e) {
content.innerHTML = `<div class="empty-state"><div>Fehler: ${e.message}</div></div>`;
}
}
// ── Servers ────────────────────────────────────────────────────────────────
async function renderServers() {
const content = document.getElementById('content');
const canEdit = state.user.role !== 'viewer';
const isFirst = !currentPageInit.servers;
currentPageInit.servers = true;
if (canEdit) {
document.getElementById('topbar-actions').innerHTML =
`<button class="btn btn-primary" onclick="showServerModal()">+ Neuer Server</button>`;
}
if (isFirst) content.innerHTML = '<div class="empty-state"><div class="spinner"></div></div>';
try {
const servers = await api.get('/servers');
invalidateServersCache();
if (!servers.length) {
content.innerHTML = `<div class="empty-state">
<div class="icon">⊞</div>
<div>Noch keine Server konfiguriert</div>
${canEdit ? '<button class="btn btn-primary" style="margin-top:16px" onclick="showServerModal()">+ Ersten Server anlegen</button>' : ''}
</div>`;
return;
}
const dirLabels = { source: 'Eingang', destination: 'Ausgang', both: 'Beide' };
content.innerHTML = `
<div class="panel">
<div class="panel-header"><h3>${servers.length} Server</h3></div>
<table>
<thead>
<tr>
<th>Name</th>
<th>Host</th>
<th>Port</th>
<th>SSL</th>
<th>Richtung</th>
<th>Verwendung</th>
<th></th>
</tr>
</thead>
<tbody>
${servers.map(s => `
<tr>
<td>
<div style="font-weight:500">${esc(s.name)}</div>
<div class="text-muted text-sm mono">#${s.id}</div>
</td>
<td class="mono text-sm">${esc(s.host)}</td>
<td class="mono text-sm">${s.port}</td>
<td>${s.ssl ? '<span class="badge enabled">SSL</span>' : '<span class="badge disabled">Kein SSL</span>'}</td>
<td><span class="badge ${s.direction}">${dirLabels[s.direction] || s.direction}</span></td>
<td class="text-sm text-muted">
${s.used_as_src > 0 ? `<span style="color:var(--blue)">${s.used_as_src}× Quelle</span>` : ''}
${s.used_as_src > 0 && s.used_as_dst > 0 ? ' · ' : ''}
${s.used_as_dst > 0 ? `<span style="color:var(--warn)">${s.used_as_dst}× Ziel</span>` : ''}
${s.used_as_src === 0 && s.used_as_dst === 0 ? '<span class="text-muted">—</span>' : ''}
</td>
<td>
<div class="flex gap-8">
${canEdit ? `<button class="btn btn-outline btn-sm" onclick="showServerModal(${s.id})">✎</button>` : ''}
${canEdit ? `<button class="btn btn-danger btn-sm" onclick="deleteServer(${s.id}, '${esc(s.name)}')">✕</button>` : ''}
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
state.refreshTimer = setInterval(renderServers, 60000);
} catch(e) {
content.innerHTML = `<div class="empty-state">Fehler: ${e.message}</div>`;
}
}
async function showServerModal(serverId = null) {
let server = null;
if (serverId) {
try { server = await api.get(`/servers`); server = server.find(s => s.id === serverId); } catch(e) { toast(e.message, 'error'); return; }
}
const title = server ? `Server bearbeiten: ${server.name}` : 'Neuer Server';
openModal(`
<div class="modal">
<div class="modal-header">
<h3>⊞ ${title}</h3>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="modal-body">
<div class="form-grid">
<div class="form-group full">
<label>SERVERNAME</label>
<input id="sv-name" type="text" value="${esc(server?.name||'')}" placeholder="z.B. Google Workspace, Exchange Online">
</div>
<div class="form-group">
<label>HOST</label>
<input id="sv-host" type="text" value="${esc(server?.host||'')}" placeholder="imap.example.com">
</div>
<div class="form-group">
<label>PORT</label>
<input id="sv-port" type="number" value="${server?.port||993}">
</div>
<div class="form-group">
<label>SSL/TLS</label>
<select id="sv-ssl">
<option value="1" ${(!server||server.ssl)?'selected':''}>SSL (empfohlen)</option>
<option value="0" ${(server&&!server.ssl)?'selected':''}>Kein SSL</option>
</select>
</div>
<div class="form-group">
<label>RICHTUNG</label>
<select id="sv-direction">
<option value="source" ${(server?.direction==='source')?'selected':''}>Eingang (Quelle)</option>
<option value="destination" ${(server?.direction==='destination')?'selected':''}>Ausgang (Ziel)</option>
<option value="both" ${(!server||server?.direction==='both')?'selected':''}>Beide Richtungen</option>
</select>
</div>
</div>
<div class="form-actions">
<button class="btn btn-outline" onclick="closeModal()">Abbrechen</button>
<button class="btn btn-primary" onclick="saveServer(${serverId||'null'})">
${server ? 'Speichern' : 'Server anlegen'}
</button>
</div>
</div>
</div>
`);
}
async function saveServer(serverId) {
const data = {
name: document.getElementById('sv-name').value.trim(),
host: document.getElementById('sv-host').value.trim(),
port: parseInt(document.getElementById('sv-port').value),
ssl: document.getElementById('sv-ssl').value === '1',
direction: document.getElementById('sv-direction').value,
};
if (!data.name || !data.host) {
toast('Name und Host sind Pflichtfelder', 'error'); return;
}
try {
if (serverId) {
await api.put(`/servers/${serverId}`, data);
toast('Server aktualisiert');
} else {
await api.post('/servers', data);
toast('Server erstellt');
}
closeModal();
invalidateServersCache();
renderServers();
} catch(e) { toast(e.message, 'error'); }
}
async function deleteServer(id, name) {
if (!confirm(`Server "${name}" wirklich löschen?`)) return;
try {
await api.delete(`/servers/${id}`);
toast('Server gelöscht');
invalidateServersCache();
renderServers();
} catch(e) { toast(e.message, 'error'); }
}
// ── Jobs ───────────────────────────────────────────────────────────────────
async function renderJobs() {
const content = document.getElementById('content');
const canEdit = state.user.role !== 'viewer';
const isFirst = !currentPageInit.jobs;
currentPageInit.jobs = true;
if (canEdit) {
document.getElementById('topbar-actions').innerHTML =
`<button class="btn btn-primary" onclick="showJobModal()">+ Neuer Job</button>`;
}
if (isFirst) content.innerHTML = '<div class="empty-state"><div class="spinner"></div></div>';
try {
const jobs = await api.get('/jobs');
if (!jobs.length) {
content.innerHTML = `<div class="empty-state">
<div class="icon">⟳</div>
<div>Noch keine Sync-Jobs konfiguriert</div>
${canEdit ? '<div style="margin-top:12px"><button class="btn btn-primary" onclick="navigate(\'servers\')">Zuerst Server anlegen →</button></div>' : ''}
</div>`;
return;
}
content.innerHTML = `
<div class="panel">
<div class="panel-header"><h3>${jobs.length} Jobs</h3></div>
<table>
<thead>
<tr>
<th>Name</th>
<th>Quelle → Ziel</th>
<th>Status</th>
<th>Zeitplan</th>
<th>Letzte Ausführung</th>
<th>Synced</th>
<th></th>
</tr>
</thead>
<tbody>
${jobs.map(j => `
<tr>
<td>
<div style="font-weight:500">${esc(j.name)}</div>
<div class="text-muted text-sm mono">#${j.id}</div>
</td>
<td>
<div class="mono text-sm">${esc(j.src_server_name||'?')}${esc(j.dst_server_name||'?')}</div>
<div class="text-muted text-sm">${esc(j.src_user)}${esc(j.dst_user)}</div>
</td>
<td>
<div>${statusBadge(j.status)}</div>
<div style="margin-top:4px">${j.enabled ? '<span class="badge enabled">aktiv</span>' : '<span class="badge disabled">deaktiviert</span>'}</div>
</td>
<td class="mono text-sm">${j.schedule ? esc(j.schedule) : '<span class="text-muted">manuell</span>'}</td>
<td class="text-sm text-muted">${j.last_run ? fmtDate(j.last_run) : '—'}</td>
<td class="mono">${fmtNum(j.total_synced || 0)}</td>
<td>
<div class="flex gap-8">
${canEdit && j.status === 'idle' ? `<button class="btn btn-outline btn-sm" onclick="triggerJob(${j.id})">▶ Start</button>` : ''}
${canEdit && (j.status === 'queued' || j.status === 'running') ? `<button class="btn btn-outline btn-sm" onclick="stopJob(${j.id})">■ Stop</button>` : ''}
<button class="btn btn-outline btn-sm" onclick="navigate('runs');showRunsForJob(${j.id},'${esc(j.name)}')">Logs</button>
${canEdit ? `<button class="btn btn-outline btn-sm" onclick="showJobModal(${j.id})">✎</button>` : ''}
${canEdit ? `<button class="btn btn-danger btn-sm" onclick="deleteJob(${j.id}, '${esc(j.name)}')">✕</button>` : ''}
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
state.refreshTimer = setInterval(renderJobs, 30000);
} catch(e) {
content.innerHTML = `<div class="empty-state">Fehler: ${e.message}</div>`;
}
}
async function showJobModal(jobId = null) {
let job = null;
if (jobId) {
try { job = await api.get(`/jobs/${jobId}`); } catch(e) { toast(e.message, 'error'); return; }
}
const servers = await loadServers();
const srcServers = servers.filter(s => s.direction === 'source' || s.direction === 'both');
const dstServers = servers.filter(s => s.direction === 'destination' || s.direction === 'both');
if (!srcServers.length && !dstServers.length) {
toast('Bitte zuerst Server anlegen', 'error');
navigate('servers');
return;
}
const title = job ? `Job bearbeiten: ${job.name}` : 'Neuer Sync-Job';
function serverOptions(list, selectedId) {
if (!list.length) return '<option value="">— Keine Server verfügbar —</option>';
return list.map(s =>
`<option value="${s.id}" ${s.id === selectedId ? 'selected' : ''}>${esc(s.name)} (${esc(s.host)}:${s.port})</option>`
).join('');
}
function serverDetailLine(s) {
if (!s) return '';
return `${esc(s.host)}:${s.port} ${s.ssl ? 'SSL' : 'Kein SSL'}`;
}
const selectedSrcServer = job ? srcServers.find(s => s.id === job.src_server_id) : null;
const selectedDstServer = job ? dstServers.find(s => s.id === job.dst_server_id) : null;
openModal(`
<div class="modal">
<div class="modal-header">
<h3>⟳ ${title}</h3>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="modal-body">
<div class="form-grid">
<div class="form-group full">
<label>JOB-NAME</label>
<input id="j-name" type="text" value="${esc(job?.name||'')}" placeholder="z.B. Max Mustermann Migration">
</div>
<div class="section-divider-prominent src"><span class="section-title">▼ Quell-Server (Eingang)</span></div>
<div class="form-group full">
<label>SERVER</label>
<select id="j-src-server" onchange="updateServerInfo('src')">
<option value="">— Server auswählen —</option>
${serverOptions(srcServers, job?.src_server_id)}
</select>
</div>
<div class="form-group full" id="j-src-server-info" style="display:${selectedSrcServer?'block':'none'}">
<div class="server-info" id="j-src-server-detail">${serverDetailLine(selectedSrcServer)}</div>
</div>
<div class="form-group">
<label>BENUTZER (E-Mail)</label>
<input id="j-s-user" type="text" value="${esc(job?.src_user||'')}" placeholder="user@example.com">
</div>
<div class="form-group">
<label>PASSWORT</label>
<input id="j-s-pass" type="password" value="${esc(job?.src_password||'')}" placeholder="••••••••">
</div>
<div class="section-divider-prominent dst"><span class="section-title">▼ Ziel-Server (Ausgang)</span></div>
<div class="form-group full">
<label>SERVER</label>
<select id="j-dst-server" onchange="updateServerInfo('dst')">
<option value="">— Server auswählen —</option>
${serverOptions(dstServers, job?.dst_server_id)}
</select>
</div>
<div class="form-group full" id="j-dst-server-info" style="display:${selectedDstServer?'block':'none'}">
<div class="server-info" id="j-dst-server-detail">${serverDetailLine(selectedDstServer)}</div>
</div>
<div class="form-group">
<label>BENUTZER (E-Mail)</label>
<input id="j-d-user" type="text" value="${esc(job?.dst_user||'')}" placeholder="user@ziel.com">
</div>
<div class="form-group">
<label>PASSWORT</label>
<input id="j-d-pass" type="password" value="${esc(job?.dst_password||'')}" placeholder="••••••••">
</div>
<div class="section-divider">Zeitplan & Optionen</div>
<div class="form-group">
<label>CRON-ZEITPLAN (optional)</label>
<input id="j-schedule" type="text" value="${esc(job?.schedule||'')}" placeholder="0 2 * * * (täglich 2 Uhr)">
</div>
<div class="form-group">
<label>STATUS</label>
<select id="j-enabled">
<option value="1" ${(!job||job.enabled)?'selected':''}>Aktiviert</option>
<option value="0" ${(job&&!job.enabled)?'selected':''}>Deaktiviert</option>
</select>
</div>
<div class="form-group full">
<label>EXTRA ARGUMENTE (imapsync)</label>
<input id="j-extra" type="text" value="${esc(job?.extra_args||'')}" placeholder="z.B. --delete2 --addheader">
</div>
</div>
<div class="form-actions">
<button class="btn btn-outline" onclick="closeModal()">Abbrechen</button>
<button class="btn btn-primary" onclick="saveJob(${jobId||'null'})">
${job ? 'Speichern' : 'Job erstellen'}
</button>
</div>
</div>
</div>
`);
}
function updateServerInfo(side) {
const servers = state.serversCache || [];
const selId = side === 'src'
? parseInt(document.getElementById('j-src-server').value)
: parseInt(document.getElementById('j-dst-server').value);
const server = servers.find(s => s.id === selId);
const infoEl = document.getElementById(`j-${side}-server-info`);
const detailEl = document.getElementById(`j-${side}-server-detail`);
if (server) {
detailEl.textContent = `${server.host}:${server.port} ${server.ssl ? 'SSL' : 'Kein SSL'}`;
infoEl.style.display = 'block';
} else {
infoEl.style.display = 'none';
}
}
async function saveJob(jobId) {
const srcServerId = parseInt(document.getElementById('j-src-server').value);
const dstServerId = parseInt(document.getElementById('j-dst-server').value);
if (!srcServerId || !dstServerId) {
toast('Bitte Quell- und Ziel-Server auswählen', 'error'); return;
}
const data = {
name: document.getElementById('j-name').value.trim(),
src_server_id: srcServerId,
src_user: document.getElementById('j-s-user').value.trim(),
src_password: document.getElementById('j-s-pass').value,
dst_server_id: dstServerId,
dst_user: document.getElementById('j-d-user').value.trim(),
dst_password: document.getElementById('j-d-pass').value,
schedule: document.getElementById('j-schedule').value.trim() || null,
enabled: document.getElementById('j-enabled').value === '1',
extra_args: document.getElementById('j-extra').value.trim(),
};
if (!data.name || !data.src_user || !data.dst_user) {
toast('Bitte alle Pflichtfelder ausfüllen', 'error'); return;
}
try {
if (jobId) {
await api.put(`/jobs/${jobId}`, data);
toast('Job aktualisiert');
} else {
await api.post('/jobs', data);
toast('Job erstellt');
}
closeModal();
renderJobs();
} catch(e) { toast(e.message, 'error'); }
}
async function triggerJob(id) {
try {
await api.post(`/jobs/${id}/trigger`);
toast('Job gestartet');
renderJobs();
} catch(e) { toast(e.message, 'error'); }
}
async function stopJob(id) {
try {
await api.post(`/jobs/${id}/stop`);
toast('Job gestoppt');
renderJobs();
} catch(e) { toast(e.message, 'error'); }
}
async function deleteJob(id, name) {
if (!confirm(`Job "${name}" wirklich löschen? Alle Verlaufs-Einträge werden ebenfalls gelöscht.`)) return;
try {
await api.delete(`/jobs/${id}`);
toast('Job gelöscht');
renderJobs();
} catch(e) { toast(e.message, 'error'); }
}
// ── Runs ───────────────────────────────────────────────────────────────────
let activeJobFilter = null;
function showRunsForJob(jobId, jobName) {
activeJobFilter = { id: jobId, name: jobName };
renderRuns();
}
async function renderRuns() {
const content = document.getElementById('content');
const isFirst = !currentPageInit.runs;
currentPageInit.runs = true;
if (isFirst) content.innerHTML = '<div class="empty-state"><div class="spinner"></div></div>';
try {
let runs = [];
if (activeJobFilter) {
runs = await api.get(`/jobs/${activeJobFilter.id}/runs?limit=100`);
document.getElementById('topbar-actions').innerHTML =
`<span class="text-muted text-sm">Filter: ${esc(activeJobFilter.name)}</span>
<button class="btn btn-outline btn-sm" onclick="activeJobFilter=null;renderRuns()">✕ Filter</button>`;
} else {
document.getElementById('topbar-actions').innerHTML = '';
const jobs = await api.get('/jobs');
for (const j of jobs.slice(0, 20)) {
const jRuns = await api.get(`/jobs/${j.id}/runs?limit=10`);
runs.push(...jRuns.map(r => ({...r, job_name: j.name})));
}
runs.sort((a, b) => b.started_at > a.started_at ? 1 : -1);
}
if (!runs.length) {
content.innerHTML = '<div class="empty-state"><div class="icon">◎</div><div>Keine Ausführungen gefunden</div></div>';
return;
}
content.innerHTML = `
<div class="panel">
<div class="panel-header"><h3>${runs.length} Ausführungen</h3></div>
<table>
<thead>
<tr>
<th>Run-ID</th>
<th>Job</th>
<th>Status</th>
<th>Gestartet</th>
<th>Dauer</th>
<th>Synced</th>
<th>Fehler</th>
<th></th>
</tr>
</thead>
<tbody>
${runs.map(r => `
<tr>
<td class="mono text-muted text-sm">#${r.id}</td>
<td>${esc(r.job_name || `Job #${r.job_id}`)}</td>
<td>${statusBadge(r.status)}</td>
<td class="text-sm text-muted">${fmtDate(r.started_at)}</td>
<td class="mono text-sm">${r.duration_sec ? fmtDuration(r.duration_sec) : r.status === 'running' ? '<span class="spinner"></span>' : '—'}</td>
<td class="mono">${fmtNum(r.messages_synced)}</td>
<td class="mono ${r.errors > 0 ? 'text-sm' : ''}" style="${r.errors > 0 ? 'color:var(--danger)' : ''}">${r.errors || 0}</td>
<td>
<button class="btn btn-outline btn-sm" onclick="showLog(${r.id})">📄 Log</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
} catch(e) {
content.innerHTML = `<div class="empty-state">Fehler: ${e.message}</div>`;
}
}
async function showLog(runId) {
try {
const data = await api.get(`/runs/${runId}/log`);
const highlighted = highlightLog(data.content);
openModal(`
<div class="modal" style="width:780px">
<div class="modal-header">
<h3>📄 Log — Run #${runId}</h3>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="modal-body">
<div class="log-viewer">${highlighted}</div>
</div>
</div>
`);
} catch(e) { toast('Log nicht verfügbar: ' + e.message, 'error'); }
}
function highlightLog(text) {
return esc(text)
.replace(/(ERR[A-Z]*|FAILED|ERROR)/gi, '<span class="log-err">$1</span>')
.replace(/(OK|SUCCESS|DONE|Synced|Transferred)/gi, '<span class="log-ok">$1</span>')
.replace(/(WARN[A-Z]*|SKIP[A-Z]*)/gi, '<span class="log-warn">$1</span>')
.replace(/(--[a-z]+[a-z0-9]*)/g, '<span class="log-info">$1</span>');
}
// ── Users ──────────────────────────────────────────────────────────────────
async function renderUsers() {
if (state.user.role !== 'admin') {
document.getElementById('content').innerHTML =
'<div class="empty-state">Keine Berechtigung</div>';
return;
}
document.getElementById('topbar-actions').innerHTML =
`<button class="btn btn-primary" onclick="showUserModal()">+ Benutzer erstellen</button>`;
const content = document.getElementById('content');
const isFirst = !currentPageInit.users;
currentPageInit.users = true;
if (isFirst) content.innerHTML = '<div class="empty-state"><div class="spinner"></div></div>';
try {
const users = await api.get('/users');
content.innerHTML = `
<div class="panel">
<div class="panel-header"><h3>${users.length} Benutzer</h3></div>
<table>
<thead>
<tr>
<th>Benutzer</th>
<th>Rolle</th>
<th>Erstellt am</th>
<th>Berechtigungen</th>
<th></th>
</tr>
</thead>
<tbody>
${users.map(u => `
<tr>
<td>
<div class="flex gap-8">
<div class="user-avatar" style="width:24px;height:24px;font-size:10px">${u.username[0].toUpperCase()}</div>
<span style="font-weight:500">${esc(u.username)}</span>
${u.username === state.user.username ? '<span class="text-muted text-sm">(ich)</span>' : ''}
</div>
</td>
<td><span class="badge ${u.role}">${u.role}</span></td>
<td class="text-sm text-muted">${fmtDate(u.created_at)}</td>
<td class="text-sm text-muted">${roleDesc(u.role)}</td>
<td>
<div class="flex gap-8">
<button class="btn btn-outline btn-sm" onclick="showUserModal(${u.id},'${esc(u.username)}','${u.role}')">✎ Bearbeiten</button>
${u.username !== state.user.username ?
`<button class="btn btn-danger btn-sm" onclick="deleteUser(${u.id},'${esc(u.username)}')">✕</button>` : ''}
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
<div class="panel" style="margin-top:16px">
<div class="panel-header"><h3>Rollenbeschreibung</h3></div>
<div style="padding:18px;display:grid;grid-template-columns:repeat(3,1fr);gap:16px">
<div>
<span class="badge admin" style="margin-bottom:8px">admin</span>
<div class="text-sm text-muted" style="margin-top:8px">Vollzugriff: Benutzer, Server, Jobs, Logs erstellen/löschen</div>
</div>
<div>
<span class="badge operator" style="margin-bottom:8px">operator</span>
<div class="text-sm text-muted" style="margin-top:8px">Server, Jobs erstellen/starten, Logs einsehen. Keine Benutzerverwaltung.</div>
</div>
<div>
<span class="badge viewer" style="margin-bottom:8px">viewer</span>
<div class="text-sm text-muted" style="margin-top:8px">Nur Lesezugriff: Statistiken und Logs ansehen</div>
</div>
</div>
</div>
`;
} catch(e) {
content.innerHTML = `<div class="empty-state">Fehler: ${e.message}</div>`;
}
}
function showUserModal(id = null, username = '', role = 'viewer') {
const isEdit = id !== null;
openModal(`
<div class="modal">
<div class="modal-header">
<h3>${isEdit ? `Benutzer bearbeiten: ${username}` : 'Neuer Benutzer'}</h3>
<button class="modal-close" onclick="closeModal()">✕</button>
</div>
<div class="modal-body">
<div class="form-grid">
${!isEdit ? `
<div class="form-group full">
<label>BENUTZERNAME</label>
<input id="u-name" type="text" placeholder="benutzername">
</div>
` : ''}
<div class="form-group ${!isEdit ? '' : 'full'}">
<label>${isEdit ? 'NEUES PASSWORT (leer = unverändert)' : 'PASSWORT'}</label>
<input id="u-pass" type="password" placeholder="••••••••">
</div>
<div class="form-group ${!isEdit ? '' : 'full'}">
<label>ROLLE</label>
<select id="u-role">
<option value="admin" ${role==='admin'?'selected':''}>Admin</option>
<option value="operator" ${role==='operator'?'selected':''}>Operator</option>
<option value="viewer" ${role==='viewer'?'selected':''}>Viewer</option>
</select>
</div>
</div>
<div class="form-actions">
<button class="btn btn-outline" onclick="closeModal()">Abbrechen</button>
<button class="btn btn-primary" onclick="saveUser(${id||'null'})">
${isEdit ? 'Speichern' : 'Erstellen'}
</button>
</div>
</div>
</div>
`);
}
async function saveUser(id) {
const pass = document.getElementById('u-pass').value;
const role = document.getElementById('u-role').value;
try {
if (id) {
const body = {};
if (pass) body.password = pass;
body.role = role;
await api.put(`/users/${id}`, body);
toast('Benutzer aktualisiert');
} else {
const name = document.getElementById('u-name').value.trim();
if (!name || !pass) { toast('Name und Passwort erforderlich', 'error'); return; }
await api.post('/users', { username: name, password: pass, role });
toast('Benutzer erstellt');
}
closeModal();
renderUsers();
} catch(e) { toast(e.message, 'error'); }
}
async function deleteUser(id, name) {
if (!confirm(`Benutzer "${name}" wirklich löschen?`)) return;
try {
await api.delete(`/users/${id}`);
toast('Benutzer gelöscht');
renderUsers();
} catch(e) { toast(e.message, 'error'); }
}
// ── Helpers ────────────────────────────────────────────────────────────────
function esc(str) {
return String(str || '').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
function fmtNum(n) { return Number(n||0).toLocaleString('de-DE'); }
function fmtDate(d) {
if (!d) return '—';
return new Date(d + (d.includes('Z')||d.includes('+') ? '' : 'Z'))
.toLocaleString('de-DE', { day:'2-digit', month:'2-digit', year:'2-digit', hour:'2-digit', minute:'2-digit' });
}
function fmtDuration(sec) {
if (sec < 60) return sec + 's';
if (sec < 3600) return Math.floor(sec/60) + 'm ' + (sec%60) + 's';
return Math.floor(sec/3600) + 'h ' + Math.floor((sec%3600)/60) + 'm';
}
function statusBadge(s) {
const labels = { idle:'Bereit', queued:'Warteschlange', running:'Läuft', done:'Fertig', failed:'Fehler', cancelling:'Abbruch…', cancelled:'Abgebrochen' };
return `<span class="badge ${s}"><span class="badge-dot"></span>${labels[s]||s}</span>`;
}
function roleDesc(r) {
const d = { admin:'Vollzugriff', operator:'Jobs & Server verwalten', viewer:'Nur Lesen' };
return d[r] || r;
}
</script>
</body>
</html>