backup utility working

This commit is contained in:
Peter Li 2026-02-07 21:26:05 -08:00
parent 2ce2d5d881
commit a8d3bc66ad
3 changed files with 185 additions and 42 deletions

View File

@ -26,6 +26,7 @@ type liveSiteStatus struct {
SiteID int64 `json:"site_id"` SiteID int64 `json:"site_id"`
LastRunStatus string `json:"last_run_status"` LastRunStatus string `json:"last_run_status"`
LastRunOutput string `json:"last_run_output"` LastRunOutput string `json:"last_run_output"`
LastScanState string `json:"last_scan_state"`
LastRunAt time.Time `json:"last_run_at,omitempty"` LastRunAt time.Time `json:"last_run_at,omitempty"`
} }
@ -121,10 +122,14 @@ func (a *app) buildLiveProgressPayload(ctx context.Context) (liveProgressPayload
SiteID: site.ID, SiteID: site.ID,
LastRunStatus: strings.TrimSpace(site.LastRunStatus.String), LastRunStatus: strings.TrimSpace(site.LastRunStatus.String),
LastRunOutput: strings.TrimSpace(site.LastRunOutput.String), LastRunOutput: strings.TrimSpace(site.LastRunOutput.String),
LastScanState: strings.TrimSpace(site.LastScanState.String),
} }
if row.LastRunStatus == "" { if row.LastRunStatus == "" {
row.LastRunStatus = "pending" row.LastRunStatus = "pending"
} }
if row.LastScanState == "" {
row.LastScanState = "pending"
}
if site.LastRunAt.Valid { if site.LastRunAt.Valid {
row.LastRunAt = site.LastRunAt.Time row.LastRunAt = site.LastRunAt.Time
} }

View File

