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>
This commit is contained in:
Sebastian Serfling
2026-04-22 14:07:13 +02:00
parent f2d749ba3f
commit f600cd52f3
4 changed files with 721 additions and 205 deletions
+385 -99
View File
@@ -33,7 +33,7 @@
background: var(--bg);
color: var(--text);
min-height: 100vh;
font-size: 14px;
font-size: 15px;
}
/* ── Login ── */
@@ -130,10 +130,10 @@
display: flex;
align-items: center;
gap: 10px;
padding: 9px 20px;
padding: 10px 20px;
color: var(--muted);
text-decoration: none;
font-size: 13px;
font-size: 14px;
font-weight: 500;
border-left: 2px solid transparent;
transition: all 0.15s;
@@ -170,7 +170,7 @@
flex-shrink: 0;
}
.user-info { flex: 1; min-width: 0; }
.user-info .name { font-size: 12px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.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;
@@ -182,7 +182,7 @@
/* ── Main Content ── */
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.topbar {
height: 52px;
height: 54px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
@@ -192,7 +192,7 @@
}
.page-title {
flex: 1;
font-size: 14px;
font-size: 15px;
font-weight: 600;
font-family: var(--mono);
letter-spacing: 0.5px;
@@ -223,7 +223,7 @@
}
.stat-card .value {
font-family: var(--mono);
font-size: 28px;
font-size: 30px;
font-weight: 600;
line-height: 1;
}
@@ -261,7 +261,7 @@
table { width: 100%; border-collapse: collapse; }
th {
text-align: left;
padding: 10px 18px;
padding: 9px 18px;
font-family: var(--mono);
font-size: 10px;
letter-spacing: 1.5px;
@@ -270,7 +270,7 @@
border-bottom: 1px solid var(--border2);
font-weight: 500;
}
td { padding: 11px 18px; border-bottom: 1px solid var(--border2); vertical-align: middle; }
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); }
@@ -298,11 +298,18 @@
.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 {
@@ -313,7 +320,7 @@
border-radius: 5px;
border: 1px solid transparent;
font-family: var(--sans);
font-size: 13px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
@@ -326,14 +333,14 @@
.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: 12px; }
.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: 12px; font-weight: 500; color: var(--muted); font-family: var(--mono); letter-spacing: 0.5px; }
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);
@@ -341,7 +348,7 @@
color: var(--text);
padding: 8px 12px;
font-family: var(--sans);
font-size: 13px;
font-size: 15px;
transition: border-color 0.15s;
width: 100%;
}
@@ -350,7 +357,7 @@
border-color: var(--blue);
}
input[type="checkbox"] { width: auto; }
textarea { resize: vertical; min-height: 80px; font-family: var(--mono); font-size: 12px; }
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 {
@@ -363,6 +370,45 @@
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 {
@@ -386,7 +432,7 @@
border-bottom: 1px solid var(--border2);
display: flex; align-items: center; gap: 12px;
}
.modal-header h3 { flex: 1; font-family: var(--mono); font-size: 14px; }
.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); }
@@ -443,7 +489,7 @@
/* ── Misc ── */
.mono { font-family: var(--mono); }
.text-muted { color: var(--muted); }
.text-sm { font-size: 12px; }
.text-sm { font-size: 13px; }
.flex { display: flex; align-items: center; }
.gap-8 { gap: 8px; }
.gap-12 { gap: 12px; }
@@ -481,8 +527,8 @@
<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:12px;text-align:center;margin-top:12px;display:none"></div>
<div style="text-align:center;margin-top:20px;font-size:11px;color:var(--muted);font-family:var(--mono)">
<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>
@@ -501,6 +547,9 @@
<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>
@@ -538,10 +587,8 @@
<div class="toast-container" id="toasts"></div>
<script>
// ── State ──────────────────────────────────────────────────────────────────
let state = { token: null, user: null, refreshTimer: null };
let state = { token: null, user: null, refreshTimer: null, serversCache: null };
// ── API ────────────────────────────────────────────────────────────────────
const api = {
async req(method, path, body = null) {
const opts = {
@@ -562,7 +609,6 @@ const api = {
delete: (p) => api.req('DELETE', p),
};
// ── Auth ───────────────────────────────────────────────────────────────────
async function doLogin() {
const u = document.getElementById('login-user').value.trim();
const p = document.getElementById('login-pass').value;
@@ -595,21 +641,17 @@ function logout() {
function startApp() {
document.getElementById('login-screen').style.display = 'none';
document.getElementById('app').classList.add('visible');
// Sidebar user info
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 = '';
}
navigate('dashboard');
const lastPage = localStorage.getItem('ims_page') || 'dashboard';
navigate(lastPage);
}
// Check saved token
(async () => {
const saved = localStorage.getItem('ims_token');
if (saved) {
@@ -622,22 +664,34 @@ function startApp() {
}
})();
// ── Navigation ──────────────────────────────────────────────────────────────
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', jobs:'⟳ SYNC-JOBS', runs:'◎ VERLAUF', users:'◉ BENUTZER' };
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, jobs: renderJobs, runs: renderRuns, users: renderUsers };
const pages = {
dashboard: renderDashboard,
servers: renderServers,
jobs: renderJobs,
runs: renderRuns,
users: renderUsers
};
if (pages[page]) pages[page]();
}
// ── Toast ──────────────────────────────────────────────────────────────────
function toast(msg, type = 'success') {
const t = document.createElement('div');
t.className = `toast ${type}`;
@@ -646,12 +700,16 @@ function toast(msg, type = 'success') {
setTimeout(() => t.remove(), 3500);
}
// ── Modal ──────────────────────────────────────────────────────────────────
function openModal(html) {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay';
overlay.innerHTML = html;
overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); });
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;
}
@@ -659,10 +717,25 @@ 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');
content.innerHTML = '<div class="empty-state"><div class="spinner"></div></div>';
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 = '';
@@ -670,14 +743,14 @@ async function renderDashboard() {
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); // MM-DD
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:12px;padding:24px 0">Noch keine Daten</div>';
dailyChart = '<div style="color:var(--muted);font-size:13px;padding:24px 0">Noch keine Daten</div>';
}
const recentRows = (s.recent_runs || []).map(r => `
@@ -699,6 +772,11 @@ async function renderDashboard() {
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>
@@ -741,31 +819,199 @@ async function renderDashboard() {
</div>
`;
// Auto-refresh every 30s
state.refreshTimer = setInterval(renderDashboard, 30000);
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>`;
}
content.innerHTML = '<div class="empty-state"><div class="spinner"></div></div>';
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 ? '<button class="btn btn-primary" style="margin-top:16px" onclick="showJobModal()">+ Ersten Job erstellen</button>' : ''}
${canEdit ? '<div style="margin-top:12px"><button class="btn btn-primary" onclick="navigate(\'servers\')">Zuerst Server anlegen →</button></div>' : ''}
</div>`;
return;
}
@@ -793,7 +1039,7 @@ async function renderJobs() {
<div class="text-muted text-sm mono">#${j.id}</div>
</td>
<td>
<div class="mono text-sm">${esc(j.src_host)}${esc(j.dst_host)}</div>
<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>
@@ -806,7 +1052,7 @@ async function renderJobs() {
<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' ? `<button class="btn btn-outline btn-sm" onclick="stopJob(${j.id})">■ Stop</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>` : ''}
@@ -819,8 +1065,7 @@ async function renderJobs() {
</div>
`;
// Auto-refresh every 15s
state.refreshTimer = setInterval(renderJobs, 15000);
state.refreshTimer = setInterval(renderJobs, 30000);
} catch(e) {
content.innerHTML = `<div class="empty-state">Fehler: ${e.message}</div>`;
}
@@ -831,8 +1076,35 @@ async function showJobModal(jobId = 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';
const overlay = openModal(`
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>
@@ -845,55 +1117,45 @@ async function showJobModal(jobId = null) {
<input id="j-name" type="text" value="${esc(job?.name||'')}" placeholder="z.B. Max Mustermann Migration">
</div>
<div class="section-divider">Quell-Server (Source)</div>
<div class="form-group">
<label>HOST</label>
<input id="j-s-host" type="text" value="${esc(job?.src_host||'')}" placeholder="mail.example.com">
<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>PORT</label>
<input id="j-s-port" type="number" value="${job?.src_port||993}">
</div>
<div class="form-group">
<label>BENUTZER</label>
<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="form-group">
<label>SSL/TLS</label>
<select id="j-s-ssl">
<option value="1" ${(!job||job.src_ssl)?'selected':''}>SSL (empfohlen)</option>
<option value="0" ${(job&&!job.src_ssl)?'selected':''}>Kein SSL</option>
<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="section-divider">Ziel-Server (Destination)</div>
<div class="form-group">
<label>HOST</label>
<input id="j-d-host" type="text" value="${esc(job?.dst_host||'')}" placeholder="mail.ziel.com">
<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>PORT</label>
<input id="j-d-port" type="number" value="${job?.dst_port||993}">
</div>
<div class="form-group">
<label>BENUTZER</label>
<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="form-group">
<label>SSL/TLS</label>
<select id="j-d-ssl">
<option value="1" ${(!job||job.dst_ssl)?'selected':''}>SSL (empfohlen)</option>
<option value="0" ${(job&&!job.dst_ssl)?'selected':''}>Kein SSL</option>
</select>
</div>
<div class="section-divider">Zeitplan & Optionen</div>
<div class="form-group">
@@ -923,24 +1185,45 @@ async function showJobModal(jobId = null) {
`);
}
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_host: document.getElementById('j-s-host').value.trim(),
src_port: parseInt(document.getElementById('j-s-port').value),
src_ssl: document.getElementById('j-s-ssl').value === '1',
src_user: document.getElementById('j-s-user').value.trim(),
src_password: document.getElementById('j-s-pass').value,
dst_host: document.getElementById('j-d-host').value.trim(),
dst_port: parseInt(document.getElementById('j-d-port').value),
dst_ssl: document.getElementById('j-d-ssl').value === '1',
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(),
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_host || !data.src_user || !data.dst_host || !data.dst_user) {
if (!data.name || !data.src_user || !data.dst_user) {
toast('Bitte alle Pflichtfelder ausfüllen', 'error'); return;
}
try {
@@ -991,7 +1274,9 @@ function showRunsForJob(jobId, jobName) {
async function renderRuns() {
const content = document.getElementById('content');
content.innerHTML = '<div class="empty-state"><div class="spinner"></div></div>';
const isFirst = !currentPageInit.runs;
currentPageInit.runs = true;
if (isFirst) content.innerHTML = '<div class="empty-state"><div class="spinner"></div></div>';
try {
let runs = [];
@@ -1001,7 +1286,6 @@ async function renderRuns() {
`<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 {
// Load all jobs and their runs
document.getElementById('topbar-actions').innerHTML = '';
const jobs = await api.get('/jobs');
for (const j of jobs.slice(0, 20)) {
@@ -1094,7 +1378,9 @@ async function renderUsers() {
`<button class="btn btn-primary" onclick="showUserModal()">+ Benutzer erstellen</button>`;
const content = document.getElementById('content');
content.innerHTML = '<div class="empty-state"><div class="spinner"></div></div>';
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');
@@ -1142,11 +1428,11 @@ async function renderUsers() {
<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, Jobs, Logs erstellen/löschen</div>
<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">Jobs erstellen/starten, Logs einsehen. Keine Benutzerverwaltung.</div>
<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>
@@ -1246,11 +1532,11 @@ function fmtDuration(sec) {
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' };
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 verwalten', viewer:'Nur Lesen' };
const d = { admin:'Vollzugriff', operator:'Jobs & Server verwalten', viewer:'Nur Lesen' };
return d[r] || r;
}
</script>