Add backup history charts
This commit is contained in:
@@ -1 +0,0 @@
|
|||||||
36178615cdb1a303639d03ce5c211a3fb02ab889d5b47808b707cfe5e0c87db8 backupper-2026.06.14.3-x86_64-1.txz
|
|
||||||
BIN
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
ecf48010be750ee450981401f71398aa73a004090ecba122c83bbd4643febb76 backupper-2026.06.14.4-x86_64-1.txz
|
||||||
Vendored
+5
-2
@@ -2,13 +2,16 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "backupper">
|
<!ENTITY name "backupper">
|
||||||
<!ENTITY author "Backupper Project">
|
<!ENTITY author "Backupper Project">
|
||||||
<!ENTITY version "2026.06.14.3">
|
<!ENTITY version "2026.06.14.4">
|
||||||
<!ENTITY pluginURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg">
|
<!ENTITY pluginURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg">
|
||||||
<!ENTITY packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz">
|
<!ENTITY packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz">
|
||||||
<!ENTITY packageSHA256 "36178615cdb1a303639d03ce5c211a3fb02ab889d5b47808b707cfe5e0c87db8">
|
<!ENTITY packageSHA256 "ecf48010be750ee450981401f71398aa73a004090ecba122c83bbd4643febb76">
|
||||||
]>
|
]>
|
||||||
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues">
|
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues">
|
||||||
<CHANGES>
|
<CHANGES>
|
||||||
|
### 2026.06.14.4
|
||||||
|
- Add dashboard history charts for processed backup size and backed-up file count.
|
||||||
|
|
||||||
### 2026.06.14.3
|
### 2026.06.14.3
|
||||||
- Force a daemon restart after every plugin upgrade so new API endpoints become active immediately.
|
- Force a daemon restart after every plugin upgrade so new API endpoints become active immediately.
|
||||||
|
|
||||||
|
|||||||
+16
-14
@@ -111,20 +111,22 @@ type NotificationTarget struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Run struct {
|
type Run struct {
|
||||||
SchemaVersion int `json:"schemaVersion"`
|
SchemaVersion int `json:"schemaVersion"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
JobID string `json:"jobId,omitempty"`
|
JobID string `json:"jobId,omitempty"`
|
||||||
TaskType string `json:"taskType"`
|
TaskType string `json:"taskType"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Priority int `json:"priority"`
|
Priority int `json:"priority"`
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
StartedAt *time.Time `json:"startedAt,omitempty"`
|
StartedAt *time.Time `json:"startedAt,omitempty"`
|
||||||
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
FinishedAt *time.Time `json:"finishedAt,omitempty"`
|
||||||
SnapshotID string `json:"snapshotId,omitempty"`
|
SnapshotID string `json:"snapshotId,omitempty"`
|
||||||
Message string `json:"message,omitempty"`
|
Message string `json:"message,omitempty"`
|
||||||
ErrorCode string `json:"errorCode,omitempty"`
|
ErrorCode string `json:"errorCode,omitempty"`
|
||||||
BytesAdded int64 `json:"bytesAdded,omitempty"`
|
BytesAdded int64 `json:"bytesAdded,omitempty"`
|
||||||
FilesNew int64 `json:"filesNew,omitempty"`
|
FilesNew int64 `json:"filesNew,omitempty"`
|
||||||
|
BytesProcessed int64 `json:"bytesProcessed,omitempty"`
|
||||||
|
FilesProcessed int64 `json:"filesProcessed,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type RestoreTask struct {
|
type RestoreTask struct {
|
||||||
|
|||||||
@@ -26,10 +26,12 @@ type Runner struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Summary struct {
|
type Summary struct {
|
||||||
MessageType string `json:"message_type"`
|
MessageType string `json:"message_type"`
|
||||||
SnapshotID string `json:"snapshot_id"`
|
SnapshotID string `json:"snapshot_id"`
|
||||||
DataAdded int64 `json:"data_added"`
|
DataAdded int64 `json:"data_added"`
|
||||||
FilesNew int64 `json:"files_new"`
|
FilesNew int64 `json:"files_new"`
|
||||||
|
TotalBytesProcessed int64 `json:"total_bytes_processed"`
|
||||||
|
TotalFilesProcessed int64 `json:"total_files_processed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Runner) Init(ctx context.Context, repo model.Repository) error {
|
func (r *Runner) Init(ctx context.Context, repo model.Repository) error {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ func TestBackupUsesPasswordFileAndStructuredArguments(t *testing.T) {
|
|||||||
argsPath := filepath.Join(dir, "args")
|
argsPath := filepath.Join(dir, "args")
|
||||||
passwordPath := filepath.Join(dir, "password")
|
passwordPath := filepath.Join(dir, "password")
|
||||||
script := filepath.Join(dir, "restic")
|
script := filepath.Join(dir, "restic")
|
||||||
body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\ncat \"$RESTIC_PASSWORD_FILE\" > '%s'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"abc123\",\"data_added\":42,\"files_new\":3}'\n", argsPath, passwordPath)
|
body := fmt.Sprintf("#!/bin/sh\nprintf '%%s\\n' \"$@\" > '%s'\ncat \"$RESTIC_PASSWORD_FILE\" > '%s'\nprintf '%%s\\n' '{\"message_type\":\"summary\",\"snapshot_id\":\"abc123\",\"data_added\":42,\"files_new\":3,\"total_bytes_processed\":1024,\"total_files_processed\":12}'\n", argsPath, passwordPath)
|
||||||
if err := os.WriteFile(script, []byte(body), 0700); err != nil {
|
if err := os.WriteFile(script, []byte(body), 0700); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -31,7 +31,7 @@ func TestBackupUsesPasswordFileAndStructuredArguments(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if summary.SnapshotID != "abc123" || summary.DataAdded != 42 {
|
if summary.SnapshotID != "abc123" || summary.DataAdded != 42 || summary.TotalBytesProcessed != 1024 || summary.TotalFilesProcessed != 12 {
|
||||||
t.Fatalf("unexpected summary: %+v", summary)
|
t.Fatalf("unexpected summary: %+v", summary)
|
||||||
}
|
}
|
||||||
args, _ := os.ReadFile(argsPath)
|
args, _ := os.ReadFile(argsPath)
|
||||||
|
|||||||
@@ -282,6 +282,7 @@ func (s *Service) executeBackup(ctx context.Context, run model.Run) (result mode
|
|||||||
run.Status, run.Message = "success", "backup completed"
|
run.Status, run.Message = "success", "backup completed"
|
||||||
}
|
}
|
||||||
run.SnapshotID, run.BytesAdded, run.FilesNew = summary.SnapshotID, summary.DataAdded, summary.FilesNew
|
run.SnapshotID, run.BytesAdded, run.FilesNew = summary.SnapshotID, summary.DataAdded, summary.FilesNew
|
||||||
|
run.BytesProcessed, run.FilesProcessed = summary.TotalBytesProcessed, summary.TotalFilesProcessed
|
||||||
return run
|
return run
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,16 @@
|
|||||||
<!DOCTYPE PLUGIN [
|
<!DOCTYPE PLUGIN [
|
||||||
<!ENTITY name "backupper">
|
<!ENTITY name "backupper">
|
||||||
<!ENTITY author "Backupper Project">
|
<!ENTITY author "Backupper Project">
|
||||||
<!ENTITY version "2026.06.14.3">
|
<!ENTITY version "2026.06.14.4">
|
||||||
<!ENTITY pluginURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg">
|
<!ENTITY pluginURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper.plg">
|
||||||
<!ENTITY packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz">
|
<!ENTITY packageURL "https://git.casaderoll.de/michael/BackUpper/raw/branch/main/dist/backupper-&version;-x86_64-1.txz">
|
||||||
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE">
|
<!ENTITY packageSHA256 "REPLACE_DURING_RELEASE">
|
||||||
]>
|
]>
|
||||||
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues">
|
<PLUGIN name="&name;" author="&author;" version="&version;" pluginURL="&pluginURL;" min="7.0.0" support="https://git.casaderoll.de/michael/BackUpper/issues">
|
||||||
<CHANGES>
|
<CHANGES>
|
||||||
|
### 2026.06.14.4
|
||||||
|
- Add dashboard history charts for processed backup size and backed-up file count.
|
||||||
|
|
||||||
### 2026.06.14.3
|
### 2026.06.14.3
|
||||||
- Force a daemon restart after every plugin upgrade so new API endpoints become active immediately.
|
- Force a daemon restart after every plugin upgrade so new API endpoints become active immediately.
|
||||||
|
|
||||||
|
|||||||
@@ -9,10 +9,10 @@ Tag="backup restic snapshots restore"
|
|||||||
<?php
|
<?php
|
||||||
$pluginRoot = '/plugins/backupper';
|
$pluginRoot = '/plugins/backupper';
|
||||||
?>
|
?>
|
||||||
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/backupper.css?v=202606143">
|
<link rel="stylesheet" href="<?= $pluginRoot ?>/assets/backupper.css?v=202606144">
|
||||||
<div id="backupper-app" data-api="<?= $pluginRoot ?>/api.php">
|
<div id="backupper-app" data-api="<?= $pluginRoot ?>/api.php">
|
||||||
<header class="bu-header">
|
<header class="bu-header">
|
||||||
<div><h1>Backupper <span class="bu-version">2026.06.14.3</span></h1><p>Encrypted Restic backups for Unraid</p></div>
|
<div><h1>Backupper <span class="bu-version">2026.06.14.4</span></h1><p>Encrypted Restic backups for Unraid</p></div>
|
||||||
<div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die Backupper-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Connecting...</div>
|
<div id="bu-health" class="bu-health" tabindex="0" data-tooltip="Zeigt, ob die Backupper-Hintergrundkomponente erreichbar ist. Beispiel: 'Daemon online' bedeutet, dass Jobs gestartet werden können.">Connecting...</div>
|
||||||
</header>
|
</header>
|
||||||
<nav class="bu-tabs" aria-label="Backupper sections">
|
<nav class="bu-tabs" aria-label="Backupper sections">
|
||||||
@@ -29,4 +29,4 @@ $pluginRoot = '/plugins/backupper';
|
|||||||
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
|
<div id="bu-tooltip" role="tooltip" aria-hidden="true"></div>
|
||||||
</div>
|
</div>
|
||||||
<script>window.BACKUPPER_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
|
<script>window.BACKUPPER_CSRF = <?= json_encode($var['csrf_token'] ?? '') ?>;</script>
|
||||||
<script src="<?= $pluginRoot ?>/assets/backupper-2026.06.14.3.js"></script>
|
<script src="<?= $pluginRoot ?>/assets/backupper-2026.06.14.4.js"></script>
|
||||||
|
|||||||
@@ -54,9 +54,17 @@
|
|||||||
#backupper-app .bu-tabs button.active { border-bottom-color: var(--bu-accent) !important; color: var(--bu-text) !important; opacity: 1; }
|
#backupper-app .bu-tabs button.active { border-bottom-color: var(--bu-accent) !important; color: var(--bu-text) !important; opacity: 1; }
|
||||||
|
|
||||||
.bu-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); }
|
.bu-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); }
|
||||||
|
.bu-chart-grid-layout { display: grid; gap: 16px; grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 16px; }
|
||||||
.bu-card { background: var(--bu-panel); border: 1px solid var(--bu-border); border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.12); color: var(--bu-text); padding: 18px; }
|
.bu-card { background: var(--bu-panel); border: 1px solid var(--bu-border); border-radius: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.12); color: var(--bu-text); padding: 18px; }
|
||||||
#backupper-app .bu-card h2, #backupper-app .bu-card h3 { color: var(--bu-text) !important; font-weight: 700; margin-top: 0; }
|
#backupper-app .bu-card h2, #backupper-app .bu-card h3 { color: var(--bu-text) !important; font-weight: 700; margin-top: 0; }
|
||||||
.bu-stat { color: var(--bu-text); font-size: 32px; font-weight: 700; }
|
.bu-stat { color: var(--bu-text); font-size: 32px; font-weight: 700; }
|
||||||
|
.bu-chart { display: block; height: 240px; margin-top: 12px; overflow: visible; width: 100%; }
|
||||||
|
.bu-chart-grid line { stroke: var(--bu-border); stroke-width: 1; }
|
||||||
|
.bu-chart-grid text, .bu-chart-date { fill: var(--bu-muted); font-size: 11px; }
|
||||||
|
.bu-chart-area { fill: var(--bu-accent); opacity: .10; }
|
||||||
|
.bu-chart-line { fill: none; stroke: var(--bu-accent); stroke-linecap: round; stroke-linejoin: round; stroke-width: 3; }
|
||||||
|
.bu-chart-dots circle { fill: var(--bu-panel); stroke: var(--bu-accent); stroke-width: 3; }
|
||||||
|
.bu-chart-empty { align-items: center; color: var(--bu-muted); display: flex; height: 240px; justify-content: center; text-align: center; }
|
||||||
#backupper-app .bu-muted { color: var(--bu-muted) !important; opacity: .72; }
|
#backupper-app .bu-muted { color: var(--bu-muted) !important; opacity: .72; }
|
||||||
.bu-toolbar { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-bottom: 14px; }
|
.bu-toolbar { align-items: center; display: flex; flex-wrap: wrap; gap: 8px; justify-content: space-between; margin-bottom: 14px; }
|
||||||
.bu-actions { display: flex; flex-wrap: wrap; gap: 7px; }
|
.bu-actions { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||||
@@ -173,6 +181,7 @@
|
|||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
#backupper-app { padding-left: 8px; padding-right: 8px; }
|
#backupper-app { padding-left: 8px; padding-right: 8px; }
|
||||||
.bu-form { grid-template-columns: 1fr; }
|
.bu-form { grid-template-columns: 1fr; }
|
||||||
|
.bu-chart-grid-layout { grid-template-columns: 1fr; }
|
||||||
.bu-header { align-items: flex-start; flex-direction: column; gap: 12px; }
|
.bu-header { align-items: flex-start; flex-direction: column; gap: 12px; }
|
||||||
.bu-table { display: block; overflow-x: auto; }
|
.bu-table { display: block; overflow-x: auto; }
|
||||||
.bu-table-actions { min-width: 240px; }
|
.bu-table-actions { min-width: 240px; }
|
||||||
|
|||||||
@@ -110,6 +110,26 @@
|
|||||||
const button = (label, action, kind = '') => `<button class="bu-button ${kind}" type="button" data-action="${esc(action)}"${tipAttr(actionTooltip(action, label))}>${esc(label)}</button>`;
|
const button = (label, action, kind = '') => `<button class="bu-button ${kind}" type="button" data-action="${esc(action)}"${tipAttr(actionTooltip(action, label))}>${esc(label)}</button>`;
|
||||||
const disabledButton = (label, help) => `<button class="bu-button" type="button" disabled${tipAttr(help)}>${esc(label)}</button>`;
|
const disabledButton = (label, help) => `<button class="bu-button" type="button" disabled${tipAttr(help)}>${esc(label)}</button>`;
|
||||||
const actionGroup = (buttons) => `<div class="bu-actions bu-table-actions">${buttons}</div>`;
|
const actionGroup = (buttons) => `<div class="bu-actions bu-table-actions">${buttons}</div>`;
|
||||||
|
const formatBytes = (value) => {
|
||||||
|
const units = ['B','KiB','MiB','GiB','TiB']; let size = Number(value)||0; let unit = 0;
|
||||||
|
while (size >= 1024 && unit < units.length-1) { size /= 1024; unit++; }
|
||||||
|
return `${size >= 10 || unit === 0 ? size.toFixed(0) : size.toFixed(1)} ${units[unit]}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
function lineChart(runs, field, formatter, emptyText) {
|
||||||
|
const points = runs.map(run=>({run,value:Number(run[field] || (field==='bytesProcessed'?run.bytesAdded:run.filesNew) || 0)}));
|
||||||
|
if (!points.length || points.every(point=>point.value===0)) return `<div class="bu-chart-empty">${esc(emptyText)}</div>`;
|
||||||
|
const width=720, height=220, left=54, right=18, top=18, bottom=38, innerW=width-left-right, innerH=height-top-bottom;
|
||||||
|
const max=Math.max(...points.map(point=>point.value),1);
|
||||||
|
const coords=points.map((point,index)=>({ ...point, x:left+(points.length===1?innerW/2:index*innerW/(points.length-1)), y:top+innerH-(point.value/max)*innerH }));
|
||||||
|
const polyline=coords.map(point=>`${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' ');
|
||||||
|
const area=`${left},${top+innerH} ${polyline} ${left+innerW},${top+innerH}`;
|
||||||
|
const grid=[0,.25,.5,.75,1].map(ratio=>{const y=top+innerH-ratio*innerH; return `<line x1="${left}" y1="${y}" x2="${left+innerW}" y2="${y}"/><text x="${left-8}" y="${y+4}" text-anchor="end">${esc(formatter(max*ratio))}</text>`;}).join('');
|
||||||
|
const dots=coords.map(point=>{const job=state.config.jobs.find(item=>item.id===point.run.jobId)?.name||point.run.jobId; const date=new Date(point.run.finishedAt||point.run.createdAt).toLocaleString(); return `<circle cx="${point.x}" cy="${point.y}" r="4"><title>${esc(`${date} · ${job} · ${formatter(point.value)}`)}</title></circle>`;}).join('');
|
||||||
|
const first=new Date(points[0].run.finishedAt||points[0].run.createdAt).toLocaleDateString();
|
||||||
|
const last=new Date(points.at(-1).run.finishedAt||points.at(-1).run.createdAt).toLocaleDateString();
|
||||||
|
return `<svg class="bu-chart" viewBox="0 0 ${width} ${height}" role="img"><g class="bu-chart-grid">${grid}</g><polygon class="bu-chart-area" points="${area}"/><polyline class="bu-chart-line" points="${polyline}"/><g class="bu-chart-dots">${dots}</g><text class="bu-chart-date" x="${left}" y="${height-8}">${esc(first)}</text><text class="bu-chart-date" x="${left+innerW}" y="${height-8}" text-anchor="end">${esc(last)}</text></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
function setTooltip(element, text) {
|
function setTooltip(element, text) {
|
||||||
if (!element || !text || element.dataset.tooltip) return;
|
if (!element || !text || element.dataset.tooltip) return;
|
||||||
@@ -203,11 +223,15 @@
|
|||||||
const active = state.runs.filter(r => ['queued','running'].includes(r.status)).length;
|
const active = state.runs.filter(r => ['queued','running'].includes(r.status)).length;
|
||||||
const failures = state.runs.filter(r => r.status === 'failed').slice(-5);
|
const failures = state.runs.filter(r => r.status === 'failed').slice(-5);
|
||||||
const last = [...state.runs].reverse().slice(0, 8);
|
const last = [...state.runs].reverse().slice(0, 8);
|
||||||
|
const backups = state.runs.filter(r=>r.taskType==='backup' && ['success','warning'].includes(r.status)).slice(-20);
|
||||||
return `<div class="bu-grid">
|
return `<div class="bu-grid">
|
||||||
<section class="bu-card"><div class="bu-muted">Enabled jobs</div><div class="bu-stat">${state.config.jobs.filter(j => j.enabled).length}</div></section>
|
<section class="bu-card"><div class="bu-muted">Enabled jobs</div><div class="bu-stat">${state.config.jobs.filter(j => j.enabled).length}</div></section>
|
||||||
<section class="bu-card"><div class="bu-muted">Repositories</div><div class="bu-stat">${state.config.repositories.length}</div></section>
|
<section class="bu-card"><div class="bu-muted">Repositories</div><div class="bu-stat">${state.config.repositories.length}</div></section>
|
||||||
<section class="bu-card"><div class="bu-muted">Queued / running</div><div class="bu-stat">${active}</div></section>
|
<section class="bu-card"><div class="bu-muted">Queued / running</div><div class="bu-stat">${active}</div></section>
|
||||||
<section class="bu-card"><div class="bu-muted">Recent failures</div><div class="bu-stat">${failures.length}</div></section>
|
<section class="bu-card"><div class="bu-muted">Recent failures</div><div class="bu-stat">${failures.length}</div></section>
|
||||||
|
</div><div class="bu-chart-grid-layout">
|
||||||
|
<section class="bu-card"><h2>Backup size</h2><div class="bu-muted">Total source data processed per backup</div>${lineChart(backups,'bytesProcessed',formatBytes,'Run a backup to collect size history.')}</section>
|
||||||
|
<section class="bu-card"><h2>Files backed up</h2><div class="bu-muted">Total files processed per backup</div>${lineChart(backups,'filesProcessed',value=>Math.round(value).toLocaleString(),'Run a backup to collect file history.')}</section>
|
||||||
</div><section class="bu-card" style="margin-top:16px"><h2>Recent activity</h2>${runsTable(last)}</section>`;
|
</div><section class="bu-card" style="margin-top:16px"><h2>Recent activity</h2>${runsTable(last)}</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user