Merge claude/2
This commit is contained in:
commit
b38da353a6
@ -1197,6 +1197,42 @@ table.sys-apps tr:hover td { background: rgba(var(--text-rgb), 0.03); }
|
||||
background: rgba(var(--status-warning-rgb), 0.18);
|
||||
color: var(--status-warning);
|
||||
}
|
||||
|
||||
/* Tasks-style list container (Images + Storage by app): a recessed dark panel
|
||||
behind the rows. */
|
||||
.sys-tasklist {
|
||||
margin-top: 10px;
|
||||
padding: 8px;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border: 1px solid rgba(var(--text-rgb), 0.08);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
/* App / image icon inside a task row. */
|
||||
.sys-task-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
/* Clear All / Select all now live in the section head — no extra top margin. */
|
||||
.sys-images-head .sys-images-toolbar { margin: 0; }
|
||||
/* Donut-colour key dot in a Storage-by-app row. */
|
||||
.task-info .sys-storage-swatch {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
/* Storage-by-app rows reuse .task-item; the folder list drops in below. */
|
||||
.sys-storage-app-item.is-clickable .task-header { cursor: pointer; }
|
||||
.sys-storage-app-item .sys-storage-folders { padding: 4px 12px 8px 30px; }
|
||||
.sys-storage-app-item .task-actions .sys-storage-caret { margin-right: 0; }
|
||||
|
||||
.sys-storage-card-meta {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
|
||||
@ -101,15 +101,16 @@ class AdminSystem {
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
const [metrics, history, apps, appsHist, info, storage] = await Promise.all([
|
||||
const [metrics, history, apps, appsHist, info, storage, appStorage] = await Promise.all([
|
||||
this.fetchJson('/data/system/metrics.json'),
|
||||
this.fetchJson(`/api/system/history?range=${this.range}`),
|
||||
this.fetchJson('/data/system/metrics_apps.json'),
|
||||
this.fetchJson('/data/system/metrics_apps_history.json'),
|
||||
this.fetchJson('/data/system/system_info.json'),
|
||||
this.fetchJson('/api/system/storage')
|
||||
this.fetchJson('/api/system/storage'),
|
||||
this.fetchJson('/data/system/app_storage.json')
|
||||
]);
|
||||
this.d = { metrics, history, apps, appsHist, info, storage };
|
||||
this.d = { metrics, history, apps, appsHist, info, storage, appStorage };
|
||||
this.render();
|
||||
}
|
||||
|
||||
@ -217,10 +218,19 @@ class AdminSystem {
|
||||
: loadRatio >= 1.0 ? 'status-warning'
|
||||
: 'status-success';
|
||||
|
||||
// Disk gauge gets a second inner ring for the slice of the disk that's
|
||||
// LibrePortal (app data on disk + Docker images/cache), so it shows both
|
||||
// total disk used (outer) and how much of that is us (inner).
|
||||
const lpBytes = ((this.d.appStorage && this.d.appStorage.total_local) || 0)
|
||||
+ ((this.d.storage && this.d.storage.total) || 0);
|
||||
const diskTotal = Number(rootDisk.total) || 0;
|
||||
const lpPct = diskTotal > 0 ? (lpBytes / diskTotal) * 100 : 0;
|
||||
const diskSub = lpBytes > 0 ? `LibrePortal ${this.bytes(lpBytes)}` : (rootDisk.mount || '/');
|
||||
|
||||
return `
|
||||
${wrap('cpu', C.gauge(cpu.percent || 0, { label: 'CPU', sublabel: `${cpu.cores || '?'} cores` }))}
|
||||
${wrap('mem', C.gauge(mem.percent || 0, { label: 'Memory', sublabel: `${this.bytes(mem.used)} / ${this.bytes(mem.total)}` }))}
|
||||
${wrap('disk', C.gauge(rootDisk.percent || 0, { label: 'Disk', sublabel: rootDisk.mount || '/' }))}
|
||||
${wrap('disk', C.gauge(rootDisk.percent || 0, { label: 'Disk', sublabel: diskSub, inner: { value: lpPct, color: 'accent' } }))}
|
||||
${wrap('load1', C.gauge(load1, { label: 'Load', display: load1.toFixed(2), suffix: '', max: cores * 2, color: loadColor, sublabel: `1m · ${cpu.load5 ?? '–'}/${cpu.load15 ?? '–'}` }))}`;
|
||||
}
|
||||
|
||||
@ -347,17 +357,20 @@ class AdminSystem {
|
||||
</div>`;
|
||||
}
|
||||
const C = window.LPCharts;
|
||||
const segments = SP.segmentsFrom(s);
|
||||
const donut = SP.donutSvg(segments, s.total);
|
||||
// Full LibrePortal breakdown: apps + Docker images/cache, not just the
|
||||
// engine categories — same story the Storage page tells.
|
||||
const segments = SP.unifiedSegments(this.d.appStorage, s);
|
||||
const grandTotal = segments.reduce((t, seg) => t + ((seg.data && seg.data.size) || 0), 0);
|
||||
const donut = SP.donutSvg(segments, grandTotal, 'in use');
|
||||
const recl = s.reclaimable || 0;
|
||||
const reclPct = s.total ? Math.round((recl / s.total) * 100) : 0;
|
||||
const rows = segments.map(seg => {
|
||||
const sz = (seg.data && seg.data.size) || 0;
|
||||
const pct = s.total ? (sz / s.total) * 100 : 0;
|
||||
const pct = grandTotal ? (sz / grandTotal) * 100 : 0;
|
||||
return `
|
||||
<div class="sys-storage-srow">
|
||||
<span class="sys-storage-swatch" style="background: var(--${seg.color})"></span>
|
||||
<span class="sys-storage-srow-k">${seg.label}</span>
|
||||
<span class="sys-storage-srow-k">${this.escape(seg.label)}</span>
|
||||
<span class="sys-storage-srow-bar">${C.bar(pct, { color: seg.color })}</span>
|
||||
<span class="sys-storage-srow-v">${this.bytes(sz)}</span>
|
||||
</div>`;
|
||||
|
||||
@ -91,12 +91,25 @@ const LPCharts = (() => {
|
||||
vector-effect="non-scaling-stroke" stroke-linejoin="round"/></svg>`;
|
||||
}
|
||||
|
||||
// Circular ring gauge. value 0..max. opts: { label, color, sublabel, max }.
|
||||
// Circular ring gauge. value 0..max. opts: { label, color, sublabel, max,
|
||||
// inner }. inner = { value, color } draws a second, smaller ring inside the
|
||||
// main one (e.g. "of the disk used, this much is LibrePortal").
|
||||
function gauge(value, opts = {}) {
|
||||
const max = opts.max || 100;
|
||||
const pct = Math.max(0, Math.min(1, (value || 0) / max));
|
||||
const color = palette(opts.color || pickColor(pct));
|
||||
const r = 52, C = 2 * Math.PI * r, off = C * (1 - pct);
|
||||
let innerSvg = '';
|
||||
if (opts.inner) {
|
||||
const ip = Math.max(0, Math.min(1, (opts.inner.value || 0) / max));
|
||||
const icolor = palette(opts.inner.color || 'accent');
|
||||
const ir = 38, IC = 2 * Math.PI * ir, ioff = IC * (1 - ip);
|
||||
innerSvg = `
|
||||
<circle cx="60" cy="60" r="${ir}" fill="none" stroke="rgba(var(--text-rgb),0.10)" stroke-width="7"/>
|
||||
<circle cx="60" cy="60" r="${ir}" fill="none" stroke="${icolor.line}" stroke-width="7"
|
||||
stroke-linecap="round" stroke-dasharray="${IC.toFixed(1)}" stroke-dashoffset="${ioff.toFixed(1)}"
|
||||
transform="rotate(-90 60 60)" style="transition:stroke-dashoffset .5s ease"/>`;
|
||||
}
|
||||
return `
|
||||
<div class="lp-gauge">
|
||||
<svg viewBox="0 0 120 120" class="lp-gauge-svg">
|
||||
@ -104,6 +117,7 @@ const LPCharts = (() => {
|
||||
<circle cx="60" cy="60" r="${r}" fill="none" stroke="${color.line}" stroke-width="10"
|
||||
stroke-linecap="round" stroke-dasharray="${C.toFixed(1)}" stroke-dashoffset="${off.toFixed(1)}"
|
||||
transform="rotate(-90 60 60)" style="transition:stroke-dashoffset .5s ease"/>
|
||||
${innerSvg}
|
||||
</svg>
|
||||
<div class="lp-gauge-center">
|
||||
<div class="lp-gauge-value">${opts.display !== undefined ? opts.display : Math.round(value)}<span>${opts.suffix || '%'}</span></div>
|
||||
|
||||
@ -171,13 +171,30 @@ class SystemStoragePage {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Docker engine categories. Warm colours = reclaimable overhead: Images get
|
||||
// the same orange as the Reclaim action, build cache the deeper red.
|
||||
static segmentsFrom(d) {
|
||||
return [
|
||||
{ key: 'images', label: 'Images', color: 'accent', data: (d && d.images) || {} },
|
||||
{ key: 'build_cache', label: 'Build cache', color: 'status-warning', data: (d && d.build_cache) || {} },
|
||||
{ key: 'images', label: 'Images', color: 'status-warning', data: (d && d.images) || {} },
|
||||
{ key: 'build_cache', label: 'Build cache', color: 'status-danger', data: (d && d.build_cache) || {} },
|
||||
];
|
||||
}
|
||||
|
||||
// Cool colours for apps (your data), so they read as distinct from the warm
|
||||
// Docker engine slices.
|
||||
static get APP_PALETTE() { return ['accent', 'status-info', 'status-success']; }
|
||||
|
||||
// The full LibrePortal storage breakdown: a slice per app followed by the
|
||||
// Docker engine categories. Shared by this page's donut and the System
|
||||
// page's storage summary so both tell the same story.
|
||||
static unifiedSegments(appStorage, dockerData) {
|
||||
const pal = SystemStoragePage.APP_PALETTE;
|
||||
const apps = (appStorage && Array.isArray(appStorage.apps)) ? appStorage.apps : [];
|
||||
const appSegs = apps.map((a, i) => ({ key: 'app:' + a.app, label: a.app, color: pal[i % pal.length], data: { size: a.bytes || 0 }, kind: 'app' }));
|
||||
const docker = dockerData ? SystemStoragePage.segmentsFrom(dockerData).map(s => ({ ...s, kind: 'docker' })) : [];
|
||||
return [...appSegs, ...docker];
|
||||
}
|
||||
|
||||
// Hand-rolled donut, full circle = total. Each slice's dashoffset is the
|
||||
// running cumulative fraction (off), so a slice starts where the previous
|
||||
// one ended and the ring fills proportionally.
|
||||
@ -210,10 +227,6 @@ class SystemStoragePage {
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
// Distinct slice colours for the per-app donut, cycled if there are more
|
||||
// apps than colours.
|
||||
static get PALETTE() { return ['accent', 'status-info', 'status-success', 'status-warning', 'status-danger']; }
|
||||
|
||||
_render() {
|
||||
const r = this.root();
|
||||
if (!r) return;
|
||||
@ -227,17 +240,14 @@ class SystemStoragePage {
|
||||
body.innerHTML = `<div class="sys-storage-err">Couldn't read storage usage.</div>`;
|
||||
return;
|
||||
}
|
||||
const palette = SystemStoragePage.PALETTE;
|
||||
const colorFor = i => palette[i % palette.length];
|
||||
const APP_PAL = SystemStoragePage.APP_PALETTE;
|
||||
const appColorFor = i => APP_PAL[i % APP_PAL.length];
|
||||
|
||||
// ---- Headline: ONE donut covering everything — a slice per app, then
|
||||
// Docker's own categories (images, build cache). Colours run continuously
|
||||
// across the whole list so app and Docker slices stay distinct, and the
|
||||
// legend lists them all. This is the unified "where's my disk going" view.
|
||||
// Docker's own categories (images, build cache). The legend lists them
|
||||
// all. This is the unified "where's my disk going" view.
|
||||
const appTotal = (as && as.total) || 0;
|
||||
const appSegs = appRows.map((a, i) => ({ label: a.app, color: colorFor(i), data: { size: a.bytes || 0 } }));
|
||||
const dockerCats = d ? SystemStoragePage.segmentsFrom(d).map((s, j) => ({ ...s, color: colorFor(appSegs.length + j) })) : [];
|
||||
const allSegs = [...appSegs, ...dockerCats];
|
||||
const allSegs = SystemStoragePage.unifiedSegments(as, d);
|
||||
const grandTotal = allSegs.reduce((t, s) => t + ((s.data && s.data.size) || 0), 0);
|
||||
const legend = `
|
||||
<ul class="sys-storage-legend">
|
||||
@ -270,39 +280,38 @@ class SystemStoragePage {
|
||||
</div>`
|
||||
: `<div class="sys-storage-app-empty">${as ? 'No app data found on disk.' : 'Measuring on-disk usage… (refreshes within a few minutes)'}</div>`;
|
||||
|
||||
// ---- Storage by app — the donut's legend + per-folder drill-down.
|
||||
// Swatch + bar colours match each app's donut slice.
|
||||
const appMax = appRows.reduce((m, a) => Math.max(m, a.bytes || 0), 0);
|
||||
const hasExternal = appRows.some(a => a.external_bytes > 0);
|
||||
const cols = hasExternal ? 4 : 3;
|
||||
// ---- Storage by app — same Tasks-style list as Images (no selection).
|
||||
// A coloured dot keys the row to its donut slice; the app icon + name
|
||||
// identify it; clicking a row expands its per-folder breakdown.
|
||||
const appsSection = appRows.length ? `
|
||||
<div class="sys-section-head"><h2>Storage by app</h2><span class="sys-chart-meta">on-disk data per app — click an app to see its folders</span></div>
|
||||
<div class="sys-apps-wrap">
|
||||
<table class="sys-apps">
|
||||
<thead><tr><th>App</th><th>Size</th><th class="sys-storage-app-barcol"></th>${hasExternal ? '<th>External</th>' : ''}</tr></thead>
|
||||
<tbody>
|
||||
${appRows.map((a, i) => {
|
||||
const pct = appMax ? ((a.bytes || 0) / appMax) * 100 : 0;
|
||||
const mounts = Array.isArray(a.mounts) ? a.mounts : [];
|
||||
const open = this._expanded.has(a.app);
|
||||
const folders = mounts.map(m => `
|
||||
<li>
|
||||
<span class="sys-storage-folder-path" title="${fmt.escape(m.source || '')}">${fmt.escape(m.path || m.source || '—')}</span>
|
||||
${m.external ? '<span class="sys-storage-ext-badge">external</span>' : ''}
|
||||
<span class="sys-storage-folder-size">${fmt.bytes(m.bytes || 0)}</span>
|
||||
</li>`).join('');
|
||||
return `<tr class="sys-storage-app-row${mounts.length ? ' is-clickable' : ''}"${mounts.length ? ` data-app-toggle="${fmt.escape(a.app)}"` : ''}>
|
||||
<td class="sys-app-name"><span class="sys-storage-swatch" style="background: var(--${colorFor(i)})"></span>${mounts.length ? `<span class="sys-storage-caret${open ? ' is-open' : ''}">▸</span>` : ''}${fmt.escape(a.app)}</td>
|
||||
<td>${fmt.bytes(a.bytes || 0)}</td>
|
||||
<td class="sys-storage-app-barcol"><span class="sys-storage-app-bar"><span style="width:${pct.toFixed(1)}%; background: var(--${colorFor(i)})"></span></span></td>
|
||||
${hasExternal ? `<td>${a.external_bytes ? fmt.bytes(a.external_bytes) : '—'}</td>` : ''}
|
||||
</tr>${mounts.length ? `
|
||||
<tr class="sys-storage-app-detail${open ? '' : ' is-collapsed'}" data-app-detail="${fmt.escape(a.app)}">
|
||||
<td colspan="${cols}"><ul class="sys-storage-folders">${folders}</ul></td>
|
||||
</tr>` : ''}`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="sys-tasklist sys-storage-app-list">
|
||||
${appRows.map((a, i) => {
|
||||
const mounts = Array.isArray(a.mounts) ? a.mounts : [];
|
||||
const open = this._expanded.has(a.app);
|
||||
const folders = mounts.map(m => `
|
||||
<li>
|
||||
<span class="sys-storage-folder-path" title="${fmt.escape(m.source || '')}">${fmt.escape(m.path || m.source || '—')}</span>
|
||||
${m.external ? '<span class="sys-storage-ext-badge">external</span>' : ''}
|
||||
<span class="sys-storage-folder-size">${fmt.bytes(m.bytes || 0)}</span>
|
||||
</li>`).join('');
|
||||
return `
|
||||
<div class="task-item sys-storage-app-item${mounts.length ? ' is-clickable' : ''}"${mounts.length ? ` data-app-toggle="${fmt.escape(a.app)}"` : ''}>
|
||||
<div class="task-header">
|
||||
<div class="task-info">
|
||||
<span class="sys-storage-swatch" style="background: var(--${appColorFor(i)})"></span>
|
||||
${this._appIconHtml(a.app)}
|
||||
<span class="task-title">${fmt.escape(a.app)}</span>
|
||||
<span class="task-time">${fmt.bytes(a.bytes || 0)}</span>
|
||||
${a.external_bytes ? `<span class="task-duration" title="on a separate disk">${fmt.bytes(a.external_bytes)} external</span>` : ''}
|
||||
</div>
|
||||
<div class="task-actions">
|
||||
${mounts.length ? `<span class="sys-storage-caret${open ? ' is-open' : ''}">▸</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${mounts.length ? `<ul class="sys-storage-folders sys-storage-app-detail${open ? '' : ' is-collapsed'}" data-app-detail="${fmt.escape(a.app)}">${folders}</ul>` : ''}
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>` : '';
|
||||
|
||||
// ---- Docker engine (secondary): images + build cache the daemon keeps,
|
||||
@ -311,7 +320,7 @@ class SystemStoragePage {
|
||||
if (d) {
|
||||
const total = d.total || 0;
|
||||
const recl = d.reclaimable || 0;
|
||||
const cards = dockerCats.map(s => {
|
||||
const cards = SystemStoragePage.segmentsFrom(d).map(s => {
|
||||
const v = s.data || {};
|
||||
const pct = v.size && total ? (v.size / total) * 100 : 0;
|
||||
const rPct = v.size ? (v.reclaimable / v.size) * 100 : 0;
|
||||
@ -362,32 +371,56 @@ class SystemStoragePage {
|
||||
return { cls: 'is-unused', label: 'unused' };
|
||||
}
|
||||
|
||||
// App-icon <img>, falling back to the generic app icon when the slug has no
|
||||
// bundled icon. Icons are served at /icons/apps/<slug>.svg.
|
||||
_iconImg(slug) {
|
||||
return `<img class="sys-task-icon" src="/icons/apps/${encodeURIComponent(slug)}.svg" alt="" onerror="this.onerror=null;this.src='/icons/apps/default.svg'">`;
|
||||
}
|
||||
|
||||
_appIconHtml(app) {
|
||||
return this._iconImg(String(app || '').toLowerCase());
|
||||
}
|
||||
|
||||
// The app an image belongs to, derived from its repo tag (registry +
|
||||
// namespace stripped, tag/digest dropped). null for dangling/untagged layers.
|
||||
_imageAppSlug(im) {
|
||||
const tag = (im.repo_tags || []).find(t => t && !t.includes('<none>'));
|
||||
if (!tag) return null;
|
||||
const repo = tag.split('@')[0].split(':')[0];
|
||||
const seg = (repo.split('/').pop() || '').toLowerCase();
|
||||
return seg || null;
|
||||
}
|
||||
|
||||
// App icon for an image, or the generic "picture" glyph for untagged layers.
|
||||
_imageIconHtml(im) {
|
||||
const slug = this._imageAppSlug(im);
|
||||
if (slug) return this._iconImg(slug);
|
||||
return `<svg class="sys-img-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>`;
|
||||
}
|
||||
|
||||
_renderImages(images) {
|
||||
const fmt = window.SystemFmt;
|
||||
const trash = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`;
|
||||
const imgIcon = `<svg class="sys-img-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="M21 15l-5-5L5 21"/></svg>`;
|
||||
const totalSize = images.reduce((a, im) => a + (im.size || 0), 0);
|
||||
|
||||
if (!images.length) {
|
||||
return `<div class="sys-section-head"><h2>Images</h2></div><div class="sys-storage-app-empty">No images on disk.</div>`;
|
||||
}
|
||||
|
||||
// Clear All / Select all live in the section head (where the count used
|
||||
// to be), so the list itself is just the rows in a dark container.
|
||||
const head = `
|
||||
<div class="sys-section-head sys-images-head">
|
||||
<h2>Images</h2>
|
||||
<span class="sys-chart-meta">${images.length} image${images.length === 1 ? '' : 's'} · ${fmt.bytes(totalSize)}</span>
|
||||
</div>`;
|
||||
|
||||
if (!images.length) {
|
||||
return `${head}<div class="sys-storage-app-empty">No images on disk.</div>`;
|
||||
}
|
||||
|
||||
const toolbar = `
|
||||
<div class="sys-images-toolbar">
|
||||
<button type="button" class="clear-btn" data-images-clear id="sys-images-clear" title="Remove all images">
|
||||
${trash}<span class="clear-btn-label">Clear All</span>
|
||||
</button>
|
||||
<label class="task-select-all" title="Select all images">
|
||||
<input type="checkbox" data-images-select-all id="sys-images-select-all">
|
||||
<span class="task-select-box" aria-hidden="true"></span>
|
||||
<span class="task-select-all-label">Select all</span>
|
||||
</label>
|
||||
<div class="sys-images-toolbar">
|
||||
<button type="button" class="clear-btn" data-images-clear id="sys-images-clear" title="Remove all images">
|
||||
${trash}<span class="clear-btn-label">Clear All</span>
|
||||
</button>
|
||||
<label class="task-select-all" title="Select all images">
|
||||
<input type="checkbox" data-images-select-all id="sys-images-select-all">
|
||||
<span class="task-select-box" aria-hidden="true"></span>
|
||||
<span class="task-select-all-label">Select all</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const rows = images.map(im => {
|
||||
@ -398,7 +431,7 @@ class SystemStoragePage {
|
||||
<div class="task-item sys-image-item" data-image-id="${id}">
|
||||
<div class="task-header">
|
||||
<div class="task-info">
|
||||
${imgIcon}
|
||||
${this._imageIconHtml(im)}
|
||||
<span class="task-title" title="${fmt.escape((im.repo_tags || []).join(', '))}">${fmt.escape(this._imageName(im))}</span>
|
||||
<span class="task-status sys-img-pill ${pill.cls}">${fmt.escape(pill.label)}</span>
|
||||
<span class="task-time">${fmt.bytes(im.size || 0)}</span>
|
||||
@ -418,7 +451,7 @@ class SystemStoragePage {
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
return `${head}${toolbar}<div class="sys-images-list" id="sys-images-list">${rows}</div>`;
|
||||
return `${head}<div class="sys-tasklist sys-images-list" id="sys-images-list">${rows}</div>`;
|
||||
}
|
||||
|
||||
// ---- selection (mirrors TasksManager) -----------------------------------
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user