From 1716d88f567ab28d0de8c49ce27d1bbc022c15fe Mon Sep 17 00:00:00 2001 From: Franz Rolfsvaag Date: Wed, 17 Jun 2026 05:11:30 +0200 Subject: [PATCH] updates: refine admin update and metrics flows --- .../verify-command-preview-confirmations.js | 31 ++- src/main.js | 9 +- src/services/command-preview-worker.js | 85 +++++++- src/services/commands.js | 2 +- src/services/update-index.js | 24 ++- src/services/updater.js | 93 ++++++++- src/web/public/dashboard.js | 115 +++++++++-- src/web/public/lumi-components.css | 73 ++++++- src/web/public/lumi-interactions.js | 5 +- src/web/public/lumi-layout.css | 31 +++ src/web/public/styles.css | 7 +- src/web/server.js | 188 ++++++++++++++++-- src/web/views/admin-commands.ejs | 18 +- src/web/views/admin-dashboard.ejs | 10 +- src/web/views/admin-logs.ejs | 2 + src/web/views/admin-settings.ejs | 9 +- src/web/views/admin-updates.ejs | 4 +- src/web/views/partials/layout-top.ejs | 4 +- 18 files changed, 624 insertions(+), 86 deletions(-) diff --git a/scripts/verify-command-preview-confirmations.js b/scripts/verify-command-preview-confirmations.js index d9d05a9..a235267 100644 --- a/scripts/verify-command-preview-confirmations.js +++ b/scripts/verify-command-preview-confirmations.js @@ -64,12 +64,37 @@ async function run() { assert.equal(noSideEffects.preview_status, "ready"); assert.equal(noSideEffects.preview_text, "Balance 1234"); + const readOnlyFetch = await generateCommandPreview({ + language: "js", + code: ` + async function run() { + const response = await fetch("data:application/json,%7B%22message%22%3A%22preview%22%7D"); + const data = await response.json(); + return data.message; + } + ` + }); + assert.equal(readOnlyFetch.preview_status, "ready"); + assert.equal(readOnlyFetch.preview_text, "preview"); + + const blockedWriteFetch = await generateCommandPreview({ + language: "js", + code: ` + async function run() { + await fetch("data:text/plain,no", { method: "POST", body: "write" }); + return "no"; + } + ` + }); + assert.equal(blockedWriteFetch.preview_status, "unavailable"); + assert.match(blockedWriteFetch.preview_error, /read-only GET and HEAD/i); + const blocked = await generateCommandPreview({ language: "js", code: "function run() { return process.cwd(); }" }); assert.equal(blocked.preview_status, "unavailable"); - assert.match(blocked.preview_error, /blocks filesystem, network, process/i); + assert.match(blocked.preview_error, /blocks filesystem, process/i); const blockedImport = await generateCommandPreview({ language: "python", @@ -104,7 +129,7 @@ async function run() { const appScript = fs.readFileSync(path.join(__dirname, "..", "src", "web", "public", "app.js"), "utf8"); const layout = fs.readFileSync(path.join(__dirname, "..", "src", "web", "views", "partials", "layout-bottom.ejs"), "utf8"); const commandView = fs.readFileSync(path.join(__dirname, "..", "src", "web", "views", "admin-commands.ejs"), "utf8"); - assert(appScript.includes("Confirm in ${remaining}")); + assert(appScript.includes("${confirmLabel(form, submitter)} in ${remaining}")); assert(appScript.includes('button.disabled = remaining > 0')); assert(appScript.includes('fetch("/api/destructive-confirmations"')); assert(appScript.includes("event.preventDefault();")); @@ -114,6 +139,8 @@ async function run() { assert(layout.includes("data-destructive-confirm disabled")); assert(commandView.includes("Preview unavailable")); assert(commandView.includes("preview-dynamic")); + assert(commandView.includes(">Static<")); + assert(commandView.includes(">Dynamic<")); assert.equal(commandView.includes('"Advanced (" + command.language + ")"'), false); console.log("Command preview and destructive confirmation verification passed."); diff --git a/src/main.js b/src/main.js index bb5a518..e88c220 100644 --- a/src/main.js +++ b/src/main.js @@ -87,16 +87,21 @@ async function main() { const intervalMinutes = getSetting("auto_update_interval_minutes", 60); if (!safeModeRequested && autoUpdateEnabled) { const intervalMs = Math.max(5, Number(intervalMinutes)) * 60 * 1000; - setInterval(() => { + let autoUpdateRunning = false; + setInterval(async () => { + if (autoUpdateRunning) return; + autoUpdateRunning = true; try { const remote = getSetting("git_remote", "origin"); const branch = getSetting("git_branch", "main"); if (checkForUpdates(remote, branch)) { - pullUpdates(remote, branch); + await pullUpdates(remote, branch); requestRestart(); } } catch (error) { console.error("Auto-update failed", error); + } finally { + autoUpdateRunning = false; } }, intervalMs); } diff --git a/src/services/command-preview-worker.js b/src/services/command-preview-worker.js index ed8e0e9..b8573a2 100644 --- a/src/services/command-preview-worker.js +++ b/src/services/command-preview-worker.js @@ -4,9 +4,11 @@ const vm = require("vm"); const { spawn } = require("child_process"); const FIXED_NOW = Date.parse("2026-01-02T12:34:56.000Z"); -const BLOCKED_JS = /\b(?:require|process|child_process|fs|fetch|XMLHttpRequest|WebSocket|import\s*\(|import\s+|Deno|Bun)\b/; +const BLOCKED_JS = /\b(?:require|process|child_process|fs|XMLHttpRequest|WebSocket|import\s*\(|import\s+|Deno|Bun)\b/; const BLOCKED_PYTHON = /(?:\b(?:import|open|exec|eval|compile|globals|locals|getattr|setattr|delattr|vars|input|breakpoint|help)\b|__)/; const MAX_CODE_LENGTH = 20000; +const MAX_PREVIEW_FETCH_BYTES = 128 * 1024; +const PREVIEW_FETCH_TIMEOUT_MS = 3000; let input = ""; process.stdin.setEncoding("utf8"); @@ -32,7 +34,7 @@ async function previewJavaScript(code) { const source = String(code || ""); if (source.length > MAX_CODE_LENGTH) throw new Error("Command preview code is too large."); if (BLOCKED_JS.test(source)) { - throw new Error("Preview blocks filesystem, network, process, and module access."); + throw new Error("Preview blocks filesystem, process, browser socket, and module access."); } const replies = []; @@ -60,6 +62,9 @@ async function previewJavaScript(code) { Date: PreviewDate, JSON, Promise, + URL, + URLSearchParams, + fetch: readOnlyPreviewFetch, module: { exports: {} }, exports: {} }); @@ -74,7 +79,7 @@ async function previewJavaScript(code) { if (typeof handler !== "function") { throw new Error("Define a run(ctx) function."); } - const returned = await withTimeout(Promise.resolve(handler(contextValue)), 400); + const returned = await withTimeout(Promise.resolve(handler(contextValue)), PREVIEW_FETCH_TIMEOUT_MS + 500); const output = [...replies]; const normalizedReturn = normalizeResult(returned); if (normalizedReturn && !output.includes(normalizedReturn)) output.push(normalizedReturn); @@ -107,7 +112,7 @@ ctx.inventory = AttrDict({"list": lambda *args: [AttrDict({"id": "item_12345", " ctx.moderation = AttrDict({"warn": noop, "timeout": noop, "ban": noop, "unban": noop}) ctx.db = AttrDict({"prepare": lambda *args: AttrDict({"run": noop, "get": lambda *args: None, "all": lambda *args: []})}) ctx.files = AttrDict({"read": lambda *args: None, "write": noop, "remove": noop}) -ctx.api = AttrDict({"request": lambda *args: {"ok": False, "preview": True, "blocked": True}}) +ctx.api = AttrDict({"request": lambda *args: {"ok": True, "preview": True, "readOnly": True, "status": 200, "text": "", "data": {}}}) safe = { "str": str, "int": int, "float": float, "bool": bool, "len": len, "min": min, "max": max, "round": round, "range": range, @@ -206,11 +211,81 @@ function createMockContext(reply) { remove: noOpMutation }), api: Object.freeze({ - request: async () => ({ ok: false, preview: true, blocked: true }) + request: readOnlyApiRequest }) }; } +async function readOnlyApiRequest(resource, options = {}) { + const response = await readOnlyPreviewFetch(resource, options); + const text = await response.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = null; + } + return { + ok: response.ok, + status: response.status, + statusText: response.statusText, + url: response.url, + preview: true, + readOnly: true, + truncated: response.truncated, + text, + data + }; +} + +async function readOnlyPreviewFetch(resource, options = {}) { + if (typeof fetch !== "function") { + throw new Error("Preview fetch is unavailable in this runtime."); + } + const method = String(options?.method || "GET").toUpperCase(); + if (!["GET", "HEAD"].includes(method)) { + throw new Error("Preview fetch only allows read-only GET and HEAD requests."); + } + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), PREVIEW_FETCH_TIMEOUT_MS); + try { + const response = await fetch(resource, { + ...options, + method, + body: undefined, + signal: controller.signal + }); + const buffer = method === "HEAD" + ? Buffer.alloc(0) + : Buffer.from(await response.arrayBuffer()); + const limited = buffer.length > MAX_PREVIEW_FETCH_BYTES + ? buffer.subarray(0, MAX_PREVIEW_FETCH_BYTES) + : buffer; + const text = limited.toString("utf8"); + const arrayBuffer = limited.buffer.slice( + limited.byteOffset, + limited.byteOffset + limited.byteLength + ); + return Object.freeze({ + ok: response.ok, + status: response.status, + statusText: response.statusText, + url: response.url, + preview: true, + readOnly: true, + truncated: buffer.length > MAX_PREVIEW_FETCH_BYTES, + headers: Object.freeze({ + get: (name) => response.headers.get(name) + }), + text: async () => text, + json: async () => JSON.parse(text || "null"), + arrayBuffer: async () => arrayBuffer + }); + } finally { + clearTimeout(timeout); + } +} + function createSerializableContext() { return { platform: "discord", diff --git a/src/services/commands.js b/src/services/commands.js index 9af9198..4744f2c 100644 --- a/src/services/commands.js +++ b/src/services/commands.js @@ -38,7 +38,7 @@ async function runJsCommand(code, ctx) { const handler = context.run || context.module.exports || context.exports; if (typeof handler !== "function") { - throw new Error("Advanced commands must export a run(ctx) function."); + throw new Error("Dynamic commands must export a run(ctx) function."); } const result = handler(ctx); if (result && typeof result.then === "function") { diff --git a/src/services/update-index.js b/src/services/update-index.js index d46e4e9..5ba54b7 100644 --- a/src/services/update-index.js +++ b/src/services/update-index.js @@ -35,12 +35,26 @@ function tryGit(args, fallback = "") { } } -function fetchRemote(remote) { - runGit(["fetch", "--prune", remote]); +function fetchRemote(remote, branch = null) { + const args = ["fetch", "--prune", remote]; + if (branch) args.push(branch); + runGit(args); +} + +function normalizeRepositoryTarget(value) { + const target = String(value || "origin").trim() || "origin"; + if (/^(?:https?:\/\/|ssh:\/\/|git@)/i.test(target)) { + return target.replace(/\.git\/?$/i, "").replace(/\/+$/, ""); + } + return target; +} + +function isRepositoryUrl(value) { + return /^(?:https?:\/\/|ssh:\/\/|git@)/i.test(String(value || "")); } function remoteRef(remote, branch) { - return `${remote}/${branch}`; + return isRepositoryUrl(remote) ? "FETCH_HEAD" : `${remote}/${branch}`; } function readGitFile(ref, filePath) { @@ -232,10 +246,10 @@ function remotePluginDirs(ref) { } function getUpdateStatus(options = {}) { - const remote = options.remote || getSetting("git_remote", "origin"); + const remote = normalizeRepositoryTarget(options.remote || getSetting("git_remote", "origin")); const requestedSource = options.source || "stable"; - fetchRemote(remote); const sourceBranch = resolveSourceBranch(remote, requestedSource); + fetchRemote(remote, isRepositoryUrl(remote) ? sourceBranch : null); const ref = remoteRef(remote, sourceBranch); const core = buildStatus({ kind: "core", diff --git a/src/services/updater.js b/src/services/updater.js index 08d58f5..d84433e 100644 --- a/src/services/updater.js +++ b/src/services/updater.js @@ -1,11 +1,20 @@ const path = require("path"); +const fs = require("fs"); +const os = require("os"); const { spawnSync } = require("child_process"); +let AdmZip = null; +try { + AdmZip = require("adm-zip"); +} catch { + AdmZip = null; +} +const { applyBotUpdate } = require("./update-manager"); const repoRoot = path.join(__dirname, "..", ".."); -function runGit(args) { +function runGit(args, options = {}) { const result = spawnSync("git", args, { - cwd: repoRoot, + cwd: options.cwd || repoRoot, encoding: "utf8" }); if (result.status !== 0) { @@ -14,18 +23,89 @@ function runGit(args) { return result.stdout.trim(); } +function isGitRepository() { + try { + return runGit(["rev-parse", "--is-inside-work-tree"]) === "true"; + } catch { + return false; + } +} + +function normalizeRepositoryTarget(value) { + const target = String(value || "origin").trim() || "origin"; + if (/^(?:https?:\/\/|ssh:\/\/|git@)/i.test(target)) { + return target.replace(/\.git\/?$/i, "").replace(/\/+$/, ""); + } + return target; +} + +function remoteRef(remote, branch) { + return /^(?:https?:\/\/|ssh:\/\/|git@)/i.test(remote) + ? "FETCH_HEAD" + : `${remote}/${branch}`; +} + function checkForUpdates(remote, branch) { - runGit(["fetch", remote]); + const target = normalizeRepositoryTarget(remote); + if (!isGitRepository()) { + runGit(["ls-remote", "--heads", target, branch]); + return true; + } + runGit(["fetch", target, branch]); const count = runGit([ "rev-list", - `HEAD..${remote}/${branch}`, + `HEAD..${remoteRef(target, branch)}`, "--count" ]); return Number(count) > 0; } -function pullUpdates(remote, branch) { - return runGit(["pull", remote, branch]); +async function pullUpdates(remote, branch) { + const target = normalizeRepositoryTarget(remote); + if (isGitRepository()) { + return runGit(["pull", target, branch]); + } + return applyRepositorySnapshot(target, branch); +} + +async function applyRepositorySnapshot(remote, branch) { + if (!AdmZip) { + throw new Error("adm-zip is not installed. Run npm install before applying repository updates from a ZIP install."); + } + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lumi-repo-update-")); + const cloneDir = path.join(tempDir, "repo"); + const zipPath = path.join(tempDir, "core.zip"); + try { + runGit(["clone", "--depth", "1", "--branch", branch, remote, cloneDir], { cwd: tempDir }); + const zip = new AdmZip(); + addFolderToZip(zip, cloneDir, cloneDir, new Set([".git", "node_modules", "data", "plugins", "updates"])); + zip.writeZip(zipPath); + await applyBotUpdate(zipPath, { + mode: "full", + metadata: { + source_branch: branch, + update_method: "repo_clone", + rollback_safe: false + } + }); + return `Applied core update from ${remote} (${branch}).`; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function addFolderToZip(zip, folderPath, basePath, ignore) { + for (const entry of fs.readdirSync(folderPath, { withFileTypes: true })) { + const fullPath = path.join(folderPath, entry.name); + const relPath = path.relative(basePath, fullPath); + const topLevel = relPath.split(path.sep)[0]; + if (ignore.has(topLevel)) continue; + if (entry.isDirectory()) { + addFolderToZip(zip, fullPath, basePath, ignore); + } else if (entry.isFile()) { + zip.addLocalFile(fullPath, path.dirname(relPath) === "." ? "" : path.dirname(relPath)); + } + } } function requestRestart() { @@ -35,5 +115,6 @@ function requestRestart() { module.exports = { checkForUpdates, pullUpdates, + normalizeRepositoryTarget, requestRestart }; diff --git a/src/web/public/dashboard.js b/src/web/public/dashboard.js index c2bf28d..192a9b5 100644 --- a/src/web/public/dashboard.js +++ b/src/web/public/dashboard.js @@ -3,8 +3,20 @@ if (!root) return; const memoryChart = root.querySelector("[data-memory-chart]"); const logChart = root.querySelector("[data-log-chart]"); + const logSummary = root.querySelector("[data-log-summary]"); const status = root.querySelector("[data-metrics-status]"); - const history = []; + const memoryScopeLabel = root.querySelector("[data-memory-scope-label]"); + const logScopeLabel = root.querySelector("[data-log-scope-label]"); + const scopeButtons = Array.from(root.querySelectorAll("[data-dashboard-scope]")); + let activeScope = window.localStorage.getItem("lumi-dashboard-scope") || "5m"; + + const scopeLabels = { "5m": "5 minutes", "24h": "24 hours", "7d": "7 days" }; + const levels = [ + ["error", "Errors", "danger"], + ["warn", "Warnings", "warning"], + ["info", "Info", "info"], + ["debug", "Debug", "text-muted"] + ]; const bytes = (value) => { const mb = Number(value || 0) / 1048576; @@ -23,40 +35,91 @@ if (target) target.textContent = value; }; - const line = (values) => { - const max = Math.max(...values, 1); - return values.map((value, index) => { - const x = values.length === 1 ? 0 : (index / (values.length - 1)) * 280 + 10; - const y = 108 - (value / max) * 96; - return `${x.toFixed(1)},${y.toFixed(1)}`; - }).join(" "); + const escapeAttr = (value) => String(value ?? "").replace(/[&"<]/g, (char) => ({ + "&": "&", + "\"": """, + "<": "<" + })[char]); + + const scaleX = (sample, samples, scopeMs, now) => { + if (samples.length <= 1) return 290; + const min = now - scopeMs; + return 10 + Math.max(0, Math.min(1, (sample.sampled_at - min) / scopeMs)) * 250; }; - const drawMemory = () => { + const scaleY = (value, max) => 104 - (Number(value || 0) / Math.max(max, 1)) * 82; + + const drawMemory = (data) => { if (!memoryChart) return; - const values = history.map((item) => item.memory.rss); - memoryChart.innerHTML = ``; + const samples = data.memory_scopes?.[activeScope] || []; + const scopeMs = data.scopes?.[activeScope]?.duration_ms || 5 * 60 * 1000; + const now = Number(data.sampled_at || Date.now()); + const max = Math.max(...samples.map((sample) => sample.rss), data.memory?.rss || 0, 1); + const gridValues = [0, 0.25, 0.5, 0.75, 1].map((ratio) => Math.round(max * ratio)); + const points = samples.map((sample) => `${scaleX(sample, samples, scopeMs, now).toFixed(1)},${scaleY(sample.rss, max).toFixed(1)}`).join(" "); + const labelSamples = samples.length <= 3 + ? samples + : [samples[0], samples[Math.floor(samples.length / 2)], samples[samples.length - 1]]; + const grid = gridValues.map((value) => { + const y = scaleY(value, max); + return `${bytes(value)}`; + }).join(""); + const vertical = [0, 0.5, 1].map((ratio) => { + const x = 10 + ratio * 250; + const label = ratio === 0 ? `-${scopeLabels[activeScope] || activeScope}` : ratio === 1 ? "now" : "mid"; + return `${label}`; + }).join(""); + const labels = labelSamples.map((sample) => { + const x = scaleX(sample, samples, scopeMs, now); + const y = scaleY(sample.rss, max); + return `${bytes(sample.rss)}`; + }).join(""); + memoryChart.innerHTML = ` + ${grid} + ${vertical} + ${points ? `` : ""} + ${labels} + `; }; const drawLogs = (logs) => { if (!logChart) return; - const entries = [["error", logs.error], ["warn", logs.warn], ["info", logs.info], ["debug", logs.debug]]; - const max = Math.max(...entries.map(([, value]) => value), 1); - logChart.innerHTML = entries.map(([label, value], index) => { - const height = Math.max(4, (value / max) * 86); - const x = 24 + index * 68; - const y = 100 - height; - return `${label}`; + const counts = logs.levels || logs; + const entries = levels.map(([level, label, token]) => [level, label, token, Number(counts[level] || 0)]); + const max = Math.max(...entries.map(([, , , value]) => value), 1); + const range = encodeURIComponent(String(logs.scope_ms || 5 * 60 * 1000)); + logChart.innerHTML = entries.map(([level, label, token, value], index) => { + const height = Math.max(4, (value / max) * 76); + const x = 22 + index * 68; + const y = 92 - height; + const href = `/admin/logs?range=${range}&level=${encodeURIComponent(level)}&limit=500`; + return `${value}${level}`; }).join(""); + if (logSummary) { + logSummary.innerHTML = entries.map(([level, label, , value]) => { + const href = `/admin/logs?range=${range}&level=${encodeURIComponent(level)}&limit=500`; + const badge = level === "error" ? "danger" : level === "warn" ? "warning" : level === "info" ? "info" : "muted"; + return `${label}: ${value}`; + }).join(""); + } + }; + + const syncScopeButtons = () => { + scopeButtons.forEach((button) => { + const active = button.dataset.dashboardScope === activeScope; + button.classList.toggle("active", active); + button.setAttribute("aria-pressed", active ? "true" : "false"); + }); + if (memoryScopeLabel) memoryScopeLabel.textContent = scopeLabels[activeScope] || activeScope; + if (logScopeLabel) logScopeLabel.textContent = scopeLabels[activeScope] || activeScope; }; const refresh = async () => { try { - const response = await fetch("/api/admin/dashboard-metrics", { cache: "no-store" }); + syncScopeButtons(); + const response = await fetch(`/api/admin/dashboard-metrics?scope=${encodeURIComponent(activeScope)}`, { cache: "no-store" }); const data = await response.json(); if (!response.ok) throw new Error(data.error || "Metrics unavailable."); - history.push(data); - while (history.length > 24) history.shift(); setMetric("uptime", duration(data.uptime_seconds)); setMetric("rss", bytes(data.memory.rss)); setMetric("heap", `${bytes(data.memory.heap_used)} / ${bytes(data.memory.heap_total)}`); @@ -67,7 +130,7 @@ status.textContent = "Live"; status.className = "status-indicator status-success"; } - drawMemory(); + drawMemory(data); drawLogs(data.logs); } catch (error) { if (status) { @@ -77,6 +140,14 @@ } }; + scopeButtons.forEach((button) => { + button.addEventListener("click", () => { + activeScope = button.dataset.dashboardScope || "5m"; + window.localStorage.setItem("lumi-dashboard-scope", activeScope); + refresh(); + }); + }); + refresh(); window.setInterval(refresh, 10000); })(); diff --git a/src/web/public/lumi-components.css b/src/web/public/lumi-components.css index 6c94230..46c879d 100644 --- a/src/web/public/lumi-components.css +++ b/src/web/public/lumi-components.css @@ -490,6 +490,20 @@ input[type="color"] { padding: 0 var(--lumi-space-2); } +.badge.info { + color: var(--lumi-info); + background: color-mix(in srgb, var(--lumi-info) 12%, var(--lumi-surface)); + border: 1px solid color-mix(in srgb, var(--lumi-info) 35%, var(--lumi-border)); + padding: 0 var(--lumi-space-2); +} + +.badge.muted { + color: var(--lumi-text-muted); + background: var(--lumi-surface-subtle); + border: 1px solid var(--lumi-border); + padding: 0 var(--lumi-space-2); +} + .status-warning { color: var(--lumi-warning); } @@ -817,6 +831,18 @@ input[type="color"] { margin-top: var(--lumi-space-4); } +.dashboard-scope-tabs { + display: flex; + flex-wrap: wrap; + gap: var(--lumi-space-2); + margin-top: var(--lumi-space-3); +} + +.dashboard-scope-tabs .button.active { + background: var(--lumi-primary); + color: var(--lumi-on-primary); +} + .dashboard-metric-grid > div, .dashboard-chart-card { min-width: 0; @@ -856,13 +882,46 @@ input[type="color"] { background: var(--lumi-surface); } +.dashboard-chart-card svg a { + cursor: pointer; +} + +.dashboard-chart-card svg a:hover rect { + filter: brightness(0.92); +} + +.dashboard-log-summary { + display: flex; + flex-wrap: wrap; + gap: var(--lumi-space-2); + margin-top: var(--lumi-space-3); +} + +.dashboard-log-summary a { + text-decoration: none; +} + +.update-action-row { + display: flex; + flex-wrap: wrap; + gap: var(--lumi-space-2); + align-items: center; + justify-content: center; +} + +.update-action-row > form { + margin: 0; +} + .log-controls label { - display: grid; + display: flex; + flex-direction: column; gap: var(--lumi-space-1); min-width: 9rem; } .log-controls label:first-child { + flex: 1 1 16rem; min-width: min(18rem, 100%); } @@ -872,6 +931,18 @@ input[type="color"] { font-weight: 700; } +.log-controls .button, +.log-controls button { + align-self: flex-end; +} + +@media (min-width: 901px) { + .update-action-row .button { + padding-left: 7.5px; + padding-right: 7.5px; + } +} + .modal-backdrop { padding: var(--lumi-space-4); background: rgba(5, 10, 12, 0.62); diff --git a/src/web/public/lumi-interactions.js b/src/web/public/lumi-interactions.js index 43e84a6..8c6e43e 100644 --- a/src/web/public/lumi-interactions.js +++ b/src/web/public/lumi-interactions.js @@ -123,8 +123,9 @@ if (!response.ok) throw new Error(`Save failed for ${form.action || "settings form"}.`); form._lumiSnapshot = snapshotForm(form); } - status.textContent = "Saved."; - updateDirtyState(); + status.textContent = "Saved. Reloading..."; + window.removeEventListener("beforeunload", warnDirtyNavigation); + window.setTimeout(() => window.location.reload(), 75); } catch (error) { status.textContent = error.message || "Save failed."; bar.classList.add("has-error"); diff --git a/src/web/public/lumi-layout.css b/src/web/public/lumi-layout.css index 78962bd..4b91f01 100644 --- a/src/web/public/lumi-layout.css +++ b/src/web/public/lumi-layout.css @@ -31,6 +31,10 @@ body { } .sidebar-brand { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; gap: var(--lumi-space-2); } @@ -39,6 +43,21 @@ body { padding: var(--lumi-space-2); } +.sidebar-toggle { + align-self: center; + flex: 0 0 auto; + width: 2.25rem; + height: 2.25rem; + border-radius: var(--lumi-radius-pill); + background: var(--lumi-surface-subtle); + color: var(--lumi-text-muted); +} + +.sidebar-toggle:hover { + background: var(--lumi-surface-raised); + color: var(--lumi-text); +} + .sidebar-nav { gap: var(--lumi-space-2); scrollbar-width: thin; @@ -186,6 +205,14 @@ body { body.sidebar-collapsed .sidebar { width: 5.5rem; } + + body.sidebar-collapsed .sidebar-brand { + flex-direction: column; + } + + body.sidebar-collapsed .sidebar-toggle svg { + transform: rotate(180deg); + } } @media (max-width: 1100px) and (min-width: 901px) { @@ -210,6 +237,10 @@ body { padding-top: var(--lumi-space-3); } + .sidebar-brand { + align-items: center; + } + body.sidebar-open { overflow: hidden; } diff --git a/src/web/public/styles.css b/src/web/public/styles.css index bf927a1..6d8d998 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -640,8 +640,9 @@ body { display: flex; flex-wrap: wrap; gap: 10px; - align-items: center; - justify-content: flex-end; + align-items: flex-end; + justify-content: center; + width: 100%; } .command-subtitle { @@ -1440,7 +1441,7 @@ body .modal-backdrop.destructive-confirm-modal { overflow: hidden; color: var(--ink); text-overflow: ellipsis; - white-space: pre-line; + white-space: normal; vertical-align: bottom; } diff --git a/src/web/server.js b/src/web/server.js index cad8c8d..e024528 100644 --- a/src/web/server.js +++ b/src/web/server.js @@ -1251,6 +1251,12 @@ function buildPlatformLabels(platforms) { const LOG_LEVELS = new Set(["debug", "info", "warn", "error"]); const LOG_LIMITS = new Set([50, 100, 250, 500]); const DEFAULT_LOG_RANGE_MS = 24 * 60 * 60 * 1000; +const DASHBOARD_SCOPES = { + "5m": 5 * 60 * 1000, + "24h": 24 * 60 * 60 * 1000, + "7d": 7 * 24 * 60 * 60 * 1000 +}; +const memorySamples = []; function normalizeLogLevel(value) { const normalized = (value || "").toString().trim().toLowerCase(); @@ -1295,6 +1301,143 @@ function parseLogLimit(value, { allowAll = false } = {}) { return { limit: 50, limitValue: "50" }; } +function parseDashboardScope(value) { + const normalized = String(value || "5m").trim().toLowerCase(); + return DASHBOARD_SCOPES[normalized] ? normalized : "5m"; +} + +function recordMemorySample(memory = process.memoryUsage(), now = Date.now()) { + const last = memorySamples[memorySamples.length - 1]; + if (!last || now - last.sampled_at >= 10 * 1000) { + memorySamples.push({ + sampled_at: now, + rss: memory.rss, + heap_used: memory.heapUsed, + heap_total: memory.heapTotal + }); + } + const oldest = now - DASHBOARD_SCOPES["7d"]; + while (memorySamples.length && memorySamples[0].sampled_at < oldest) { + memorySamples.shift(); + } +} + +function scopedMemorySamples(now = Date.now()) { + return Object.fromEntries( + Object.entries(DASHBOARD_SCOPES).map(([scope, duration]) => [ + scope, + memorySamples.filter((sample) => sample.sampled_at >= now - duration) + ]) + ); +} + +function countLogsByLevel(logs) { + const counts = { error: 0, warn: 0, info: 0, debug: 0 }; + for (const entry of logs) { + if (counts[entry.level] !== undefined) counts[entry.level] += 1; + } + return counts; +} + +function countVisibleCommands() { + const enabledPlatforms = getEnabledPlatformIds(); + const commands = []; + + const addCommand = (command) => { + if (!command.trigger) { + return; + } + commands.push({ + trigger: command.trigger, + subcommand: command.subcommand || "" + }); + }; + + try { + const customCommands = db + .prepare("SELECT trigger, platform FROM custom_commands WHERE enabled = 1") + .all(); + for (const row of customCommands) { + const trigger = normalizeCommandTrigger(row.trigger); + const platforms = normalizeCustomPlatforms(row.platform, enabledPlatforms); + addCommand({ trigger, platforms }); + } + } catch { + return 0; + } + + const topOptions = getTopCommandOptions(); + if (topOptions.length) { + addCommand({ trigger: "top", platforms: enabledPlatforms }); + for (const option of topOptions) { + const subcommand = normalizeSubcommand(option.id); + addCommand({ trigger: "top", subcommand, platforms: enabledPlatforms }); + } + } + + const plugins = getPlugins().filter((plugin) => plugin.enabled); + for (const plugin of plugins) { + const cmdsPath = path.join(plugin.path, "cmds.json"); + if (!fs.existsSync(cmdsPath)) { + continue; + } + const manifest = readJsonSafe(cmdsPath); + if (!manifest || !Array.isArray(manifest.commands)) { + continue; + } + const pluginSettings = getPluginSettingsMap(plugin.id); + const platformKeys = manifest.platformKeys || {}; + const platformFlags = {}; + for (const platform of enabledPlatforms) { + platformFlags[platform] = platformKeys[platform] + ? parseBooleanSetting(pluginSettings[platformKeys[platform]], true) + : true; + } + + for (const command of manifest.commands) { + if (!command || !command.trigger) { + continue; + } + const enabled = command.enabledKey + ? parseBooleanSetting(pluginSettings[command.enabledKey], true) + : true; + if (!enabled) { + continue; + } + const override = command.triggerKey ? pluginSettings[command.triggerKey] : ""; + const trigger = normalizeCommandTrigger(override, command.trigger); + const subcommand = normalizeSubcommand(command.subcommand); + const platforms = (Array.isArray(command.platforms) && command.platforms.length + ? command.platforms + : enabledPlatforms + ).filter((platform) => platformFlags[platform] && enabledPlatforms.includes(platform)); + if (!platforms.length) { + continue; + } + addCommand({ trigger, subcommand, platforms }); + } + } + + const roots = new Set(); + const subcommandTriggers = new Set(); + let total = 0; + for (const command of commands) { + if (command.subcommand) { + subcommandTriggers.add(command.trigger); + total += 1; + } else { + roots.add(command.trigger); + total += 1; + } + } + for (const trigger of subcommandTriggers) { + if (!roots.has(trigger)) { + total += 1; + } + } + return total; +} + function normalizePageFormat(value) { const normalized = (value || "").toString().trim().toLowerCase(); return normalized === "markdown" ? "markdown" : "html"; @@ -3254,7 +3397,7 @@ function createWebServer({ loadPlugins, discordClient }) { const platforms = normalizeCustomPlatforms(row.platform, enabledPlatforms); const description = row.mode === "advanced" - ? `Advanced command (${row.language})` + ? `Dynamic command (${row.language})` : truncateText(row.response, 140); addCommand({ id: `custom:${trigger}`, @@ -3696,8 +3839,12 @@ function createWebServer({ loadPlugins, discordClient }) { app.get("/api/admin/dashboard-metrics", requireRole("admin"), (req, res) => { const plugins = getPlugins(); - const logs = listLogs({ limit: 500 }); + const scope = parseDashboardScope(req.query.scope); + const scopeMs = DASHBOARD_SCOPES[scope]; + const now = Date.now(); + const logs = listLogs({ sinceMs: now - scopeMs }); const memory = process.memoryUsage(); + recordMemorySample(memory, now); const count = (table) => { try { return db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get().count; @@ -3719,17 +3866,22 @@ function createWebServer({ loadPlugins, discordClient }) { }, counts: { users: count("user_profiles"), - commands: count("custom_commands"), + commands: countVisibleCommands(), pages: count("custom_pages"), logs: count("logs") }, + scopes: Object.fromEntries( + Object.entries(DASHBOARD_SCOPES).map(([id, duration]) => [id, { duration_ms: duration }]) + ), + selected_scope: scope, + memory_scopes: scopedMemorySamples(now), logs: { - error: logs.filter((entry) => entry.level === "error").length, - warn: logs.filter((entry) => entry.level === "warn").length, - info: logs.filter((entry) => entry.level === "info").length, - debug: logs.filter((entry) => entry.level === "debug").length + scope, + scope_ms: scopeMs, + total: logs.length, + levels: countLogsByLevel(logs) }, - sampled_at: Date.now() + sampled_at: now }); }); @@ -4477,16 +4629,16 @@ function createWebServer({ loadPlugins, discordClient }) { return res.redirect("/admin/commands"); } if (!isAdmin && mode === "advanced") { - setFlash(req, "error", "Advanced commands are restricted to admins."); + setFlash(req, "error", "Dynamic commands are restricted to admins."); return res.redirect("/admin/commands"); } if (mode === "advanced") { if (!code) { - setFlash(req, "error", "Advanced commands require code."); + setFlash(req, "error", "Dynamic commands require code."); return res.redirect("/admin/commands"); } } else if (!response) { - setFlash(req, "error", "Plain commands require a response."); + setFlash(req, "error", "Static commands require a response."); return res.redirect("/admin/commands"); } const now = Date.now(); @@ -4543,7 +4695,7 @@ function createWebServer({ loadPlugins, discordClient }) { .prepare("SELECT mode FROM custom_commands WHERE id = ?") .get(req.params.id); if (existing?.mode === "advanced" && !isAdmin) { - setFlash(req, "error", "Advanced commands can only be edited by admins."); + setFlash(req, "error", "Dynamic commands can only be edited by admins."); return res.redirect("/admin/commands"); } const availablePlatforms = getPlatformStatus() @@ -4567,16 +4719,16 @@ function createWebServer({ loadPlugins, discordClient }) { return res.redirect("/admin/commands"); } if (!isAdmin && mode === "advanced") { - setFlash(req, "error", "Advanced commands are restricted to admins."); + setFlash(req, "error", "Dynamic commands are restricted to admins."); return res.redirect("/admin/commands"); } if (mode === "advanced") { if (!code) { - setFlash(req, "error", "Advanced commands require code."); + setFlash(req, "error", "Dynamic commands require code."); return res.redirect("/admin/commands"); } } else if (!response) { - setFlash(req, "error", "Plain commands require a response."); + setFlash(req, "error", "Static commands require a response."); return res.redirect("/admin/commands"); } const preview = isAdmin && mode === "advanced" @@ -4613,7 +4765,7 @@ function createWebServer({ loadPlugins, discordClient }) { .prepare("SELECT mode, language, code FROM custom_commands WHERE id = ?") .get(req.params.id); if (!command || command.mode !== "advanced" || !command.code) { - setFlash(req, "error", "Advanced command not found."); + setFlash(req, "error", "Dynamic command not found."); return res.redirect("/admin/commands"); } const preview = await generateCommandPreview(command); @@ -5206,11 +5358,11 @@ function createWebServer({ loadPlugins, discordClient }) { } ); - app.post("/admin/update", requireRole("admin"), (req, res) => { + app.post("/admin/update", requireRole("admin"), async (req, res) => { try { const remote = getSetting("git_remote", "origin"); const branch = getSetting("git_branch", "main"); - pullUpdates(remote, branch); + await pullUpdates(remote, branch); setFlash(req, "success", "Update applied. Restarting..."); res.redirect("/admin"); requestRestart(); diff --git a/src/web/views/admin-commands.ejs b/src/web/views/admin-commands.ejs index f5ed1fd..e13c999 100644 --- a/src/web/views/admin-commands.ejs +++ b/src/web/views/admin-commands.ejs @@ -30,8 +30,8 @@
@@ -51,16 +51,16 @@
<% if (isAdmin) { %>
- +
<% } %> <% if (isAdmin) { %> -

Advanced commands must export a run(ctx) function. Return a string to reply.

+

Dynamic commands must export a run(ctx) function. Return a string to reply.

<% } else { %> -

Moderators can create plain text commands only.

+

Moderators can create static text commands only.

<% } %>

Existing commands

<% if (!commands.length) { %> @@ -167,8 +167,8 @@
@@ -188,14 +188,14 @@
<% if (isAdmin) { %>
- +
<% } %> <% if (!isAdmin && command.mode === 'advanced') { %> -

Advanced commands can only be edited by admins.

+

Dynamic commands can only be edited by admins.

<% } %> diff --git a/src/web/views/admin-dashboard.ejs b/src/web/views/admin-dashboard.ejs index 65a42f6..b146436 100644 --- a/src/web/views/admin-dashboard.ejs +++ b/src/web/views/admin-dashboard.ejs @@ -51,6 +51,11 @@ Loading +
+ + + +
Uptime-
Memory RSS-
@@ -61,12 +66,13 @@
-
Memory trend
+
Memory trend 5m
-
Recent logs by severity
+
Recent logs by severity 5m
+
diff --git a/src/web/views/admin-logs.ejs b/src/web/views/admin-logs.ejs index 8bbefd1..52e90e5 100644 --- a/src/web/views/admin-logs.ejs +++ b/src/web/views/admin-logs.ejs @@ -31,6 +31,7 @@ Range + diff --git a/src/web/views/admin-settings.ejs b/src/web/views/admin-settings.ejs index afb097c..2c1e899 100644 --- a/src/web/views/admin-settings.ejs +++ b/src/web/views/admin-settings.ejs @@ -31,10 +31,11 @@ -
- - -
+
+ + +

Use a remote alias such as origin or a repository URL. The .git suffix is optional.

+
diff --git a/src/web/views/admin-updates.ejs b/src/web/views/admin-updates.ejs index eaaa1cb..0e3edb8 100644 --- a/src/web/views/admin-updates.ejs +++ b/src/web/views/admin-updates.ejs @@ -110,7 +110,7 @@ <% } %>
-
+
@@ -188,7 +188,7 @@ <% }) %> <% } %> -
+
diff --git a/src/web/views/partials/layout-top.ejs b/src/web/views/partials/layout-top.ejs index 0ff4a22..a7ea410 100644 --- a/src/web/views/partials/layout-top.ejs +++ b/src/web/views/partials/layout-top.ejs @@ -31,8 +31,8 @@ <%= siteTitle %> -