homelab/services/ha-sync/internal/ui/templates/index.html
Dan V deb6c38d7b chore: commit homelab setup — deployment, services, orchestration, skill
- 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>
2026-04-09 08:10:32 +02:00

1728 lines
67 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="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 &amp; 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 &amp; 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&#10;.Trash-*&#10;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,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ═══════════════════════════════════════════════════════════════
// 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,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
refresh();
setInterval(refresh, 15000);
</script>
</body>
</html>