@ -62,8 +62,10 @@ func Dashboard(data DashboardData) templ.Component {
} }
var sites strings.Builder var sites strings.Builder
var siteNav strings.Builder
if len(data.Sites) == 0 { if len(data.Sites) == 0 {
sites.WriteString(`<p class="muted">No sites added yet. Add your first Linux SSH site below.</p>`) sites.WriteString(`<p class="muted">No sites added yet. Add your first Linux SSH site below.</p>`)
siteNav.WriteString(`<p class="muted">No sites yet.</p>`)
} }
for _, site := range data.Sites { for _, site := range data.Sites {
last := "Never run" last := "Never run"
@ -90,6 +92,17 @@ func Dashboard(data DashboardData) templ.Component {
if site.LastScanNotes.Valid && site.LastScanNotes.String != "" { if site.LastScanNotes.Valid && site.LastScanNotes.String != "" {
scanNotes = site.LastScanNotes.String scanNotes = site.LastScanNotes.String
} }
siteNav.WriteString(fmt.Sprintf(`<button class="site-nav-item" type="button" data-site-select="%d"><strong>%s@%s</strong><span class="pill %s" data-sidebar-run-status="%d">%s</span><span class="pill %s" data-sidebar-scan-status="%d">%s</span></button>`,
site.ID,
html.EscapeString(site.SSHUser),
html.EscapeString(site.Host),
html.EscapeString(runStatus),
site.ID,
html.EscapeString(runStatus),
html.EscapeString(scanState),
site.ID,
html.EscapeString(scanState),
))
var targets strings.Builder var targets strings.Builder
if len(site.Targets) == 0 { if len(site.Targets) == 0 {
@ -238,14 +251,15 @@ func Dashboard(data DashboardData) templ.Component {
</div> </div>
</div> </div>
%s %s
<section> <div class="dashboard-shell">
<h2>Host Runtime Status</h2> <aside class="site-sidebar">
<h2>Sites Health</h2>
<p class="muted" id="live-connection">Connecting...</p>
<div class="site-nav-list">%s</div>
<h2>Runtime Checks</h2>
<ul class="status-list">%s</ul> <ul class="status-list">%s</ul>
</section> </aside>
<section> <section class="site-main-panel">
<h2>Planned Workflow</h2>
<ol class="workflow">%s</ol>
</section>
<section> <section>
<h2>Add Site</h2> <h2>Add Site</h2>
<form class="grid-2" method="post" action="/sites"> <form class="grid-2" method="post" action="/sites">
@ -259,34 +273,59 @@ func Dashboard(data DashboardData) templ.Component {
</form> </form>
</section> </section>
<section> <section>
<h2>Live Backup Progress</h2> <h2 id="active-site-title">Active Site</h2>
<p class="muted" id="live-connection">Connecting...</p> %s
</section>
<section>
<h2>Active Site Logs</h2>
<ul class="target-list" id="live-active-jobs"> <ul class="target-list" id="live-active-jobs">
<li class="muted">Waiting for job updates.</li> <li class="muted">Waiting for job updates.</li>
</ul> </ul>
<div class="row"> <div class="row">
<button class="button ghost button-sm copy-btn" type="button" data-copy-target="live-events">Copy live events</button> <button class="button ghost button-sm copy-btn" type="button" data-copy-target="live-events">Copy active logs</button>
</div> </div>
<pre class="output" id="live-events">No live events yet.</pre> <pre class="output" id="live-events">No live events yet.</pre>
</section> </section>
<section> <section>
<h2>Managed Sites</h2> <h2>Planned Workflow</h2>
%s <ol class="workflow">%s</ol>
</section> </section>
</section>
</div>
</main> </main>
<script> <script>
(function () { (function () {
const connLabel = document.getElementById("live-connection"); const connLabel = document.getElementById("live-connection");
const activeJobs = document.getElementById("live-active-jobs"); const activeJobs = document.getElementById("live-active-jobs");
const liveEvents = document.getElementById("live-events"); const liveEvents = document.getElementById("live-events");
const activeSiteTitle = document.getElementById("active-site-title");
const siteButtons = Array.from(document.querySelectorAll("[data-site-select]"));
const siteCards = Array.from(document.querySelectorAll(".site-card"));
let activeSiteID = null;
function safeText(v, fallback) { function safeText(v, fallback) {
const s = (v || "").toString().trim(); const s = (v || "").toString().trim();
return s === "" ? fallback : s; return s === "" ? fallback : s;
} }
function setActiveSite(siteID) {
activeSiteID = siteID;
siteButtons.forEach((btn) => {
const id = Number(btn.getAttribute("data-site-select"));
btn.classList.toggle("active", id === siteID);
});
siteCards.forEach((card) => {
const id = Number(card.getAttribute("data-site-id"));
card.style.display = id === siteID ? "" : "none";
});
const activeBtn = siteButtons.find((btn) => Number(btn.getAttribute("data-site-select")) === siteID);
if (activeBtn && activeSiteTitle) {
activeSiteTitle.textContent = "Active Site: " + activeBtn.querySelector("strong").textContent;
}
}
function renderJobs(jobs) { function renderJobs(jobs) {
const active = jobs.filter((j) => j.status === "queued" || j.status === "running"); const active = jobs.filter((j) => (j.status === "queued" || j.status === "running") && (activeSiteID === null || j.site_id === activeSiteID));
if (active.length === 0) { if (active.length === 0) {
activeJobs.innerHTML = '<li class="muted">No queued or running jobs.</li>'; activeJobs.innerHTML = '<li class="muted">No queued or running jobs.</li>';
return; return;
@ -298,11 +337,12 @@ func Dashboard(data DashboardData) templ.Component {
} }
function renderEvents(events) { function renderEvents(events) {
if (!events || events.length === 0) { const filtered = (events || []).filter((e) => activeSiteID === null || e.site_id === activeSiteID);
if (filtered.length === 0) {
liveEvents.textContent = "No live events yet."; liveEvents.textContent = "No live events yet.";
return; return;
} }
const lines = events.slice(0, 12).map((e) => { const lines = filtered.slice(0, 16).map((e) => {
const ts = new Date(e.occurred_at).toLocaleString(); const ts = new Date(e.occurred_at).toLocaleString();
return "[" + ts + "] site=" + e.site_id + " job=" + e.job_id + " (" + e.job_type + ") " + e.level + ": " + e.message; return "[" + ts + "] site=" + e.site_id + " job=" + e.job_id + " (" + e.job_type + ") " + e.level + ": " + e.message;
}); });
@ -339,6 +379,18 @@ func Dashboard(data DashboardData) templ.Component {
statusEl.textContent = status; statusEl.textContent = status;
statusEl.className = "pill " + status; statusEl.className = "pill " + status;
} }
const sidebarRun = document.querySelector("[data-sidebar-run-status=\"" + s.site_id + "\"]");
if (sidebarRun) {
const status = safeText(s.last_run_status, "pending");
sidebarRun.textContent = status;
sidebarRun.className = "pill " + status;
}
const sidebarScan = document.querySelector("[data-sidebar-scan-status=\"" + s.site_id + "\"]");
if (sidebarScan) {
const scanState = safeText(s.last_scan_state, "pending");
sidebarScan.textContent = scanState;
sidebarScan.className = "pill " + scanState;
}
const outputEl = document.querySelector("[data-site-run-output=\"" + s.site_id + "\"]"); const outputEl = document.querySelector("[data-site-run-output=\"" + s.site_id + "\"]");
if (outputEl) { if (outputEl) {
outputEl.textContent = safeText(s.last_run_output, "(no output yet)"); outputEl.textContent = safeText(s.last_run_output, "(no output yet)");
@ -390,6 +442,19 @@ func Dashboard(data DashboardData) templ.Component {
}; };
} }
function setupSiteSelection() {
if (siteButtons.length === 0) {
return;
}
const firstID = Number(siteButtons[0].getAttribute("data-site-select"));
setActiveSite(firstID);
siteButtons.forEach((btn) => {
btn.addEventListener("click", function () {
setActiveSite(Number(btn.getAttribute("data-site-select")));
});
});
}
function setupCopyButtons() { function setupCopyButtons() {
document.querySelectorAll(".copy-btn").forEach((btn) => { document.querySelectorAll(".copy-btn").forEach((btn) => {
btn.addEventListener("click", function () { btn.addEventListener("click", function () {
@ -441,6 +506,7 @@ func Dashboard(data DashboardData) templ.Component {
setupCopyButtons(); setupCopyButtons();
setupActiveJobActions(); setupActiveJobActions();
setupSiteSelection();
connect(); connect();
})(); })();
</script> </script>
@ -449,9 +515,10 @@ func Dashboard(data DashboardData) templ.Component {
html.EscapeString(data.User.Username), html.EscapeString(data.User.Username),
html.EscapeString(role), html.EscapeString(role),
flash, flash,
siteNav.String(),
checks.String(), checks.String(),
flows.String(),
sites.String(), sites.String(),
flows.String(),
)) ))
return err return err
}) })

View File

@ -14,8 +14,7 @@
body { body {
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
display: grid; display: block;
place-items: center;
background: radial-gradient(circle at top, #1e293b 0, var(--bg) 55%); background: radial-gradient(circle at top, #1e293b 0, var(--bg) 55%);
color: var(--fg); color: var(--fg);
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
@ -30,7 +29,51 @@ body {
} }
.dashboard-card { .dashboard-card {
width: min(980px, calc(100vw - 2rem)); width: calc(100vw - 2rem);
max-width: none;
margin: 1rem;
}
.dashboard-shell {
margin-top: 1.2rem;
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 1rem;
}
.site-sidebar {
border: 1px solid var(--border);
border-radius: 12px;
padding: 0.8rem;
height: fit-content;
position: sticky;
top: 1rem;
}
.site-main-panel {
min-width: 0;
}
.site-nav-list {
display: grid;
gap: 0.5rem;
margin: 0.75rem 0 1rem;
}
.site-nav-item {
border: 1px solid var(--border);
border-radius: 10px;
background: color-mix(in srgb, var(--card) 75%, black);
color: var(--fg);
text-align: left;
padding: 0.55rem 0.65rem;
display: grid;
gap: 0.35rem;
cursor: pointer;
}
.site-nav-item.active {
border-color: #38bdf8;
} }
.eyebrow { .eyebrow {
@ -142,6 +185,24 @@ h2 {
gap: 0.75rem; gap: 0.75rem;
} }
.site-sidebar .status-list {
grid-template-columns: 1fr;
gap: 0.4rem;
}
.site-sidebar .status {
padding: 0.4rem 0.5rem;
gap: 0.2rem;
}
.site-sidebar .status strong {
font-size: 0.82rem;
}
.site-sidebar .status span {
font-size: 0.74rem;
}
.status { .status {
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 12px; border-radius: 12px;
@ -328,6 +389,16 @@ textarea {
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
@media (max-width: 980px) {
.dashboard-shell {
grid-template-columns: 1fr;
}
.site-sidebar {
position: static;
}
}
a { a {
color: #7dd3fc; color: #7dd3fc;
} }