- Add .gitignore: exclude compiled binaries, build artifacts, and Helm values files containing real secrets (authentik, prometheus) - Add all Kubernetes deployment manifests (deployment/) - Add services source code: ha-sync, device-inventory, games-console, paperclip, parts-inventory - Add Ansible orchestration: playbooks, roles, inventory, cloud-init - Add hardware specs, execution plans, scripts, HOMELAB.md - Add skills/homelab/SKILL.md + skills/install.sh to preserve Copilot skill - Remove previously-tracked inventory-cli binary from git index Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1728 lines
67 KiB
HTML
1728 lines
67 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>HA Sync</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
:root {
|
||
--bg: #0f1117;
|
||
--card: #1a1d27;
|
||
--card-hover:#1f2335;
|
||
--border: #2a2d3e;
|
||
--border2: #353851;
|
||
--text: #e2e5f0;
|
||
--muted: #8890a8;
|
||
--accent: #4f8ef7;
|
||
--accent-dim:#2a4a8a;
|
||
--success: #22c55e;
|
||
--warning: #eab308;
|
||
--danger: #ef4444;
|
||
--grey: #4b5563;
|
||
|
||
--pair-media: #4f8ef7;
|
||
--pair-photos: #a855f7;
|
||
--pair-owncloud: #06b6d4;
|
||
--pair-games: #22c55e;
|
||
--pair-infra: #f97316;
|
||
--pair-ai: #ec4899;
|
||
}
|
||
|
||
html, body { height: 100%; }
|
||
body {
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
/* ── Layout ─────────────────────────────────────────────────────── */
|
||
.app-header {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 20;
|
||
background: var(--card);
|
||
border-bottom: 1px solid var(--border);
|
||
padding: 0 24px;
|
||
height: 56px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.app-header .logo {
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: var(--accent);
|
||
letter-spacing: -0.02em;
|
||
}
|
||
.app-header .logo span { color: var(--muted); font-weight: 400; font-size: 13px; margin-left: 10px; }
|
||
.header-actions { display: flex; gap: 10px; align-items: center; }
|
||
|
||
.view { display: none; min-height: calc(100vh - 56px); }
|
||
.view.active { display: block; }
|
||
|
||
/* ── Buttons ─────────────────────────────────────────────────────── */
|
||
.btn {
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
padding: 7px 14px; border-radius: 7px; border: none; cursor: pointer;
|
||
font-size: 13px; font-weight: 500; font-family: inherit;
|
||
transition: background 0.15s, opacity 0.15s;
|
||
white-space: nowrap;
|
||
}
|
||
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||
.btn-primary { background: var(--accent); color: #fff; }
|
||
.btn-primary:hover:not(:disabled) { background: #3d7de6; }
|
||
.btn-ghost { background: transparent; color: var(--muted); border: 1px solid var(--border2); }
|
||
.btn-ghost:hover:not(:disabled) { color: var(--text); border-color: var(--muted); }
|
||
.btn-danger { background: rgba(239,68,68,0.15); color: var(--danger); border: 1px solid rgba(239,68,68,0.3); }
|
||
.btn-danger:hover:not(:disabled) { background: rgba(239,68,68,0.25); }
|
||
.btn-success { background: rgba(34,197,94,0.15); color: var(--success); border: 1px solid rgba(34,197,94,0.3); }
|
||
.btn-success:hover:not(:disabled) { background: rgba(34,197,94,0.25); }
|
||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||
.btn-icon { padding: 6px; border-radius: 6px; background: transparent; border: 1px solid var(--border2); color: var(--muted); }
|
||
.btn-icon:hover { color: var(--text); border-color: var(--muted); }
|
||
|
||
/* ── Toggle switch ───────────────────────────────────────────────── */
|
||
.toggle-wrap { display: flex; align-items: center; gap: 8px; }
|
||
.toggle { position: relative; display: inline-block; width: 36px; height: 20px; }
|
||
.toggle input { opacity: 0; width: 0; height: 0; }
|
||
.toggle-slider {
|
||
position: absolute; cursor: pointer; inset: 0;
|
||
background: var(--border2); border-radius: 20px;
|
||
transition: background 0.2s;
|
||
}
|
||
.toggle-slider::before {
|
||
content: ''; position: absolute;
|
||
height: 14px; width: 14px; left: 3px; bottom: 3px;
|
||
background: #fff; border-radius: 50%;
|
||
transition: transform 0.2s;
|
||
}
|
||
.toggle input:checked + .toggle-slider { background: var(--accent); }
|
||
.toggle input:checked + .toggle-slider::before { transform: translateX(16px); }
|
||
|
||
/* ── Job Cards Grid ──────────────────────────────────────────────── */
|
||
.view-header {
|
||
padding: 20px 24px 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.view-header h2 { font-size: 16px; font-weight: 600; color: var(--text); }
|
||
.view-header .subtitle { font-size: 12px; color: var(--muted); margin-top: 2px; }
|
||
|
||
#jobs-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
|
||
gap: 16px;
|
||
padding: 8px 24px 24px;
|
||
}
|
||
|
||
.job-card {
|
||
background: var(--card);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: 18px;
|
||
cursor: pointer;
|
||
transition: border-color 0.15s, background 0.15s, transform 0.1s;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
.job-card:hover { border-color: var(--border2); background: var(--card-hover); transform: translateY(-1px); }
|
||
.job-card-accent {
|
||
position: absolute; top: 0; left: 0; right: 0; height: 3px;
|
||
border-radius: 12px 12px 0 0;
|
||
}
|
||
|
||
.card-top {
|
||
display: flex; align-items: flex-start; justify-content: space-between;
|
||
margin-bottom: 14px;
|
||
}
|
||
.card-title-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||
.card-name { font-size: 15px; font-weight: 600; color: var(--text); }
|
||
.badge {
|
||
display: inline-flex; align-items: center;
|
||
padding: 2px 8px; border-radius: 99px; font-size: 11px; font-weight: 600;
|
||
}
|
||
.badge-pair { color: #fff; }
|
||
.badge-dir {
|
||
background: rgba(255,255,255,0.07);
|
||
color: var(--muted);
|
||
font-family: monospace; font-size: 11px;
|
||
}
|
||
|
||
.status-dot {
|
||
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
|
||
box-shadow: 0 0 6px currentColor;
|
||
}
|
||
.dot-green { background: var(--success); color: var(--success); }
|
||
.dot-yellow { background: var(--warning); color: var(--warning); }
|
||
.dot-red { background: var(--danger); color: var(--danger); }
|
||
.dot-grey { background: var(--grey); color: var(--grey); box-shadow: none; }
|
||
|
||
.card-meta { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
|
||
.card-meta-row { display: flex; align-items: center; gap: 8px; color: var(--muted); font-size: 12px; }
|
||
.card-meta-row .icon { font-size: 13px; width: 16px; text-align: center; }
|
||
.card-meta-row .val { color: var(--text); font-weight: 500; }
|
||
.card-meta-row .outcome { font-weight: 600; }
|
||
.outcome-ok { color: var(--success); }
|
||
.outcome-fail { color: var(--danger); }
|
||
.outcome-dry { color: var(--muted); }
|
||
|
||
.file-counts { display: flex; gap: 12px; margin-bottom: 14px; }
|
||
.file-stat { display: flex; flex-direction: column; align-items: center; }
|
||
.file-stat .num { font-size: 15px; font-weight: 700; color: var(--text); }
|
||
.file-stat .lbl { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
|
||
.file-stat .num.copied { color: var(--accent); }
|
||
.file-stat .num.deleted { color: var(--danger); }
|
||
.file-stat .num.skipped { color: var(--muted); }
|
||
|
||
.card-actions {
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
padding-top: 12px; border-top: 1px solid var(--border);
|
||
gap: 8px;
|
||
}
|
||
.card-actions-left { display: flex; gap: 8px; align-items: center; }
|
||
.lock-badge {
|
||
font-size: 12px; color: var(--warning);
|
||
display: inline-flex; align-items: center; gap: 4px;
|
||
background: rgba(234,179,8,0.12); padding: 2px 8px; border-radius: 99px;
|
||
}
|
||
|
||
.loading-state { padding: 60px 24px; text-align: center; color: var(--muted); font-size: 15px; }
|
||
.empty-state { padding: 60px 24px; text-align: center; }
|
||
.empty-state .empty-icon { font-size: 48px; margin-bottom: 16px; }
|
||
.empty-state h3 { font-size: 18px; font-weight: 600; margin-bottom: 8px; }
|
||
.empty-state p { color: var(--muted); }
|
||
|
||
/* ── Detail View ─────────────────────────────────────────────────── */
|
||
.detail-header {
|
||
padding: 16px 24px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
border-bottom: 1px solid var(--border);
|
||
background: var(--card);
|
||
}
|
||
.detail-header .back-btn {
|
||
background: transparent; border: 1px solid var(--border2);
|
||
color: var(--muted); border-radius: 7px; padding: 6px 12px;
|
||
cursor: pointer; font-size: 13px; font-family: inherit;
|
||
transition: color 0.15s, border-color 0.15s;
|
||
}
|
||
.detail-header .back-btn:hover { color: var(--text); border-color: var(--muted); }
|
||
.detail-header h2 { font-size: 17px; font-weight: 600; flex: 1; }
|
||
.detail-header .header-btns { display: flex; gap: 8px; }
|
||
|
||
.detail-body { padding: 24px; max-width: 1100px; }
|
||
|
||
.config-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||
gap: 12px;
|
||
background: var(--card);
|
||
border: 1px solid var(--border);
|
||
border-radius: 12px;
|
||
padding: 20px;
|
||
margin-bottom: 28px;
|
||
}
|
||
.config-item .cfg-label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 4px; }
|
||
.config-item .cfg-val { font-size: 13px; font-weight: 500; color: var(--text); word-break: break-all; }
|
||
.config-item .cfg-val code { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
|
||
|
||
.section-title { font-size: 14px; font-weight: 600; color: var(--text); margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
|
||
.section-title .count { font-size: 12px; color: var(--muted); font-weight: 400; }
|
||
|
||
.runs-table-wrap { overflow-x: auto; border-radius: 10px; border: 1px solid var(--border); }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
thead th {
|
||
background: var(--card); padding: 10px 14px;
|
||
font-size: 11px; font-weight: 600; color: var(--muted);
|
||
text-transform: uppercase; letter-spacing: 0.06em;
|
||
text-align: left; white-space: nowrap;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
tbody tr {
|
||
border-bottom: 1px solid var(--border);
|
||
cursor: pointer;
|
||
transition: background 0.1s;
|
||
}
|
||
tbody tr:last-child { border-bottom: none; }
|
||
tbody tr:hover { background: rgba(255,255,255,0.03); }
|
||
tbody td { padding: 11px 14px; font-size: 13px; vertical-align: middle; }
|
||
|
||
.status-pill {
|
||
display: inline-flex; align-items: center; gap: 5px;
|
||
padding: 2px 9px; border-radius: 99px; font-size: 11px; font-weight: 600;
|
||
}
|
||
.pill-success { background: rgba(34,197,94,0.15); color: var(--success); }
|
||
.pill-failed { background: rgba(239,68,68,0.15); color: var(--danger); }
|
||
.pill-partial { background: rgba(234,179,8,0.15); color: var(--warning); }
|
||
.pill-running { background: rgba(79,142,247,0.15); color: var(--accent); }
|
||
.pill-dry { background: rgba(139,148,168,0.15); color: var(--muted); }
|
||
|
||
.load-more { text-align: center; padding: 16px; }
|
||
|
||
/* ── Ops Drawer ──────────────────────────────────────────────────── */
|
||
.drawer {
|
||
position: fixed; right: 0; top: 0; height: 100vh;
|
||
width: min(680px, 100vw);
|
||
background: var(--card);
|
||
border-left: 1px solid var(--border);
|
||
z-index: 50;
|
||
transform: translateX(100%);
|
||
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||
display: flex; flex-direction: column;
|
||
}
|
||
.drawer.open { transform: translateX(0); }
|
||
.drawer-header {
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;
|
||
flex-shrink: 0;
|
||
}
|
||
.drawer-title { font-size: 15px; font-weight: 600; }
|
||
.drawer-subtitle { font-size: 12px; color: var(--muted); margin-top: 3px; }
|
||
.drawer-close {
|
||
background: transparent; border: 1px solid var(--border2);
|
||
color: var(--muted); border-radius: 6px; width: 28px; height: 28px;
|
||
cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center;
|
||
flex-shrink: 0;
|
||
}
|
||
.drawer-close:hover { color: var(--text); border-color: var(--muted); }
|
||
.filter-bar {
|
||
padding: 10px 20px;
|
||
display: flex; gap: 6px; flex-wrap: wrap;
|
||
border-bottom: 1px solid var(--border);
|
||
flex-shrink: 0;
|
||
}
|
||
.filter-btn {
|
||
padding: 4px 12px; border-radius: 99px; font-size: 12px; font-weight: 500;
|
||
border: 1px solid var(--border2); background: transparent; color: var(--muted);
|
||
cursor: pointer; font-family: inherit; transition: all 0.15s;
|
||
}
|
||
.filter-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||
.filter-btn:hover:not(.active) { color: var(--text); border-color: var(--muted); }
|
||
.ops-list { overflow-y: auto; flex: 1; }
|
||
.op-row {
|
||
display: grid;
|
||
grid-template-columns: 90px 1fr 120px 80px 90px;
|
||
gap: 10px;
|
||
padding: 10px 20px;
|
||
border-bottom: 1px solid rgba(255,255,255,0.04);
|
||
align-items: start;
|
||
font-size: 12px;
|
||
}
|
||
.op-row:hover { background: rgba(255,255,255,0.025); }
|
||
.op-badge {
|
||
display: inline-flex; align-items: center; padding: 2px 8px; border-radius: 6px;
|
||
font-size: 11px; font-weight: 600; white-space: nowrap;
|
||
}
|
||
.op-create { background: rgba(79,142,247,0.2); color: var(--accent); }
|
||
.op-update { background: rgba(79,142,247,0.1); color: #7ab3ff; }
|
||
.op-delete { background: rgba(239,68,68,0.2); color: var(--danger); }
|
||
.op-error { background: rgba(234,179,8,0.2); color: var(--warning); }
|
||
.op-skip { background: rgba(139,148,168,0.15); color: var(--muted); }
|
||
.op-path { color: var(--text); word-break: break-all; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 11px; line-height: 1.6; }
|
||
.op-hosts { color: var(--muted); white-space: nowrap; }
|
||
.op-size { color: var(--muted); text-align: right; }
|
||
.op-owner { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.ops-loading, .ops-empty { padding: 40px; text-align: center; color: var(--muted); }
|
||
|
||
/* ── Job Form Panel ───────────────────────────────────────────────── */
|
||
.panel {
|
||
position: fixed; right: 0; top: 0; height: 100vh;
|
||
width: min(520px, 100vw);
|
||
background: var(--card);
|
||
border-left: 1px solid var(--border);
|
||
z-index: 50;
|
||
transform: translateX(100%);
|
||
transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1);
|
||
display: flex; flex-direction: column;
|
||
}
|
||
.panel.open { transform: translateX(0); }
|
||
.panel-header {
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex; align-items: center; justify-content: space-between;
|
||
flex-shrink: 0;
|
||
}
|
||
.panel-title { font-size: 15px; font-weight: 600; }
|
||
.panel-close {
|
||
background: transparent; border: 1px solid var(--border2);
|
||
color: var(--muted); border-radius: 6px; width: 28px; height: 28px;
|
||
cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center;
|
||
}
|
||
.panel-close:hover { color: var(--text); border-color: var(--muted); }
|
||
.panel-body { flex: 1; overflow-y: auto; padding: 20px; }
|
||
.panel-footer {
|
||
padding: 14px 20px;
|
||
border-top: 1px solid var(--border);
|
||
display: flex; gap: 10px; justify-content: flex-end;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.form-group { margin-bottom: 16px; }
|
||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||
label { display: block; font-size: 12px; font-weight: 500; color: var(--muted); margin-bottom: 5px; }
|
||
input[type="text"], input[type="number"], select, textarea {
|
||
width: 100%; background: var(--bg); border: 1px solid var(--border2);
|
||
border-radius: 7px; padding: 8px 10px; color: var(--text);
|
||
font-size: 13px; font-family: inherit;
|
||
transition: border-color 0.15s;
|
||
outline: none;
|
||
}
|
||
input[type="text"]:focus, input[type="number"]:focus, select:focus, textarea:focus {
|
||
border-color: var(--accent);
|
||
}
|
||
select option { background: var(--card); }
|
||
textarea { resize: vertical; min-height: 80px; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
|
||
.form-hint { font-size: 11px; color: var(--muted); margin-top: 4px; }
|
||
.form-check { display: flex; align-items: center; gap: 8px; }
|
||
.form-check input[type="checkbox"] { width: auto; accent-color: var(--accent); cursor: pointer; }
|
||
.form-check label { margin-bottom: 0; cursor: pointer; color: var(--text); }
|
||
.form-section { font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase;
|
||
letter-spacing: 0.08em; margin: 20px 0 12px; padding-top: 16px; border-top: 1px solid var(--border); }
|
||
.form-error { color: var(--danger); font-size: 12px; margin-top: 8px; display: none; }
|
||
.form-error.visible { display: block; }
|
||
|
||
/* ── Backdrop ────────────────────────────────────────────────────── */
|
||
.backdrop {
|
||
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
|
||
z-index: 40; opacity: 0; pointer-events: none;
|
||
transition: opacity 0.25s;
|
||
}
|
||
.backdrop.visible { opacity: 1; pointer-events: auto; }
|
||
|
||
/* ── Misc ────────────────────────────────────────────────────────── */
|
||
.divider { height: 1px; background: var(--border); margin: 20px 0; }
|
||
.text-muted { color: var(--muted); }
|
||
.monospace { font-family: 'SF Mono', 'Fira Code', monospace; }
|
||
.tag { display: inline-block; background: var(--border); border-radius: 4px; padding: 1px 6px; font-size: 11px; color: var(--muted); }
|
||
|
||
@media (max-width: 640px) {
|
||
#jobs-grid { grid-template-columns: 1fr; padding: 8px 12px 24px; }
|
||
.detail-body { padding: 16px; }
|
||
.op-row { grid-template-columns: 80px 1fr; }
|
||
.op-hosts, .op-size, .op-owner { display: none; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header class="app-header">
|
||
<div>
|
||
<span class="logo">HA Sync <span>dell ⇌ hp</span></span>
|
||
</div>
|
||
<div class="header-actions" id="header-actions">
|
||
<button class="btn btn-primary" onclick="showJobForm(null)">+ New Job</button>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- View 1: Jobs Overview -->
|
||
<div id="view-jobs" class="view active">
|
||
<div class="view-header">
|
||
<div>
|
||
<h2>Sync Jobs</h2>
|
||
<div class="subtitle" id="jobs-last-refresh">Loading…</div>
|
||
</div>
|
||
</div>
|
||
<div id="jobs-grid"><div class="loading-state">Loading jobs…</div></div>
|
||
</div>
|
||
|
||
<!-- View 2: Job Detail -->
|
||
<div id="view-detail" class="view">
|
||
<div class="detail-header">
|
||
<button class="back-btn" onclick="showJobsView()">← Back</button>
|
||
<h2 id="detail-title">Job Detail</h2>
|
||
<div class="header-btns">
|
||
<button class="btn btn-ghost btn-sm" id="btn-edit-job" onclick="editCurrentJob()">Edit</button>
|
||
<button class="btn btn-danger btn-sm" id="btn-delete-job" onclick="deleteCurrentJob()">Delete</button>
|
||
</div>
|
||
</div>
|
||
<div class="detail-body" id="detail-body">Loading…</div>
|
||
</div>
|
||
|
||
<!-- Ops Drawer -->
|
||
<div id="drawer-ops" class="drawer">
|
||
<div class="drawer-header">
|
||
<div>
|
||
<div class="drawer-title" id="ops-title">Operations</div>
|
||
<div class="drawer-subtitle" id="ops-subtitle"></div>
|
||
</div>
|
||
<button class="drawer-close" onclick="closeOpsDrawer()">×</button>
|
||
</div>
|
||
<div class="filter-bar" id="ops-filter-bar"></div>
|
||
<div class="ops-list" id="ops-list"><div class="ops-loading">Loading…</div></div>
|
||
</div>
|
||
|
||
<!-- Job Form Panel -->
|
||
<div id="panel-job" class="panel">
|
||
<div class="panel-header">
|
||
<div class="panel-title" id="form-panel-title">New Job</div>
|
||
<button class="panel-close" onclick="closeJobForm()">×</button>
|
||
</div>
|
||
<div class="panel-body">
|
||
<form id="job-form" onsubmit="return false">
|
||
<div class="form-group">
|
||
<label for="f-name">Name *</label>
|
||
<input type="text" id="f-name" placeholder="e.g. media-sync" required>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="f-pair">Pair *</label>
|
||
<select id="f-pair">
|
||
<option value="media">media</option>
|
||
<option value="photos">photos</option>
|
||
<option value="owncloud">owncloud</option>
|
||
<option value="games">games</option>
|
||
<option value="infra">infra</option>
|
||
<option value="ai">ai</option>
|
||
</select>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="f-direction">Direction *</label>
|
||
<select id="f-direction">
|
||
<option value="fwd">dell → hp</option>
|
||
<option value="rev">hp → dell</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-section">Paths & Hosts</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="f-src">Source Path *</label>
|
||
<input type="text" id="f-src" placeholder="/mnt/media">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="f-dest">Destination Path *</label>
|
||
<input type="text" id="f-dest" placeholder="/mnt/media">
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="f-src-host">Src Host</label>
|
||
<input type="text" id="f-src-host" placeholder="dell">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="f-dest-host">Dest Host</label>
|
||
<input type="text" id="f-dest-host" placeholder="hp">
|
||
</div>
|
||
</div>
|
||
<div class="form-section">Schedule & Performance</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="f-schedule">Cron Schedule</label>
|
||
<input type="text" id="f-schedule" placeholder="*/15 * * * *">
|
||
<div class="form-hint" id="f-schedule-hint"></div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="f-lock-ttl">Lock TTL (seconds)</label>
|
||
<input type="number" id="f-lock-ttl" placeholder="3600" min="60">
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="f-workers">Workers</label>
|
||
<input type="number" id="f-workers" placeholder="4" min="1" max="32">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="f-mtime">Mtime Threshold</label>
|
||
<input type="text" id="f-mtime" placeholder="2s">
|
||
</div>
|
||
</div>
|
||
<div class="form-section">Options</div>
|
||
<div class="form-group">
|
||
<div class="form-check">
|
||
<input type="checkbox" id="f-dry-run">
|
||
<label for="f-dry-run">Dry run (simulate only)</label>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<div class="form-check">
|
||
<input type="checkbox" id="f-delete-missing">
|
||
<label for="f-delete-missing">Delete missing files at destination</label>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<div class="form-check">
|
||
<input type="checkbox" id="f-enabled" checked>
|
||
<label for="f-enabled">Enabled (active schedule)</label>
|
||
</div>
|
||
</div>
|
||
<div class="form-section">Excludes</div>
|
||
<div class="form-group">
|
||
<label for="f-excludes">Exclude patterns (one per line)</label>
|
||
<textarea id="f-excludes" rows="4" placeholder="*.tmp .Trash-* lost+found"></textarea>
|
||
</div>
|
||
<div class="form-error" id="form-error"></div>
|
||
</form>
|
||
</div>
|
||
<div class="panel-footer">
|
||
<button class="btn btn-ghost" onclick="closeJobForm()">Cancel</button>
|
||
<button class="btn btn-primary" id="btn-save-job" onclick="saveJob()">Save Job</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="backdrop" id="backdrop" onclick="closeAllOverlays()"></div>
|
||
|
||
<script>
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// State
|
||
// ═══════════════════════════════════════════════════════════════
|
||
const state = {
|
||
jobs: [],
|
||
currentJobId: null,
|
||
editingJobId: null,
|
||
currentRunId: null,
|
||
opsAll: [],
|
||
opsFilter: null,
|
||
refreshTimer: null,
|
||
};
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// API helpers
|
||
// ═══════════════════════════════════════════════════════════════
|
||
async function apiFetch(url, opts = {}) {
|
||
const res = await fetch(url, {
|
||
headers: { 'Content-Type': 'application/json' },
|
||
...opts,
|
||
});
|
||
if (res.status === 204) return null;
|
||
const body = await res.json().catch(() => ({}));
|
||
if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`);
|
||
return body;
|
||
}
|
||
|
||
const API = {
|
||
listJobs: () => apiFetch('/api/jobs'),
|
||
getJob: (id) => apiFetch(`/api/jobs/${id}`),
|
||
createJob: (d) => apiFetch('/api/jobs', { method: 'POST', body: JSON.stringify(d) }),
|
||
updateJob: (id, d) => apiFetch(`/api/jobs/${id}`, { method: 'PUT', body: JSON.stringify(d) }),
|
||
deleteJob: (id) => apiFetch(`/api/jobs/${id}`, { method: 'DELETE' }),
|
||
enableJob: (id) => apiFetch(`/api/jobs/${id}/enable`, { method: 'POST' }),
|
||
disableJob: (id) => apiFetch(`/api/jobs/${id}/disable`, { method: 'POST' }),
|
||
triggerJob: (id) => apiFetch(`/api/jobs/${id}/trigger`, { method: 'POST' }),
|
||
getLockStatus: (id) => apiFetch(`/api/jobs/${id}/lock`),
|
||
listOps: (runId, limit, offset) =>
|
||
apiFetch(`/api/runs/${runId}/ops?limit=${limit}&offset=${offset}`),
|
||
};
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Utilities
|
||
// ═══════════════════════════════════════════════════════════════
|
||
const PAIR_COLORS = {
|
||
media: '#4f8ef7',
|
||
photos: '#a855f7',
|
||
owncloud: '#06b6d4',
|
||
games: '#22c55e',
|
||
infra: '#f97316',
|
||
ai: '#ec4899',
|
||
};
|
||
|
||
function pairColor(pair) { return PAIR_COLORS[pair] || '#8890a8'; }
|
||
|
||
function dirLabel(dir) {
|
||
if (dir === 'fwd') return 'dell → hp';
|
||
if (dir === 'rev') return 'hp → dell';
|
||
return dir;
|
||
}
|
||
|
||
function timeAgo(iso) {
|
||
if (!iso) return 'never';
|
||
const diff = (Date.now() - new Date(iso)) / 1000;
|
||
if (diff < 60) return `${Math.floor(diff)}s ago`;
|
||
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
|
||
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
|
||
return `${Math.floor(diff / 86400)}d ago`;
|
||
}
|
||
|
||
function fmtDuration(startIso, endIso) {
|
||
if (!endIso) return '–';
|
||
const s = Math.round((new Date(endIso) - new Date(startIso)) / 1000);
|
||
if (s < 60) return `${s}s`;
|
||
const m = Math.floor(s / 60), r = s % 60;
|
||
return r > 0 ? `${m}m ${r}s` : `${m}m`;
|
||
}
|
||
|
||
function fmtBytes(n) {
|
||
if (!n) return '–';
|
||
if (n < 1024) return `${n}B`;
|
||
if (n < 1048576) return `${(n/1024).toFixed(1)}K`;
|
||
if (n < 1073741824) return `${(n/1048576).toFixed(1)}M`;
|
||
return `${(n/1073741824).toFixed(2)}G`;
|
||
}
|
||
|
||
function cronHuman(sched) {
|
||
if (!sched) return '';
|
||
const p = sched.trim().split(/\s+/);
|
||
if (p.length < 5) return sched;
|
||
const [min, hr, dom, mon, dow] = p;
|
||
if (sched === '* * * * *') return 'every minute';
|
||
if (min.startsWith('*/')) {
|
||
const n = min.slice(2);
|
||
if (hr === '*' && dom === '*' && mon === '*' && dow === '*')
|
||
return `every ${n} min`;
|
||
}
|
||
if (min === '0' && hr.startsWith('*/')) return `every ${hr.slice(2)}h`;
|
||
if (min === '0' && dom === '*' && mon === '*' && dow === '*')
|
||
return `daily at ${hr.padStart(2,'0')}:00`;
|
||
if (min === '0' && hr === '0') return 'daily midnight';
|
||
return sched;
|
||
}
|
||
|
||
function jobStatusClass(job) {
|
||
if (!job.enabled) return 'dot-red';
|
||
const it = job.last_iteration;
|
||
if (!it) return 'dot-grey';
|
||
const stale = !it.ended_at || (Date.now() - new Date(it.started_at)) > 86400000;
|
||
if (it.status === 'failed' || it.status === 'partial_failure' || stale) return 'dot-yellow';
|
||
return 'dot-green';
|
||
}
|
||
|
||
function statusPill(status, dryRun) {
|
||
if (dryRun) return `<span class="status-pill pill-dry">dry-run</span>`;
|
||
const map = {
|
||
success: 'pill-success',
|
||
failed: 'pill-failed',
|
||
partial_failure: 'pill-partial',
|
||
running: 'pill-running',
|
||
};
|
||
return `<span class="status-pill ${map[status] || 'pill-partial'}">${status || '–'}</span>`;
|
||
}
|
||
|
||
function opBadge(op, status) {
|
||
if (status === 'fail') return '<span class="op-badge op-error">error</span>';
|
||
const map = { create: 'op-create', update: 'op-update', delete: 'op-delete' };
|
||
const cls = map[op] || 'op-skip';
|
||
const labels = { create: 'copy', update: 'update', delete: 'delete' };
|
||
return `<span class="op-badge ${cls}">${labels[op] || op}</span>`;
|
||
}
|
||
|
||
function esc(s) {
|
||
return String(s ?? '')
|
||
.replace(/&/g,'&').replace(/</g,'<')
|
||
.replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Views
|
||
// ═══════════════════════════════════════════════════════════════
|
||
function showView(name) {
|
||
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
||
document.getElementById(`view-${name}`).classList.add('active');
|
||
document.getElementById('header-actions').innerHTML =
|
||
name === 'jobs'
|
||
? '<button class="btn btn-primary" onclick="showJobForm(null)">+ New Job</button>'
|
||
: `<button class="btn btn-ghost btn-sm" onclick="showJobForm(null)">+ New Job</button>`;
|
||
}
|
||
|
||
function showJobsView() {
|
||
showView('jobs');
|
||
state.currentJobId = null;
|
||
startRefresh();
|
||
}
|
||
|
||
async function showJobDetail(jobId) {
|
||
stopRefresh();
|
||
state.currentJobId = jobId;
|
||
showView('detail');
|
||
document.getElementById('detail-title').textContent = 'Loading…';
|
||
document.getElementById('detail-body').innerHTML = '<div class="loading-state">Loading…</div>';
|
||
try {
|
||
const data = await API.getJob(jobId);
|
||
renderJobDetail(data);
|
||
} catch (e) {
|
||
document.getElementById('detail-body').innerHTML = `<div class="loading-state">${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Jobs Overview
|
||
// ═══════════════════════════════════════════════════════════════
|
||
async function fetchJobs() {
|
||
try {
|
||
const jobs = await API.listJobs();
|
||
state.jobs = jobs || [];
|
||
renderJobsGrid(state.jobs);
|
||
document.getElementById('jobs-last-refresh').textContent =
|
||
`${state.jobs.length} job${state.jobs.length !== 1 ? 's' : ''} · updated just now`;
|
||
// Fetch lock statuses asynchronously
|
||
state.jobs.forEach(j => fetchLockBadge(j));
|
||
} catch (e) {
|
||
document.getElementById('jobs-grid').innerHTML =
|
||
`<div class="loading-state">Error: ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderJobsGrid(jobs) {
|
||
const grid = document.getElementById('jobs-grid');
|
||
if (!jobs.length) {
|
||
grid.innerHTML = `
|
||
<div class="empty-state" style="grid-column:1/-1">
|
||
<div class="empty-icon">📭</div>
|
||
<h3>No sync jobs yet</h3>
|
||
<p>Create your first job to get started.</p>
|
||
</div>`;
|
||
return;
|
||
}
|
||
grid.innerHTML = jobs.map(jobCard).join('');
|
||
}
|
||
|
||
function jobCard(job) {
|
||
const color = pairColor(job.pair);
|
||
const dotClass = jobStatusClass(job);
|
||
const it = job.last_iteration;
|
||
|
||
let lastRunHtml = '<span class="text-muted">never run</span>';
|
||
let countsHtml = '';
|
||
|
||
if (it) {
|
||
const outcomeClass = it.status === 'success' ? 'outcome-ok' : (it.status === 'failed' ? 'outcome-fail' : 'outcome-dry');
|
||
const outcomeIcon = it.status === 'success' ? '✓' : (it.status === 'failed' ? '✗' : '○');
|
||
const outcomeLabel = it.status === 'success' ? 'synced' : (it.status === 'failed' ? 'failed' : it.status);
|
||
|
||
lastRunHtml = `
|
||
<span class="val">${esc(timeAgo(it.started_at))}</span>
|
||
<span class="outcome ${outcomeClass}">${outcomeIcon} ${outcomeLabel}</span>
|
||
${it.dry_run ? '<span class="tag">dry</span>' : ''}`;
|
||
|
||
countsHtml = `
|
||
<div class="file-counts">
|
||
<div class="file-stat"><span class="num copied">${it.files_created + it.files_updated}</span><span class="lbl">copied</span></div>
|
||
<div class="file-stat"><span class="num deleted">${it.files_deleted}</span><span class="lbl">deleted</span></div>
|
||
<div class="file-stat"><span class="num skipped">${it.files_skipped}</span><span class="lbl">skipped</span></div>
|
||
${it.files_failed > 0 ? `<div class="file-stat"><span class="num" style="color:var(--danger)">${it.files_failed}</span><span class="lbl">errors</span></div>` : ''}
|
||
</div>`;
|
||
}
|
||
|
||
return `
|
||
<div class="job-card" id="card-${job.id}" onclick="showJobDetail(${job.id})">
|
||
<div class="job-card-accent" style="background:${color}"></div>
|
||
<div class="card-top">
|
||
<div>
|
||
<div class="card-title-row">
|
||
<span class="status-dot ${dotClass}"></span>
|
||
<span class="card-name">${esc(job.name)}</span>
|
||
</div>
|
||
<div style="margin-top:6px;display:flex;gap:6px;flex-wrap:wrap">
|
||
<span class="badge badge-pair" style="background:${color}22;color:${color};border:1px solid ${color}44">${esc(job.pair)}</span>
|
||
<span class="badge badge-dir">${esc(dirLabel(job.direction))}</span>
|
||
</div>
|
||
</div>
|
||
<div id="lock-${job.id}"></div>
|
||
</div>
|
||
<div class="card-meta">
|
||
<div class="card-meta-row">
|
||
<span class="icon">🕐</span>
|
||
<span>${lastRunHtml}</span>
|
||
</div>
|
||
<div class="card-meta-row">
|
||
<span class="icon">⏱</span>
|
||
<span>${esc(cronHuman(job.cron_schedule))}</span>
|
||
</div>
|
||
</div>
|
||
${countsHtml}
|
||
<div class="card-actions" onclick="event.stopPropagation()">
|
||
<div class="card-actions-left">
|
||
<button class="btn btn-ghost btn-sm" onclick="triggerJob(${job.id}, event)">▶ Run Now</button>
|
||
</div>
|
||
<label class="toggle" title="${job.enabled ? 'Disable' : 'Enable'}">
|
||
<input type="checkbox" ${job.enabled ? 'checked' : ''} onchange="toggleJob(${job.id}, this.checked)">
|
||
<span class="toggle-slider"></span>
|
||
</label>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
async function fetchLockBadge(job) {
|
||
try {
|
||
const ls = await API.getLockStatus(job.id);
|
||
const el = document.getElementById(`lock-${job.id}`);
|
||
if (el && ls && ls.locked) {
|
||
el.innerHTML = `<span class="lock-badge">🔒 running</span>`;
|
||
}
|
||
} catch (_) { /* kube may be unavailable */ }
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Job Detail
|
||
// ═══════════════════════════════════════════════════════════════
|
||
function renderJobDetail(data) {
|
||
const job = data;
|
||
const iters = data.iterations || [];
|
||
document.getElementById('detail-title').textContent = job.name;
|
||
|
||
const excludesHtml = (job.excludes || []).length
|
||
? job.excludes.map(e => `<span class="tag">${esc(e)}</span>`).join(' ')
|
||
: '<span class="text-muted">none</span>';
|
||
|
||
const configHtml = `
|
||
<div class="config-grid">
|
||
<div class="config-item"><div class="cfg-label">Pair</div>
|
||
<div class="cfg-val"><span class="badge badge-pair" style="background:${pairColor(job.pair)}22;color:${pairColor(job.pair)};border:1px solid ${pairColor(job.pair)}44">${esc(job.pair)}</span></div>
|
||
</div>
|
||
<div class="config-item"><div class="cfg-label">Direction</div><div class="cfg-val">${esc(dirLabel(job.direction))}</div></div>
|
||
<div class="config-item"><div class="cfg-label">Source</div><div class="cfg-val"><code>${esc(job.src)}</code></div></div>
|
||
<div class="config-item"><div class="cfg-label">Destination</div><div class="cfg-val"><code>${esc(job.dest)}</code></div></div>
|
||
<div class="config-item"><div class="cfg-label">Src Host</div><div class="cfg-val">${esc(job.src_host)}</div></div>
|
||
<div class="config-item"><div class="cfg-label">Dest Host</div><div class="cfg-val">${esc(job.dest_host)}</div></div>
|
||
<div class="config-item"><div class="cfg-label">Schedule</div><div class="cfg-val"><code>${esc(job.cron_schedule)}</code><br><span class="text-muted" style="font-size:11px">${esc(cronHuman(job.cron_schedule))}</span></div></div>
|
||
<div class="config-item"><div class="cfg-label">Lock TTL</div><div class="cfg-val">${job.lock_ttl_seconds}s</div></div>
|
||
<div class="config-item"><div class="cfg-label">Workers</div><div class="cfg-val">${job.workers}</div></div>
|
||
<div class="config-item"><div class="cfg-label">Mtime threshold</div><div class="cfg-val">${esc(job.mtime_threshold)}</div></div>
|
||
<div class="config-item"><div class="cfg-label">Dry run</div><div class="cfg-val">${job.dry_run ? '✓ yes' : '– no'}</div></div>
|
||
<div class="config-item"><div class="cfg-label">Delete missing</div><div class="cfg-val">${job.delete_missing ? '✓ yes' : '– no'}</div></div>
|
||
<div class="config-item" style="grid-column:1/-1"><div class="cfg-label">Excludes</div><div class="cfg-val">${excludesHtml}</div></div>
|
||
</div>`;
|
||
|
||
let runsHtml;
|
||
if (!iters.length) {
|
||
runsHtml = '<div class="empty-state"><div class="empty-icon">📋</div><h3>No runs yet</h3><p>Trigger a run manually or wait for the schedule.</p></div>';
|
||
} else {
|
||
const rows = iters.map(it => {
|
||
const dur = fmtDuration(it.started_at, it.ended_at);
|
||
const copied = it.files_created + it.files_updated;
|
||
return `<tr onclick="showOpsDrawer(${it.id}, '${esc(timeAgo(it.started_at))}')">
|
||
<td>${esc(timeAgo(it.started_at))}</td>
|
||
<td>${esc(dur)}</td>
|
||
<td>${statusPill(it.status, it.dry_run)}</td>
|
||
<td style="color:var(--accent)">${copied}</td>
|
||
<td style="color:var(--danger)">${it.files_deleted}</td>
|
||
<td class="text-muted">${it.files_skipped}</td>
|
||
<td style="color:${it.files_failed > 0 ? 'var(--danger)' : 'var(--muted)'}">${it.files_failed}</td>
|
||
<td>${it.dry_run ? '<span class="tag">dry</span>' : '–'}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
runsHtml = `
|
||
<div class="runs-table-wrap">
|
||
<table>
|
||
<thead><tr>
|
||
<th>Started</th><th>Duration</th><th>Status</th>
|
||
<th>Copied</th><th>Deleted</th><th>Skipped</th><th>Errors</th><th>Dry</th>
|
||
</tr></thead>
|
||
<tbody>${rows}</tbody>
|
||
</table>
|
||
</div>`;
|
||
}
|
||
|
||
document.getElementById('detail-body').innerHTML = `
|
||
${configHtml}
|
||
<div class="section-title">
|
||
Recent Runs <span class="count">(${iters.length})</span>
|
||
<button class="btn btn-ghost btn-sm" onclick="triggerJob(${job.id}, event)" style="margin-left:auto">▶ Run Now</button>
|
||
</div>
|
||
${runsHtml}`;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Ops Drawer
|
||
// ═══════════════════════════════════════════════════════════════
|
||
async function showOpsDrawer(runId, label) {
|
||
state.currentRunId = runId;
|
||
state.opsFilter = null;
|
||
state.opsAll = [];
|
||
|
||
document.getElementById('ops-title').textContent = 'Operations';
|
||
document.getElementById('ops-subtitle').textContent = label ? `Run ${label}` : '';
|
||
document.getElementById('ops-filter-bar').innerHTML = '';
|
||
document.getElementById('ops-list').innerHTML = '<div class="ops-loading">Loading…</div>';
|
||
openOverlay('drawer-ops');
|
||
|
||
try {
|
||
const data = await API.listOps(runId, 500, 0);
|
||
state.opsAll = data.operations || [];
|
||
document.getElementById('ops-title').textContent = `Operations`;
|
||
document.getElementById('ops-subtitle').textContent =
|
||
`${data.total} total${label ? ' · run ' + label : ''}`;
|
||
renderOpsFilterBar();
|
||
renderOpsList();
|
||
} catch (e) {
|
||
document.getElementById('ops-list').innerHTML =
|
||
`<div class="ops-empty">Error: ${esc(e.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderOpsFilterBar() {
|
||
const counts = {};
|
||
for (const op of state.opsAll) {
|
||
const key = op.status === 'fail' ? 'error' : op.operation;
|
||
counts[key] = (counts[key] || 0) + 1;
|
||
}
|
||
const filters = ['all', ...Object.keys(counts).sort()];
|
||
const bar = filters.map(f => {
|
||
const active = state.opsFilter === (f === 'all' ? null : f);
|
||
const label = f === 'all' ? `All (${state.opsAll.length})` : `${f} (${counts[f] || 0})`;
|
||
return `<button class="filter-btn ${active ? 'active' : ''}"
|
||
onclick="setOpsFilter(${f === 'all' ? 'null' : `'${f}'`})">${esc(label)}</button>`;
|
||
}).join('');
|
||
document.getElementById('ops-filter-bar').innerHTML = bar;
|
||
}
|
||
|
||
function setOpsFilter(f) {
|
||
state.opsFilter = f;
|
||
renderOpsFilterBar();
|
||
renderOpsList();
|
||
}
|
||
|
||
function renderOpsList() {
|
||
const ops = state.opsFilter
|
||
? state.opsAll.filter(op => {
|
||
const key = op.status === 'fail' ? 'error' : op.operation;
|
||
return key === state.opsFilter;
|
||
})
|
||
: state.opsAll;
|
||
|
||
if (!ops.length) {
|
||
document.getElementById('ops-list').innerHTML = '<div class="ops-empty">No operations match.</div>';
|
||
return;
|
||
}
|
||
|
||
const rows = ops.map(op => {
|
||
const src = op.src_host || '?';
|
||
const dest = op.dest_host || '?';
|
||
const size = op.size_after != null ? fmtBytes(op.size_after) : (op.size_before != null ? fmtBytes(op.size_before) : '–');
|
||
return `<div class="op-row">
|
||
${opBadge(op.operation, op.status)}
|
||
<div class="op-path">${esc(op.filepath)}</div>
|
||
<div class="op-hosts">${esc(src)} → ${esc(dest)}</div>
|
||
<div class="op-size">${size}</div>
|
||
<div class="op-owner">${esc(op.owner || '–')}</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
document.getElementById('ops-list').innerHTML = rows;
|
||
}
|
||
|
||
function closeOpsDrawer() {
|
||
closeOverlay('drawer-ops');
|
||
state.currentRunId = null;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Job Form
|
||
// ═══════════════════════════════════════════════════════════════
|
||
function showJobForm(jobId) {
|
||
state.editingJobId = jobId;
|
||
document.getElementById('form-panel-title').textContent = jobId ? 'Edit Job' : 'New Job';
|
||
document.getElementById('form-error').textContent = '';
|
||
document.getElementById('form-error').classList.remove('visible');
|
||
document.getElementById('btn-save-job').disabled = false;
|
||
|
||
if (jobId) {
|
||
// Populate form from state
|
||
const job = state.jobs.find(j => j.id === jobId) || {};
|
||
setField('f-name', job.name || '');
|
||
setField('f-pair', job.pair || 'media');
|
||
setField('f-direction', job.direction || 'fwd');
|
||
setField('f-src', job.src || '');
|
||
setField('f-dest', job.dest || '');
|
||
setField('f-src-host', job.src_host || 'dell');
|
||
setField('f-dest-host', job.dest_host || 'hp');
|
||
setField('f-schedule', job.cron_schedule || '*/15 * * * *');
|
||
setField('f-lock-ttl', job.lock_ttl_seconds || 3600);
|
||
setField('f-workers', job.workers || 4);
|
||
setField('f-mtime', job.mtime_threshold || '2s');
|
||
document.getElementById('f-dry-run').checked = !!job.dry_run;
|
||
document.getElementById('f-delete-missing').checked = !!job.delete_missing;
|
||
document.getElementById('f-enabled').checked = job.enabled !== false;
|
||
setField('f-excludes', (job.excludes || []).join('\n'));
|
||
document.getElementById('f-name').disabled = true; // can't rename
|
||
} else {
|
||
document.getElementById('job-form').reset();
|
||
document.getElementById('f-src-host').value = 'dell';
|
||
document.getElementById('f-dest-host').value = 'hp';
|
||
document.getElementById('f-schedule').value = '*/15 * * * *';
|
||
document.getElementById('f-lock-ttl').value = '3600';
|
||
document.getElementById('f-workers').value = '4';
|
||
document.getElementById('f-mtime').value = '2s';
|
||
document.getElementById('f-enabled').checked = true;
|
||
document.getElementById('f-name').disabled = false;
|
||
}
|
||
updateScheduleHint();
|
||
openOverlay('panel-job');
|
||
}
|
||
|
||
function editCurrentJob() {
|
||
if (!state.currentJobId) return;
|
||
// If we're in detail view, fetch the current job data into state.jobs first
|
||
const existing = state.jobs.find(j => j.id === state.currentJobId);
|
||
if (existing) {
|
||
showJobForm(state.currentJobId);
|
||
} else {
|
||
API.getJob(state.currentJobId).then(data => {
|
||
// Merge into state.jobs
|
||
const idx = state.jobs.findIndex(j => j.id === state.currentJobId);
|
||
if (idx >= 0) state.jobs[idx] = { ...state.jobs[idx], ...data };
|
||
else state.jobs.push(data);
|
||
showJobForm(state.currentJobId);
|
||
}).catch(e => alert(`Failed to load job: ${e.message}`));
|
||
}
|
||
}
|
||
|
||
function setField(id, val) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.value = val;
|
||
}
|
||
|
||
function updateScheduleHint() {
|
||
const sched = document.getElementById('f-schedule').value;
|
||
document.getElementById('f-schedule-hint').textContent = cronHuman(sched);
|
||
}
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
document.getElementById('f-schedule').addEventListener('input', updateScheduleHint);
|
||
});
|
||
|
||
function collectFormData() {
|
||
const excludes = document.getElementById('f-excludes').value
|
||
.split('\n').map(s => s.trim()).filter(Boolean);
|
||
return {
|
||
name: document.getElementById('f-name').value.trim(),
|
||
pair: document.getElementById('f-pair').value,
|
||
direction: document.getElementById('f-direction').value,
|
||
src: document.getElementById('f-src').value.trim(),
|
||
dest: document.getElementById('f-dest').value.trim(),
|
||
src_host: document.getElementById('f-src-host').value.trim(),
|
||
dest_host: document.getElementById('f-dest-host').value.trim(),
|
||
cron_schedule: document.getElementById('f-schedule').value.trim(),
|
||
lock_ttl_seconds: parseInt(document.getElementById('f-lock-ttl').value, 10) || 3600,
|
||
workers: parseInt(document.getElementById('f-workers').value, 10) || 4,
|
||
mtime_threshold: document.getElementById('f-mtime').value.trim(),
|
||
dry_run: document.getElementById('f-dry-run').checked,
|
||
delete_missing: document.getElementById('f-delete-missing').checked,
|
||
enabled: document.getElementById('f-enabled').checked,
|
||
excludes,
|
||
};
|
||
}
|
||
|
||
async function saveJob() {
|
||
const errEl = document.getElementById('form-error');
|
||
errEl.classList.remove('visible');
|
||
|
||
const data = collectFormData();
|
||
if (!data.name) { showFormError('Name is required'); return; }
|
||
if (!data.src) { showFormError('Source path is required'); return; }
|
||
if (!data.dest) { showFormError('Destination path is required'); return; }
|
||
|
||
document.getElementById('btn-save-job').disabled = true;
|
||
|
||
try {
|
||
if (state.editingJobId) {
|
||
await API.updateJob(state.editingJobId, data);
|
||
} else {
|
||
await API.createJob(data);
|
||
}
|
||
closeJobForm();
|
||
if (state.currentJobId) {
|
||
showJobDetail(state.currentJobId);
|
||
} else {
|
||
await fetchJobs();
|
||
}
|
||
} catch (e) {
|
||
showFormError(e.message);
|
||
document.getElementById('btn-save-job').disabled = false;
|
||
}
|
||
}
|
||
|
||
function showFormError(msg) {
|
||
const el = document.getElementById('form-error');
|
||
el.textContent = msg;
|
||
el.classList.add('visible');
|
||
}
|
||
|
||
function closeJobForm() {
|
||
closeOverlay('panel-job');
|
||
state.editingJobId = null;
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Actions
|
||
// ═══════════════════════════════════════════════════════════════
|
||
async function toggleJob(jobId, enable) {
|
||
try {
|
||
if (enable) {
|
||
await API.enableJob(jobId);
|
||
} else {
|
||
await API.disableJob(jobId);
|
||
}
|
||
const j = state.jobs.find(j => j.id === jobId);
|
||
if (j) j.enabled = enable;
|
||
// Update dot color on the card
|
||
const card = document.getElementById(`card-${jobId}`);
|
||
if (card) {
|
||
const dot = card.querySelector('.status-dot');
|
||
if (dot) {
|
||
dot.className = 'status-dot ' + jobStatusClass(j);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
alert(`Failed: ${e.message}`);
|
||
// Revert toggle
|
||
const el = document.querySelector(`#card-${jobId} input[type=checkbox]`);
|
||
if (el) el.checked = !enable;
|
||
}
|
||
}
|
||
|
||
async function triggerJob(jobId, evt) {
|
||
if (evt) evt.stopPropagation();
|
||
const btn = evt && evt.currentTarget;
|
||
if (btn) { btn.disabled = true; btn.textContent = '⏳'; }
|
||
try {
|
||
const res = await API.triggerJob(jobId);
|
||
alert(`Job triggered: ${res.job_name}`);
|
||
} catch (e) {
|
||
alert(`Failed to trigger: ${e.message}`);
|
||
} finally {
|
||
if (btn) { btn.disabled = false; btn.textContent = '▶ Run Now'; }
|
||
}
|
||
}
|
||
|
||
async function deleteCurrentJob() {
|
||
if (!state.currentJobId) return;
|
||
const job = state.jobs.find(j => j.id === state.currentJobId);
|
||
const name = job ? job.name : `job #${state.currentJobId}`;
|
||
if (!confirm(`Delete "${name}"? This cannot be undone.`)) return;
|
||
try {
|
||
await API.deleteJob(state.currentJobId);
|
||
showJobsView();
|
||
await fetchJobs();
|
||
} catch (e) {
|
||
alert(`Failed to delete: ${e.message}`);
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Overlay management
|
||
// ═══════════════════════════════════════════════════════════════
|
||
function openOverlay(id) {
|
||
document.getElementById(id).classList.add('open');
|
||
document.getElementById('backdrop').classList.add('visible');
|
||
}
|
||
|
||
function closeOverlay(id) {
|
||
document.getElementById(id).classList.remove('open');
|
||
const anyOpen = document.querySelector('.drawer.open, .panel.open');
|
||
if (!anyOpen) document.getElementById('backdrop').classList.remove('visible');
|
||
}
|
||
|
||
function closeAllOverlays() {
|
||
document.querySelectorAll('.drawer.open, .panel.open').forEach(el => el.classList.remove('open'));
|
||
document.getElementById('backdrop').classList.remove('visible');
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Auto-refresh
|
||
// ═══════════════════════════════════════════════════════════════
|
||
function startRefresh() {
|
||
stopRefresh();
|
||
state.refreshTimer = setInterval(() => {
|
||
const view = document.querySelector('.view.active');
|
||
if (view && view.id === 'view-jobs') fetchJobs();
|
||
}, 30000);
|
||
}
|
||
|
||
function stopRefresh() {
|
||
if (state.refreshTimer) { clearInterval(state.refreshTimer); state.refreshTimer = null; }
|
||
}
|
||
|
||
// ═══════════════════════════════════════════════════════════════
|
||
// Init
|
||
// ═══════════════════════════════════════════════════════════════
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
fetchJobs();
|
||
startRefresh();
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|
||
|
||
<style>
|
||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
:root {
|
||
--bg: #0d1117;
|
||
--surface: #161b22;
|
||
--border: #30363d;
|
||
--text: #c9d1d9;
|
||
--muted: #8b949e;
|
||
--accent: #58a6ff;
|
||
--success: #3fb950;
|
||
--danger: #f85149;
|
||
--warning: #d29922;
|
||
--running: #a371f7;
|
||
}
|
||
|
||
body {
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||
font-size: 14px;
|
||
min-height: 100vh;
|
||
}
|
||
|
||
header {
|
||
padding: 18px 28px;
|
||
border-bottom: 1px solid var(--border);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
background: var(--surface);
|
||
}
|
||
|
||
header h1 {
|
||
font-size: 18px;
|
||
font-weight: 600;
|
||
color: var(--accent);
|
||
letter-spacing: 0.02em;
|
||
}
|
||
|
||
.subtitle {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
margin-top: 4px;
|
||
}
|
||
|
||
.subtitle a {
|
||
color: var(--accent);
|
||
text-decoration: none;
|
||
}
|
||
|
||
.subtitle a:hover { text-decoration: underline; }
|
||
|
||
#last-refreshed {
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
}
|
||
|
||
main { padding: 24px 28px; }
|
||
|
||
/* ---------- pair cards ---------- */
|
||
.section-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
color: var(--muted);
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
#pairs-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||
gap: 16px;
|
||
margin-bottom: 36px;
|
||
}
|
||
|
||
.pair-card {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 16px 18px;
|
||
transition: border-color 0.15s;
|
||
}
|
||
|
||
.pair-card:hover { border-color: var(--accent); }
|
||
|
||
.pair-name {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
margin-bottom: 12px;
|
||
color: #e6edf3;
|
||
}
|
||
|
||
.pair-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 6px;
|
||
font-size: 12px;
|
||
color: var(--muted);
|
||
}
|
||
|
||
.pair-row-label { margin-right: 6px; }
|
||
|
||
.stat-row {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-top: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.stat { font-size: 12px; color: var(--muted); }
|
||
.stat span { color: var(--text); font-weight: 600; }
|
||
|
||
.elapsed { font-size: 12px; color: var(--muted); margin-top: 6px; }
|
||
|
||
/* ---------- badges ---------- */
|
||
.badge {
|
||
display: inline-block;
|
||
padding: 2px 8px;
|
||
border-radius: 12px;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
|
||
.badge-success { background: rgba(63,185,80,.18); color: var(--success); }
|
||
.badge-failed { background: rgba(248,81,73,.18); color: var(--danger); }
|
||
.badge-partial { background: rgba(210,153,34,.18); color: var(--warning); }
|
||
.badge-running { background: rgba(163,113,247,.18);color: var(--running); }
|
||
.badge-none { background: rgba(139,148,158,.12);color: var(--muted); }
|
||
.badge-dry { background: rgba(88,166,255,.15); color: var(--accent); }
|
||
.badge-real { background: rgba(63,185,80,.10); color: var(--success); }
|
||
|
||
/* ---------- iterations table ---------- */
|
||
.table-wrap {
|
||
overflow-x: auto;
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
}
|
||
|
||
table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 13px;
|
||
}
|
||
|
||
thead th {
|
||
background: var(--surface);
|
||
color: var(--muted);
|
||
font-weight: 600;
|
||
text-align: left;
|
||
padding: 10px 14px;
|
||
border-bottom: 1px solid var(--border);
|
||
white-space: nowrap;
|
||
font-size: 12px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
|
||
tbody tr {
|
||
border-bottom: 1px solid var(--border);
|
||
cursor: pointer;
|
||
transition: background 0.1s;
|
||
}
|
||
|
||
tbody tr:last-child { border-bottom: none; }
|
||
tbody tr:hover { background: rgba(88,166,255,.06); }
|
||
|
||
td {
|
||
padding: 9px 14px;
|
||
white-space: nowrap;
|
||
color: var(--text);
|
||
}
|
||
|
||
td.muted { color: var(--muted); }
|
||
td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||
|
||
/* ---------- modal ---------- */
|
||
.modal-overlay {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0,0,0,0.65);
|
||
z-index: 100;
|
||
align-items: flex-start;
|
||
justify-content: center;
|
||
padding: 40px 16px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.modal-overlay.open { display: flex; }
|
||
|
||
.modal {
|
||
background: var(--surface);
|
||
border: 1px solid var(--border);
|
||
border-radius: 10px;
|
||
width: 100%;
|
||
max-width: 900px;
|
||
animation: fadeIn .15s ease;
|
||
}
|
||
|
||
@keyframes fadeIn { from { opacity:0; transform:translateY(-8px); } to { opacity:1; transform:none; } }
|
||
|
||
.modal-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 16px 20px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
|
||
.modal-title { font-size: 15px; font-weight: 600; }
|
||
|
||
.modal-close {
|
||
background: none;
|
||
border: none;
|
||
color: var(--muted);
|
||
font-size: 20px;
|
||
cursor: pointer;
|
||
line-height: 1;
|
||
padding: 0 4px;
|
||
}
|
||
|
||
.modal-close:hover { color: var(--text); }
|
||
|
||
.modal-body { padding: 16px 20px; overflow-x: auto; }
|
||
|
||
.modal-body table { font-size: 12px; }
|
||
|
||
.empty { color: var(--muted); padding: 24px; text-align: center; }
|
||
|
||
/* ---------- loading indicator ---------- */
|
||
.loading { color: var(--muted); font-size: 13px; padding: 16px 0; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header>
|
||
<div>
|
||
<h1>⟳ HA Sync Dashboard</h1>
|
||
<p class="subtitle">Bidirectional NFS sync between Dell OptiPlex and HP ProLiant across 6 data pairs. <a href="/about">How it works →</a></p>
|
||
</div>
|
||
<span id="last-refreshed">Loading…</span>
|
||
</header>
|
||
|
||
<main>
|
||
<div class="section-title">Sync Pairs</div>
|
||
<div id="pairs-grid"><p class="loading">Loading pairs…</p></div>
|
||
|
||
<div class="section-title">Recent Iterations</div>
|
||
<div class="table-wrap">
|
||
<table id="iterations-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Pair</th>
|
||
<th>Direction</th>
|
||
<th>Type</th>
|
||
<th>Status</th>
|
||
<th>Created</th>
|
||
<th>Updated</th>
|
||
<th>Deleted</th>
|
||
<th>Failed</th>
|
||
<th>Started</th>
|
||
<th>Duration</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="iterations-body">
|
||
<tr><td colspan="10" class="empty">Loading…</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</main>
|
||
|
||
<!-- Operations Modal -->
|
||
<div class="modal-overlay" id="modal-overlay">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<span class="modal-title" id="modal-title">Operations</span>
|
||
<button class="modal-close" id="modal-close" aria-label="Close">×</button>
|
||
</div>
|
||
<div class="modal-body" id="modal-body">
|
||
<p class="loading">Loading…</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const fmt = {
|
||
date(s) {
|
||
if (!s) return '—';
|
||
const d = new Date(s);
|
||
return d.toLocaleString(undefined, { month:'short', day:'numeric',
|
||
hour:'2-digit', minute:'2-digit', second:'2-digit' });
|
||
},
|
||
duration(startedAt, endedAt) {
|
||
if (!startedAt) return '—';
|
||
const end = endedAt ? new Date(endedAt) : new Date();
|
||
const ms = end - new Date(startedAt);
|
||
if (ms < 0) return '—';
|
||
const s = Math.floor(ms / 1000);
|
||
if (s < 60) return s + 's';
|
||
const m = Math.floor(s / 60), rs = s % 60;
|
||
if (m < 60) return m + 'm ' + rs + 's';
|
||
const h = Math.floor(m / 60), rm = m % 60;
|
||
return h + 'h ' + rm + 'm';
|
||
},
|
||
bytes(n) {
|
||
if (n == null) return '—';
|
||
if (n < 1024) return n + ' B';
|
||
if (n < 1048576) return (n/1024).toFixed(1) + ' KB';
|
||
if (n < 1073741824) return (n/1048576).toFixed(1) + ' MB';
|
||
return (n/1073741824).toFixed(2) + ' GB';
|
||
}
|
||
};
|
||
|
||
function statusBadge(status) {
|
||
if (!status) return '<span class="badge badge-none">—</span>';
|
||
const cls = {
|
||
success: 'badge-success',
|
||
failed: 'badge-failed',
|
||
partial_failure: 'badge-partial',
|
||
running: 'badge-running'
|
||
}[status] || 'badge-none';
|
||
return `<span class="badge ${cls}">${status}</span>`;
|
||
}
|
||
|
||
function dryBadge(dryRun) {
|
||
return dryRun
|
||
? '<span class="badge badge-dry">Dry</span>'
|
||
: '<span class="badge badge-real">Real</span>';
|
||
}
|
||
|
||
// ---- Pairs grid ----
|
||
function renderPairs(pairs) {
|
||
const grid = document.getElementById('pairs-grid');
|
||
if (!pairs.length) {
|
||
grid.innerHTML = '<p class="empty">No pairs found.</p>';
|
||
return;
|
||
}
|
||
grid.innerHTML = pairs.map(p => {
|
||
const real = p.last_real_sync;
|
||
const dry = p.last_dry_run;
|
||
const elapsedReal = real ? fmt.duration(real.started_at, real.ended_at) : null;
|
||
return `
|
||
<div class="pair-card">
|
||
<div class="pair-name">${esc(p.pair)}</div>
|
||
<div class="pair-row">
|
||
<span class="pair-row-label">Real sync</span>
|
||
${real ? statusBadge(real.status) : '<span class="badge badge-none">never</span>'}
|
||
</div>
|
||
<div class="pair-row">
|
||
<span class="pair-row-label">Dry run</span>
|
||
${dry ? statusBadge(dry.status) : '<span class="badge badge-none">never</span>'}
|
||
</div>
|
||
${real ? `
|
||
<div class="stat-row">
|
||
<span class="stat">+<span>${real.files_created}</span></span>
|
||
<span class="stat">~<span>${real.files_updated}</span></span>
|
||
<span class="stat">−<span>${real.files_deleted}</span></span>
|
||
<span class="stat">✗<span>${real.files_failed}</span></span>
|
||
</div>
|
||
${elapsedReal ? `<div class="elapsed">Last ran in ${elapsedReal}</div>` : ''}
|
||
` : ''}
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
// ---- Iterations table ----
|
||
function renderIterations(iterations) {
|
||
const tbody = document.getElementById('iterations-body');
|
||
if (!iterations.length) {
|
||
tbody.innerHTML = '<tr><td colspan="10" class="empty">No iterations found.</td></tr>';
|
||
return;
|
||
}
|
||
tbody.innerHTML = iterations.map(it => `
|
||
<tr data-id="${it.id}" data-pair="${esc(it.sync_pair)}">
|
||
<td>${esc(it.sync_pair)}</td>
|
||
<td class="muted">${esc(it.direction)}</td>
|
||
<td>${dryBadge(it.dry_run)}</td>
|
||
<td>${statusBadge(it.status)}</td>
|
||
<td class="num">${it.files_created}</td>
|
||
<td class="num">${it.files_updated}</td>
|
||
<td class="num">${it.files_deleted}</td>
|
||
<td class="num">${it.files_failed}</td>
|
||
<td class="muted">${fmt.date(it.started_at)}</td>
|
||
<td class="muted">${fmt.duration(it.started_at, it.ended_at)}</td>
|
||
</tr>`).join('');
|
||
|
||
tbody.querySelectorAll('tr[data-id]').forEach(row => {
|
||
row.addEventListener('click', () => openModal(
|
||
parseInt(row.dataset.id, 10),
|
||
row.dataset.pair
|
||
));
|
||
});
|
||
}
|
||
|
||
// ---- Modal ----
|
||
const overlay = document.getElementById('modal-overlay');
|
||
const modalTitle = document.getElementById('modal-title');
|
||
const modalBody = document.getElementById('modal-body');
|
||
|
||
document.getElementById('modal-close').addEventListener('click', closeModal);
|
||
overlay.addEventListener('click', e => { if (e.target === overlay) closeModal(); });
|
||
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
|
||
|
||
function closeModal() { overlay.classList.remove('open'); }
|
||
|
||
async function openModal(iterationId, pair) {
|
||
modalTitle.textContent = `Operations — iteration #${iterationId} (${pair})`;
|
||
modalBody.innerHTML = '<p class="loading">Loading…</p>';
|
||
overlay.classList.add('open');
|
||
|
||
try {
|
||
const ops = await apiFetch(`/api/operations?iteration_id=${iterationId}`);
|
||
if (!ops.length) {
|
||
modalBody.innerHTML = '<p class="empty">No operations recorded.</p>';
|
||
return;
|
||
}
|
||
modalBody.innerHTML = `
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>Op</th><th>File</th><th>Status</th>
|
||
<th>Size Before</th><th>Size After</th>
|
||
<th>Started</th><th>Duration</th><th>Error</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${ops.map(op => `
|
||
<tr>
|
||
<td><span class="badge ${op.operation === 'delete' ? 'badge-failed' : op.operation === 'create' ? 'badge-success' : 'badge-dry'}">${esc(op.operation)}</span></td>
|
||
<td style="max-width:320px;overflow:hidden;text-overflow:ellipsis" title="${esc(op.filepath)}">${esc(op.filepath)}</td>
|
||
<td>${statusBadge(op.status)}</td>
|
||
<td class="num">${fmt.bytes(op.size_before)}</td>
|
||
<td class="num">${fmt.bytes(op.size_after)}</td>
|
||
<td class="muted">${fmt.date(op.started_at)}</td>
|
||
<td class="muted">${fmt.duration(op.started_at, op.ended_at)}</td>
|
||
<td class="muted" style="max-width:200px;overflow:hidden;text-overflow:ellipsis" title="${esc(op.error_message||'')}">${esc(op.error_message||'')}</td>
|
||
</tr>`).join('')}
|
||
</tbody>
|
||
</table>`;
|
||
} catch (err) {
|
||
modalBody.innerHTML = `<p class="empty">Error: ${esc(err.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
// ---- Data fetching ----
|
||
async function apiFetch(url) {
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||
return resp.json();
|
||
}
|
||
|
||
async function refresh() {
|
||
try {
|
||
const [pairs, iterations] = await Promise.all([
|
||
apiFetch('/api/pairs'),
|
||
apiFetch('/api/iterations?limit=20'),
|
||
]);
|
||
renderPairs(pairs);
|
||
renderIterations(iterations);
|
||
document.getElementById('last-refreshed').textContent =
|
||
'Last refreshed: ' + new Date().toLocaleTimeString();
|
||
} catch (err) {
|
||
document.getElementById('last-refreshed').textContent =
|
||
'Refresh failed: ' + err.message;
|
||
}
|
||
}
|
||
|
||
function esc(s) {
|
||
if (s == null) return '';
|
||
return String(s)
|
||
.replace(/&/g,'&').replace(/</g,'<')
|
||
.replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
refresh();
|
||
setInterval(refresh, 15000);
|
||
</script>
|
||
</body>
|
||
</html>
|