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