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:
+385
-99
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user