671 lines
24 KiB
Go
671 lines
24 KiB
Go
package webui
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/a-h/templ"
|
|
|
|
"satoru/internal/store"
|
|
)
|
|
|
|
type RuntimeCheck struct {
|
|
Name string
|
|
Installed bool
|
|
Details string
|
|
}
|
|
|
|
type WorkflowStage struct {
|
|
Title string
|
|
Description string
|
|
}
|
|
|
|
type DashboardData struct {
|
|
Now time.Time
|
|
ScanInterval time.Duration
|
|
User store.User
|
|
Sites []store.Site
|
|
RuntimeChecks []RuntimeCheck
|
|
WorkflowStages []WorkflowStage
|
|
FlashMessage string
|
|
ShowB2InitButton bool
|
|
B2Repo string
|
|
ResticRepoPath string
|
|
ResticRepoSize string
|
|
ResticSnapshots []string
|
|
}
|
|
|
|
func Dashboard(data DashboardData) templ.Component {
|
|
return templ.ComponentFunc(func(_ context.Context, w io.Writer) error {
|
|
role := "Operator"
|
|
if data.User.IsAdmin {
|
|
role = "Admin"
|
|
}
|
|
|
|
var checks strings.Builder
|
|
for _, c := range data.RuntimeChecks {
|
|
status := "missing"
|
|
if c.Installed {
|
|
status = "installed"
|
|
}
|
|
checks.WriteString(fmt.Sprintf(`<li class="status %s"><strong>%s</strong><span>%s</span></li>`,
|
|
status,
|
|
html.EscapeString(c.Name),
|
|
html.EscapeString(c.Details)))
|
|
}
|
|
|
|
var flows strings.Builder
|
|
for _, s := range data.WorkflowStages {
|
|
flows.WriteString(fmt.Sprintf(`<li class="workflow-step"><strong>%s</strong><p>%s</p></li>`,
|
|
html.EscapeString(s.Title),
|
|
html.EscapeString(s.Description)))
|
|
}
|
|
|
|
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"
|
|
if site.LastRunAt.Valid {
|
|
last = site.LastRunAt.Time.Local().Format(time.RFC1123)
|
|
}
|
|
runStatus := "pending"
|
|
if site.LastRunStatus.Valid {
|
|
runStatus = site.LastRunStatus.String
|
|
}
|
|
runOutput := "(no output yet)"
|
|
if site.LastRunOutput.Valid && strings.TrimSpace(site.LastRunOutput.String) != "" {
|
|
runOutput = site.LastRunOutput.String
|
|
}
|
|
scanState := "pending"
|
|
if site.LastScanState.Valid && site.LastScanState.String != "" {
|
|
scanState = site.LastScanState.String
|
|
}
|
|
lastScan := "Never"
|
|
if site.LastScanAt.Valid {
|
|
lastScan = site.LastScanAt.Time.Local().Format(time.RFC1123)
|
|
}
|
|
scanNotes := "No scan yet."
|
|
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 {
|
|
targets.WriteString(`<li><span class="pill pending">none</span> <code>(no targets)</code></li>`)
|
|
}
|
|
for _, t := range site.Targets {
|
|
sizeOrErr := "size pending"
|
|
if t.LastError.Valid && t.LastError.String != "" {
|
|
sizeOrErr = "error: " + t.LastError.String
|
|
} else if t.Mode == "mysql_dump" && t.LastScanAt.Valid {
|
|
sizeOrErr = "connection established"
|
|
} else if t.LastSizeByte.Valid {
|
|
sizeOrErr = formatBytes(t.LastSizeByte.Int64)
|
|
}
|
|
label := targetModeLabel(t.Mode)
|
|
pathText := t.Path
|
|
if t.Mode == "mysql_dump" {
|
|
label = "mysql dump"
|
|
if t.MySQLHost.Valid && t.MySQLUser.Valid && t.MySQLDB.Valid {
|
|
pathText = fmt.Sprintf("db=%s user=%s host=%s", t.MySQLDB.String, t.MySQLUser.String, t.MySQLHost.String)
|
|
}
|
|
}
|
|
targets.WriteString(fmt.Sprintf(`<li><span class="pill %s">%s</span> <code>%s</code><span class="muted inline">%s</span><form class="inline-form" method="post" action="/sites/%d/targets/%d/delete"><button class="button ghost button-sm" type="submit">Remove</button></form></li>`,
|
|
targetModeClass(t.Mode),
|
|
html.EscapeString(label),
|
|
html.EscapeString(pathText),
|
|
html.EscapeString(sizeOrErr),
|
|
site.ID,
|
|
t.ID))
|
|
}
|
|
|
|
sites.WriteString(fmt.Sprintf(`
|
|
<article class="site-card" data-site-id="%d">
|
|
<div class="site-head">
|
|
<h3>%s@%s:%d</h3>
|
|
<div class="row">
|
|
<form method="post" action="/sites/%d/run">
|
|
<button class="button" type="submit">Run backup</button>
|
|
</form>
|
|
<form method="post" action="/sites/%d/cancel">
|
|
<button class="button ghost" type="submit">Cancel active job</button>
|
|
</form>
|
|
<form method="post" action="/sites/%d/restart">
|
|
<button class="button ghost" type="submit">Restart backup</button>
|
|
</form>
|
|
<form method="post" action="/sites/%d/sync">
|
|
<button class="button ghost" type="submit">Sync to B2</button>
|
|
</form>
|
|
<form method="post" action="/sites/%d/delete" onsubmit="return confirm('Delete this site?');">
|
|
<button class="button ghost" type="submit">Delete</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
<details class="edit-panel">
|
|
<summary>Edit site</summary>
|
|
<form class="grid-2" method="post" action="/sites/%d/update">
|
|
<label class="stack"><span>SSH User</span><input name="ssh_user" value="%s" required /></label>
|
|
<label class="stack"><span>Server</span><input name="host" value="%s" required /></label>
|
|
<label class="stack"><span>Port</span><input type="number" name="port" min="1" max="65535" value="%d" required /></label>
|
|
<label class="stack"><span>Directory paths (one per line)</span><textarea name="directory_paths" rows="4">%s</textarea></label>
|
|
<label class="stack"><span>SQLite DB paths (one per line)</span><textarea name="sqlite_paths" rows="4">%s</textarea></label>
|
|
<label class="stack"><span>Exclude filters (one per line)</span><textarea name="filters" rows="4">%s</textarea></label>
|
|
<button class="button" type="submit">Save changes</button>
|
|
</form>
|
|
</details>
|
|
<details class="edit-panel">
|
|
<summary>Add MySQL dump operation</summary>
|
|
<form class="grid-2" method="post" action="/sites/%d/mysql-dumps">
|
|
<label class="stack"><span>DB Host</span><input name="db_host" placeholder="127.0.0.1" required /></label>
|
|
<label class="stack"><span>DB User</span><input name="db_user" placeholder="backup_user" required /></label>
|
|
<label class="stack"><span>Database</span><input name="db_name" placeholder="appdb" required /></label>
|
|
<label class="stack"><span>DB Password</span><input type="password" name="db_password" required /></label>
|
|
<button class="button" type="submit">Add MySQL dump</button>
|
|
</form>
|
|
</details>
|
|
<p class="muted">Backup targets:</p>
|
|
<ul class="target-list">%s</ul>
|
|
<p class="muted">Filters: %s</p>
|
|
<p class="muted">Scan: <span class="pill %s">%s</span> · Last: %s · Next: %s</p>
|
|
<p class="muted">%s</p>
|
|
<p class="muted">Last run: %s</p>
|
|
<p class="muted">Status: <span class="pill %s" data-site-run-status="%d">%s</span></p>
|
|
<p class="muted">Current step: <span data-site-current-step="%d">idle</span><span class="throbber hidden" data-site-throbber="%d" aria-hidden="true"></span></p>
|
|
<div class="row">
|
|
<button class="button ghost button-sm copy-btn" type="button" data-copy-target="site-output-%d">Copy output</button>
|
|
</div>
|
|
<pre class="output" id="site-output-%d" data-site-run-output="%d">%s</pre>
|
|
</article>`,
|
|
site.ID,
|
|
html.EscapeString(site.SSHUser),
|
|
html.EscapeString(site.Host),
|
|
site.Port,
|
|
site.ID,
|
|
site.ID,
|
|
site.ID,
|
|
site.ID,
|
|
site.ID,
|
|
site.ID,
|
|
html.EscapeString(site.SSHUser),
|
|
html.EscapeString(site.Host),
|
|
site.Port,
|
|
html.EscapeString(joinTargetPaths(site.Targets, "directory")),
|
|
html.EscapeString(joinTargetPaths(site.Targets, "sqlite_dump")),
|
|
html.EscapeString(strings.Join(site.Filters, "\n")),
|
|
site.ID,
|
|
targets.String(),
|
|
html.EscapeString(formatFilters(site.Filters)),
|
|
html.EscapeString(scanState),
|
|
html.EscapeString(scanState),
|
|
html.EscapeString(lastScan),
|
|
html.EscapeString(timeUntilNextScan(data.Now, site.LastScanAt, data.ScanInterval)),
|
|
html.EscapeString(scanNotes),
|
|
html.EscapeString(last),
|
|
html.EscapeString(runStatus),
|
|
site.ID,
|
|
html.EscapeString(runStatus),
|
|
site.ID,
|
|
site.ID,
|
|
site.ID,
|
|
site.ID,
|
|
site.ID,
|
|
html.EscapeString(runOutput),
|
|
))
|
|
}
|
|
|
|
flash := ""
|
|
if data.FlashMessage != "" {
|
|
flash = fmt.Sprintf(`<p class="notice">%s</p>`, html.EscapeString(formatFlash(data.FlashMessage)))
|
|
}
|
|
b2Init := ""
|
|
if data.ShowB2InitButton {
|
|
b2Init = fmt.Sprintf(`<form method="post" action="/b2/init"><button class="button button-sm" type="submit">Initialize B2 Repo</button><p class="muted">Repository: <code>%s</code></p></form>`, html.EscapeString(data.B2Repo))
|
|
}
|
|
var resticSnaps strings.Builder
|
|
for _, s := range data.ResticSnapshots {
|
|
resticSnaps.WriteString(fmt.Sprintf(`<li><code>%s</code></li>`, html.EscapeString(s)))
|
|
}
|
|
if len(data.ResticSnapshots) == 0 {
|
|
resticSnaps.WriteString(`<li class="muted">(no snapshots yet)</li>`)
|
|
}
|
|
|
|
_, err := io.WriteString(w, fmt.Sprintf(`<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Satoru Dashboard</title>
|
|
<link rel="stylesheet" href="/static/app.css" />
|
|
</head>
|
|
<body>
|
|
<main class="card dashboard-card">
|
|
<div class="row split">
|
|
<div>
|
|
<p class="eyebrow">Satoru</p>
|
|
<h1>Managed Sites Overview</h1>
|
|
<p class="muted">Signed in as <strong>%s</strong> (%s).</p>
|
|
</div>
|
|
<div class="row">
|
|
<a class="button ghost" href="/account/password">Change password</a>
|
|
<form method="post" action="/signout"><button class="button ghost" type="submit">Sign out</button></form>
|
|
</div>
|
|
</div>
|
|
%s
|
|
<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>
|
|
%s
|
|
</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 /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 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>Local Restic Repository</h2>
|
|
<p class="muted">Path: <code>%s</code></p>
|
|
<p class="muted">Size: %s</p>
|
|
<p class="muted">Snapshots:</p>
|
|
<ul class="target-list">%s</ul>
|
|
</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") && (activeSiteID === null || j.site_id === activeSiteID));
|
|
if (active.length === 0) {
|
|
activeJobs.innerHTML = '<li class="muted">No queued or running jobs.</li>';
|
|
return;
|
|
}
|
|
activeJobs.innerHTML = active.map((j) => {
|
|
const summary = safeText(j.summary, "in progress");
|
|
return "<li><span class=\"pill " + j.status + "\">" + j.status + "</span> Job #" + j.job_id + " site " + j.site_id + " (" + j.type + ") <span class=\"muted inline\">" + summary + "</span> <button class=\"button ghost button-sm cancel-job-btn\" type=\"button\" data-job-id=\"" + j.job_id + "\">Cancel</button></li>";
|
|
}).join("");
|
|
}
|
|
|
|
function renderEvents(events) {
|
|
const filtered = (events || []).filter((e) => activeSiteID === null || e.site_id === activeSiteID);
|
|
if (filtered.length === 0) {
|
|
liveEvents.textContent = "No live events yet.";
|
|
return;
|
|
}
|
|
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;
|
|
});
|
|
liveEvents.textContent = lines.join("\n");
|
|
}
|
|
|
|
function latestEventByJob(events) {
|
|
const out = {};
|
|
(events || []).forEach((e) => {
|
|
if (out[e.job_id]) {
|
|
return;
|
|
}
|
|
out[e.job_id] = e;
|
|
});
|
|
return out;
|
|
}
|
|
|
|
function updateSites(sites, jobs, events) {
|
|
const activeJobBySite = {};
|
|
(jobs || []).forEach((j) => {
|
|
if (activeJobBySite[j.site_id]) {
|
|
return;
|
|
}
|
|
if (j.status === "queued" || j.status === "running") {
|
|
activeJobBySite[j.site_id] = j;
|
|
}
|
|
});
|
|
const eventByJob = latestEventByJob(events);
|
|
|
|
sites.forEach((s) => {
|
|
const statusEl = document.querySelector("[data-site-run-status=\"" + s.site_id + "\"]");
|
|
if (statusEl) {
|
|
const status = safeText(s.last_run_status, "pending");
|
|
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)");
|
|
}
|
|
const stepEl = document.querySelector("[data-site-current-step=\"" + s.site_id + "\"]");
|
|
const throbberEl = document.querySelector("[data-site-throbber=\"" + s.site_id + "\"]");
|
|
const activeJob = activeJobBySite[s.site_id];
|
|
if (!stepEl || !throbberEl) {
|
|
return;
|
|
}
|
|
if (!activeJob) {
|
|
stepEl.textContent = "idle";
|
|
throbberEl.classList.add("hidden");
|
|
return;
|
|
}
|
|
const ev = eventByJob[activeJob.job_id];
|
|
stepEl.textContent = ev ? ev.message : ("job " + activeJob.status + " (" + activeJob.type + ")");
|
|
throbberEl.classList.remove("hidden");
|
|
});
|
|
}
|
|
|
|
function connect() {
|
|
const proto = window.location.protocol === "https:" ? "wss" : "ws";
|
|
const ws = new WebSocket(proto + "://" + window.location.host + "/ws/progress");
|
|
|
|
ws.onopen = function () {
|
|
connLabel.textContent = "Live updates connected.";
|
|
};
|
|
ws.onclose = function () {
|
|
connLabel.textContent = "Disconnected. Reconnecting...";
|
|
setTimeout(connect, 1500);
|
|
};
|
|
ws.onerror = function () {
|
|
connLabel.textContent = "Live updates error.";
|
|
};
|
|
ws.onmessage = function (evt) {
|
|
let payload;
|
|
try {
|
|
payload = JSON.parse(evt.data);
|
|
} catch (_) {
|
|
return;
|
|
}
|
|
const sites = payload.sites || [];
|
|
const jobs = payload.jobs || [];
|
|
const events = payload.events || [];
|
|
updateSites(sites, jobs, events);
|
|
renderJobs(jobs);
|
|
renderEvents(events);
|
|
};
|
|
}
|
|
|
|
function setupSiteSelection() {
|
|
if (siteButtons.length === 0) {
|
|
return;
|
|
}
|
|
const stored = Number(window.localStorage.getItem("satoru.active_site_id") || "");
|
|
const hasStored = siteButtons.some((btn) => Number(btn.getAttribute("data-site-select")) === stored);
|
|
const firstID = Number(siteButtons[0].getAttribute("data-site-select"));
|
|
setActiveSite(hasStored ? stored : firstID);
|
|
siteButtons.forEach((btn) => {
|
|
btn.addEventListener("click", function () {
|
|
const id = Number(btn.getAttribute("data-site-select"));
|
|
setActiveSite(id);
|
|
window.localStorage.setItem("satoru.active_site_id", String(id));
|
|
});
|
|
});
|
|
}
|
|
|
|
function setupCopyButtons() {
|
|
document.querySelectorAll(".copy-btn").forEach((btn) => {
|
|
btn.addEventListener("click", function () {
|
|
const targetID = btn.getAttribute("data-copy-target");
|
|
if (!targetID || !navigator.clipboard) {
|
|
return;
|
|
}
|
|
const el = document.getElementById(targetID);
|
|
if (!el) {
|
|
return;
|
|
}
|
|
const text = (el.textContent || "").trim();
|
|
navigator.clipboard.writeText(text).then(function () {
|
|
const original = btn.textContent;
|
|
btn.textContent = "Copied";
|
|
setTimeout(function () {
|
|
btn.textContent = original;
|
|
}, 900);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function setupActiveJobActions() {
|
|
activeJobs.addEventListener("click", function (evt) {
|
|
const target = evt.target;
|
|
if (!target || !target.classList || !target.classList.contains("cancel-job-btn")) {
|
|
return;
|
|
}
|
|
const jobID = target.getAttribute("data-job-id");
|
|
if (!jobID) {
|
|
return;
|
|
}
|
|
target.disabled = true;
|
|
target.textContent = "Canceling...";
|
|
fetch("/jobs/" + jobID + "/cancel", { method: "POST", credentials: "same-origin" })
|
|
.then(function (res) {
|
|
if (!res.ok) {
|
|
target.textContent = "Cancel failed";
|
|
return;
|
|
}
|
|
target.textContent = "Cancel sent";
|
|
})
|
|
.catch(function () {
|
|
target.textContent = "Cancel failed";
|
|
});
|
|
});
|
|
}
|
|
|
|
setupCopyButtons();
|
|
setupActiveJobActions();
|
|
setupSiteSelection();
|
|
connect();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>`,
|
|
html.EscapeString(data.User.Username),
|
|
html.EscapeString(role),
|
|
flash,
|
|
siteNav.String(),
|
|
checks.String(),
|
|
b2Init,
|
|
sites.String(),
|
|
html.EscapeString(data.ResticRepoPath),
|
|
html.EscapeString(data.ResticRepoSize),
|
|
resticSnaps.String(),
|
|
flows.String(),
|
|
))
|
|
return err
|
|
})
|
|
}
|
|
|
|
func formatFlash(code string) string {
|
|
switch code {
|
|
case "site-added":
|
|
return "Site added."
|
|
case "job-queued":
|
|
return "Backup job queued."
|
|
case "sync-queued":
|
|
return "Restic sync job queued."
|
|
case "job-cancel-requested":
|
|
return "Cancel requested for active job."
|
|
case "job-restarted":
|
|
return "Backup restart queued."
|
|
case "no-active-job":
|
|
return "No active job to cancel."
|
|
case "site-updated":
|
|
return "Site updated."
|
|
case "site-deleted":
|
|
return "Site deleted."
|
|
case "site-invalid":
|
|
return "SSH user, host, and at least one target path are required."
|
|
case "site-invalid-path":
|
|
return "Target paths must be absolute Linux paths starting with '/'."
|
|
case "site-invalid-port":
|
|
return "Port must be an integer between 1 and 65535."
|
|
case "password-updated":
|
|
return "Password updated."
|
|
case "mysql-added":
|
|
return "MySQL dump operation added."
|
|
case "mysql-invalid":
|
|
return "MySQL host, user, database, and password are required."
|
|
case "target-deleted":
|
|
return "Target removed."
|
|
case "target-not-found":
|
|
return "Target not found."
|
|
case "b2-init-ok":
|
|
return "B2 restic repository initialized."
|
|
case "b2-init-failed":
|
|
return "Failed to initialize B2 restic repository."
|
|
default:
|
|
return code
|
|
}
|
|
}
|
|
|
|
func targetModeLabel(mode string) string {
|
|
if mode == "sqlite_dump" {
|
|
return "sqlite dump"
|
|
}
|
|
if mode == "mysql_dump" {
|
|
return "mysql dump"
|
|
}
|
|
return "directory"
|
|
}
|
|
|
|
func joinTargetPaths(targets []store.SiteTarget, mode string) string {
|
|
var out []string
|
|
for _, t := range targets {
|
|
if t.Mode == mode {
|
|
out = append(out, t.Path)
|
|
}
|
|
}
|
|
return strings.Join(out, "\n")
|
|
}
|
|
|
|
func formatFilters(filters []string) string {
|
|
if len(filters) == 0 {
|
|
return "(none)"
|
|
}
|
|
return strings.Join(filters, ", ")
|
|
}
|
|
|
|
func targetModeClass(mode string) string {
|
|
if mode == "sqlite_dump" || mode == "mysql_dump" {
|
|
return "sqlite"
|
|
}
|
|
return "ok"
|
|
}
|
|
|
|
func formatBytes(v int64) string {
|
|
if v < 1024 {
|
|
return fmt.Sprintf("%d B", v)
|
|
}
|
|
units := []string{"KB", "MB", "GB", "TB"}
|
|
value := float64(v)
|
|
for _, u := range units {
|
|
value /= 1024
|
|
if value < 1024 {
|
|
return fmt.Sprintf("%.1f %s", value, u)
|
|
}
|
|
}
|
|
return fmt.Sprintf("%.1f PB", value/1024)
|
|
}
|
|
|
|
func timeUntilNextScan(now time.Time, lastScan sql.NullTime, interval time.Duration) string {
|
|
if !lastScan.Valid {
|
|
return "due now"
|
|
}
|
|
next := lastScan.Time.Add(interval)
|
|
if !next.After(now) {
|
|
return "due now"
|
|
}
|
|
d := next.Sub(now).Round(time.Minute)
|
|
h := int(d.Hours())
|
|
m := int(d.Minutes()) % 60
|
|
if h == 0 {
|
|
return fmt.Sprintf("%dm", m)
|
|
}
|
|
return fmt.Sprintf("%dh %dm", h, m)
|
|
}
|