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"`
LastRunStatus string `json:"last_run_status"`
LastRunOutput string `json:"last_run_output"`
LastScanState string `json:"last_scan_state"`
LastRunAt time.Time `json:"last_run_at,omitempty"`
}
@ -121,10 +122,14 @@ func (a *app) buildLiveProgressPayload(ctx context.Context) (liveProgressPayload
SiteID: site.ID,
LastRunStatus: strings.TrimSpace(site.LastRunStatus.String),
LastRunOutput: strings.TrimSpace(site.LastRunOutput.String),
LastScanState: strings.TrimSpace(site.LastScanState.String),
}
if row.LastRunStatus == "" {
row.LastRunStatus = "pending"
}
if row.LastScanState == "" {
row.LastScanState = "pending"
}
if site.LastRunAt.Valid {
row.LastRunAt = site.LastRunAt.Time
}

View File

@ -62,8 +62,10 @@ func Dashboard(data DashboardData) templ.Component {
}
var sites strings.Builder
var siteNav strings.Builder
if len(data.Sites) == 0 {
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 {
last := "Never run"
@ -90,6 +92,17 @@ func Dashboard(data DashboardData) templ.Component {
if site.LastScanNotes.Valid && 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
if len(site.Targets) == 0 {
@ -238,55 +251,81 @@ func Dashboard(data DashboardData) templ.Component {
</div>
</div>
%s
<section>
<h2>Host Runtime Status</h2>
<ul class="status-list">%s</ul>
</section>
<section>
<h2>Planned Workflow</h2>
<ol class="workflow">%s</ol>
</section>
<section>
<h2>Add Site</h2>
<form class="grid-2" method="post" action="/sites">
<label class="stack"><span>SSH User</span><input name="ssh_user" required /></label>
<label class="stack"><span>Server</span><input name="host" placeholder="host or ip" required /></label>
<label class="stack"><span>Port</span><input type="number" name="port" min="1" max="65535" value="22" required /></label>
<label class="stack"><span>Directory paths (one per line)</span><textarea name="directory_paths" placeholder="/var/www&#10;/etc/nginx" rows="4"></textarea></label>
<label class="stack"><span>SQLite DB paths (will dump, one per line)</span><textarea name="sqlite_paths" placeholder="/srv/app/db/app.sqlite3" rows="4"></textarea></label>
<label class="stack"><span>Exclude filters (one per line)</span><textarea name="filters" placeholder="*.tmp&#10;node_modules" rows="4"></textarea></label>
<button class="button" type="submit">Add site</button>
</form>
</section>
<section>
<h2>Live Backup Progress</h2>
<p class="muted" id="live-connection">Connecting...</p>
<ul class="target-list" id="live-active-jobs">
<li class="muted">Waiting for job updates.</li>
</ul>
<div class="row">
<button class="button ghost button-sm copy-btn" type="button" data-copy-target="live-events">Copy live events</button>
</div>
<pre class="output" id="live-events">No live events yet.</pre>
</section>
<section>
<h2>Managed Sites</h2>
%s
</section>
<div class="dashboard-shell">
<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>
</aside>
<section class="site-main-panel">
<section>
<h2>Add Site</h2>
<form class="grid-2" method="post" action="/sites">
<label class="stack"><span>SSH User</span><input name="ssh_user" required /></label>
<label class="stack"><span>Server</span><input name="host" placeholder="host or ip" required /></label>
<label class="stack"><span>Port</span><input type="number" name="port" min="1" max="65535" value="22" required /></label>
<label class="stack"><span>Directory paths (one per line)</span><textarea name="directory_paths" placeholder="/var/www&#10;/etc/nginx" rows="4"></textarea></label>
<label class="stack"><span>SQLite DB paths (will dump, one per line)</span><textarea name="sqlite_paths" placeholder="/srv/app/db/app.sqlite3" rows="4"></textarea></label>
<label class="stack"><span>Exclude filters (one per line)</span><textarea name="filters" placeholder="*.tmp&#10;node_modules" rows="4"></textarea></label>
<button class="button" type="submit">Add site</button>
</form>
</section>
<section>
<h2 id="active-site-title">Active Site</h2>
%s
</section>
<section>
<h2>Active Site Logs</h2>
<ul class="target-list" id="live-active-jobs">
<li class="muted">Waiting for job updates.</li>
</ul>
<div class="row">
<button class="button ghost button-sm copy-btn" type="button" data-copy-target="live-events">Copy active logs</button>
</div>
<pre class="output" id="live-events">No live events yet.</pre>
</section>
<section>
<h2>Planned Workflow</h2>
<ol class="workflow">%s</ol>
</section>
</section>
</div>
</main>
<script>
(function () {
const connLabel = document.getElementById("live-connection");
const activeJobs = document.getElementById("live-active-jobs");
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) {
const s = (v || "").toString().trim();
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) {
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) {
activeJobs.innerHTML = '<li class="muted">No queued or running jobs.</li>';
return;
@ -298,11 +337,12 @@ func Dashboard(data DashboardData) templ.Component {
}
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.";
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();
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.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 + "\"]");
if (outputEl) {
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() {
document.querySelectorAll(".copy-btn").forEach((btn) => {
btn.addEventListener("click", function () {
@ -441,6 +506,7 @@ func Dashboard(data DashboardData) templ.Component {
setupCopyButtons();
setupActiveJobActions();
setupSiteSelection();
connect();
})();
</script>
@ -449,9 +515,10 @@ func Dashboard(data DashboardData) templ.Component {
html.EscapeString(data.User.Username),
html.EscapeString(role),
flash,
siteNav.String(),
checks.String(),
flows.String(),
sites.String(),
flows.String(),
))
return err
})

View File

@ -14,8 +14,7 @@
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
display: block;
background: radial-gradient(circle at top, #1e293b 0, var(--bg) 55%);
color: var(--fg);
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
@ -30,7 +29,51 @@ body {
}
.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 {
@ -142,6 +185,24 @@ h2 {
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 {
border: 1px solid var(--border);
border-radius: 12px;
@ -328,6 +389,16 @@ textarea {
to { transform: rotate(360deg); }
}
@media (max-width: 980px) {
.dashboard-shell {
grid-template-columns: 1fr;
}
.site-sidebar {
position: static;
}
}
a {
color: #7dd3fc;
}