backup utility working
This commit is contained in:
parent
2ce2d5d881
commit
a8d3bc66ad
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,55 +251,81 @@ 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">
|
||||||
<ul class="status-list">%s</ul>
|
<h2>Sites Health</h2>
|
||||||
</section>
|
<p class="muted" id="live-connection">Connecting...</p>
|
||||||
<section>
|
<div class="site-nav-list">%s</div>
|
||||||
<h2>Planned Workflow</h2>
|
<h2>Runtime Checks</h2>
|
||||||
<ol class="workflow">%s</ol>
|
<ul class="status-list">%s</ul>
|
||||||
</section>
|
</aside>
|
||||||
<section>
|
<section class="site-main-panel">
|
||||||
<h2>Add Site</h2>
|
<section>
|
||||||
<form class="grid-2" method="post" action="/sites">
|
<h2>Add Site</h2>
|
||||||
<label class="stack"><span>SSH User</span><input name="ssh_user" required /></label>
|
<form class="grid-2" method="post" action="/sites">
|
||||||
<label class="stack"><span>Server</span><input name="host" placeholder="host or ip" required /></label>
|
<label class="stack"><span>SSH User</span><input name="ssh_user" 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>Server</span><input name="host" placeholder="host or ip" 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>Port</span><input type="number" name="port" min="1" max="65535" value="22" required /></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>Directory paths (one per line)</span><textarea name="directory_paths" placeholder="/var/www /etc/nginx" 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>
|
<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>
|
||||||
<button class="button" type="submit">Add site</button>
|
<label class="stack"><span>Exclude filters (one per line)</span><textarea name="filters" placeholder="*.tmp node_modules" rows="4"></textarea></label>
|
||||||
</form>
|
<button class="button" type="submit">Add site</button>
|
||||||
</section>
|
</form>
|
||||||
<section>
|
</section>
|
||||||
<h2>Live Backup Progress</h2>
|
<section>
|
||||||
<p class="muted" id="live-connection">Connecting...</p>
|
<h2 id="active-site-title">Active Site</h2>
|
||||||
<ul class="target-list" id="live-active-jobs">
|
%s
|
||||||
<li class="muted">Waiting for job updates.</li>
|
</section>
|
||||||
</ul>
|
<section>
|
||||||
<div class="row">
|
<h2>Active Site Logs</h2>
|
||||||
<button class="button ghost button-sm copy-btn" type="button" data-copy-target="live-events">Copy live events</button>
|
<ul class="target-list" id="live-active-jobs">
|
||||||
</div>
|
<li class="muted">Waiting for job updates.</li>
|
||||||
<pre class="output" id="live-events">No live events yet.</pre>
|
</ul>
|
||||||
</section>
|
<div class="row">
|
||||||
<section>
|
<button class="button ghost button-sm copy-btn" type="button" data-copy-target="live-events">Copy active logs</button>
|
||||||
<h2>Managed Sites</h2>
|
</div>
|
||||||
%s
|
<pre class="output" id="live-events">No live events yet.</pre>
|
||||||
</section>
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Planned Workflow</h2>
|
||||||
|
<ol class="workflow">%s</ol>
|
||||||
|
</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
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue