merge admin update metrics refinements

This commit is contained in:
Franz Rolfsvaag 2026-06-17 05:18:25 +02:00
commit e08e520d7a
18 changed files with 624 additions and 86 deletions

View File

@ -64,12 +64,37 @@ async function run() {
assert.equal(noSideEffects.preview_status, "ready"); assert.equal(noSideEffects.preview_status, "ready");
assert.equal(noSideEffects.preview_text, "Balance 1234"); 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({ const blocked = await generateCommandPreview({
language: "js", language: "js",
code: "function run() { return process.cwd(); }" code: "function run() { return process.cwd(); }"
}); });
assert.equal(blocked.preview_status, "unavailable"); 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({ const blockedImport = await generateCommandPreview({
language: "python", language: "python",
@ -104,7 +129,7 @@ async function run() {
const appScript = fs.readFileSync(path.join(__dirname, "..", "src", "web", "public", "app.js"), "utf8"); 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 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"); 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('button.disabled = remaining > 0'));
assert(appScript.includes('fetch("/api/destructive-confirmations"')); assert(appScript.includes('fetch("/api/destructive-confirmations"'));
assert(appScript.includes("event.preventDefault();")); assert(appScript.includes("event.preventDefault();"));
@ -114,6 +139,8 @@ async function run() {
assert(layout.includes("data-destructive-confirm disabled")); assert(layout.includes("data-destructive-confirm disabled"));
assert(commandView.includes("Preview unavailable")); assert(commandView.includes("Preview unavailable"));
assert(commandView.includes("preview-dynamic")); assert(commandView.includes("preview-dynamic"));
assert(commandView.includes(">Static<"));
assert(commandView.includes(">Dynamic<"));
assert.equal(commandView.includes('"Advanced (" + command.language + ")"'), false); assert.equal(commandView.includes('"Advanced (" + command.language + ")"'), false);
console.log("Command preview and destructive confirmation verification passed."); console.log("Command preview and destructive confirmation verification passed.");

View File

@ -87,16 +87,21 @@ async function main() {
const intervalMinutes = getSetting("auto_update_interval_minutes", 60); const intervalMinutes = getSetting("auto_update_interval_minutes", 60);
if (!safeModeRequested && autoUpdateEnabled) { if (!safeModeRequested && autoUpdateEnabled) {
const intervalMs = Math.max(5, Number(intervalMinutes)) * 60 * 1000; const intervalMs = Math.max(5, Number(intervalMinutes)) * 60 * 1000;
setInterval(() => { let autoUpdateRunning = false;
setInterval(async () => {
if (autoUpdateRunning) return;
autoUpdateRunning = true;
try { try {
const remote = getSetting("git_remote", "origin"); const remote = getSetting("git_remote", "origin");
const branch = getSetting("git_branch", "main"); const branch = getSetting("git_branch", "main");
if (checkForUpdates(remote, branch)) { if (checkForUpdates(remote, branch)) {
pullUpdates(remote, branch); await pullUpdates(remote, branch);
requestRestart(); requestRestart();
} }
} catch (error) { } catch (error) {
console.error("Auto-update failed", error); console.error("Auto-update failed", error);
} finally {
autoUpdateRunning = false;
} }
}, intervalMs); }, intervalMs);
} }

View File

@ -4,9 +4,11 @@ const vm = require("vm");
const { spawn } = require("child_process"); const { spawn } = require("child_process");
const FIXED_NOW = Date.parse("2026-01-02T12:34:56.000Z"); 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 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_CODE_LENGTH = 20000;
const MAX_PREVIEW_FETCH_BYTES = 128 * 1024;
const PREVIEW_FETCH_TIMEOUT_MS = 3000;
let input = ""; let input = "";
process.stdin.setEncoding("utf8"); process.stdin.setEncoding("utf8");
@ -32,7 +34,7 @@ async function previewJavaScript(code) {
const source = String(code || ""); const source = String(code || "");
if (source.length > MAX_CODE_LENGTH) throw new Error("Command preview code is too large."); if (source.length > MAX_CODE_LENGTH) throw new Error("Command preview code is too large.");
if (BLOCKED_JS.test(source)) { 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 = []; const replies = [];
@ -60,6 +62,9 @@ async function previewJavaScript(code) {
Date: PreviewDate, Date: PreviewDate,
JSON, JSON,
Promise, Promise,
URL,
URLSearchParams,
fetch: readOnlyPreviewFetch,
module: { exports: {} }, module: { exports: {} },
exports: {} exports: {}
}); });
@ -74,7 +79,7 @@ async function previewJavaScript(code) {
if (typeof handler !== "function") { if (typeof handler !== "function") {
throw new Error("Define a run(ctx) 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 output = [...replies];
const normalizedReturn = normalizeResult(returned); const normalizedReturn = normalizeResult(returned);
if (normalizedReturn && !output.includes(normalizedReturn)) output.push(normalizedReturn); 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.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.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.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 = { safe = {
"str": str, "int": int, "float": float, "bool": bool, "len": len, "str": str, "int": int, "float": float, "bool": bool, "len": len,
"min": min, "max": max, "round": round, "range": range, "min": min, "max": max, "round": round, "range": range,
@ -206,11 +211,81 @@ function createMockContext(reply) {
remove: noOpMutation remove: noOpMutation
}), }),
api: Object.freeze({ 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() { function createSerializableContext() {
return { return {
platform: "discord", platform: "discord",

View File

@ -38,7 +38,7 @@ async function runJsCommand(code, ctx) {
const handler = context.run || context.module.exports || context.exports; const handler = context.run || context.module.exports || context.exports;
if (typeof handler !== "function") { 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); const result = handler(ctx);
if (result && typeof result.then === "function") { if (result && typeof result.then === "function") {

View File

@ -35,12 +35,26 @@ function tryGit(args, fallback = "") {
} }
} }
function fetchRemote(remote) { function fetchRemote(remote, branch = null) {
runGit(["fetch", "--prune", remote]); 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) { function remoteRef(remote, branch) {
return `${remote}/${branch}`; return isRepositoryUrl(remote) ? "FETCH_HEAD" : `${remote}/${branch}`;
} }
function readGitFile(ref, filePath) { function readGitFile(ref, filePath) {
@ -232,10 +246,10 @@ function remotePluginDirs(ref) {
} }
function getUpdateStatus(options = {}) { 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"; const requestedSource = options.source || "stable";
fetchRemote(remote);
const sourceBranch = resolveSourceBranch(remote, requestedSource); const sourceBranch = resolveSourceBranch(remote, requestedSource);
fetchRemote(remote, isRepositoryUrl(remote) ? sourceBranch : null);
const ref = remoteRef(remote, sourceBranch); const ref = remoteRef(remote, sourceBranch);
const core = buildStatus({ const core = buildStatus({
kind: "core", kind: "core",

View File

@ -1,11 +1,20 @@
const path = require("path"); const path = require("path");
const fs = require("fs");
const os = require("os");
const { spawnSync } = require("child_process"); 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, "..", ".."); const repoRoot = path.join(__dirname, "..", "..");
function runGit(args) { function runGit(args, options = {}) {
const result = spawnSync("git", args, { const result = spawnSync("git", args, {
cwd: repoRoot, cwd: options.cwd || repoRoot,
encoding: "utf8" encoding: "utf8"
}); });
if (result.status !== 0) { if (result.status !== 0) {
@ -14,18 +23,89 @@ function runGit(args) {
return result.stdout.trim(); 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) { 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([ const count = runGit([
"rev-list", "rev-list",
`HEAD..${remote}/${branch}`, `HEAD..${remoteRef(target, branch)}`,
"--count" "--count"
]); ]);
return Number(count) > 0; return Number(count) > 0;
} }
function pullUpdates(remote, branch) { async function pullUpdates(remote, branch) {
return runGit(["pull", 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() { function requestRestart() {
@ -35,5 +115,6 @@ function requestRestart() {
module.exports = { module.exports = {
checkForUpdates, checkForUpdates,
pullUpdates, pullUpdates,
normalizeRepositoryTarget,
requestRestart requestRestart
}; };

View File

@ -3,8 +3,20 @@
if (!root) return; if (!root) return;
const memoryChart = root.querySelector("[data-memory-chart]"); const memoryChart = root.querySelector("[data-memory-chart]");
const logChart = root.querySelector("[data-log-chart]"); const logChart = root.querySelector("[data-log-chart]");
const logSummary = root.querySelector("[data-log-summary]");
const status = root.querySelector("[data-metrics-status]"); 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 bytes = (value) => {
const mb = Number(value || 0) / 1048576; const mb = Number(value || 0) / 1048576;
@ -23,40 +35,91 @@
if (target) target.textContent = value; if (target) target.textContent = value;
}; };
const line = (values) => { const escapeAttr = (value) => String(value ?? "").replace(/[&"<]/g, (char) => ({
const max = Math.max(...values, 1); "&": "&amp;",
return values.map((value, index) => { "\"": "&quot;",
const x = values.length === 1 ? 0 : (index / (values.length - 1)) * 280 + 10; "<": "&lt;"
const y = 108 - (value / max) * 96; })[char]);
return `${x.toFixed(1)},${y.toFixed(1)}`;
}).join(" "); 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; if (!memoryChart) return;
const values = history.map((item) => item.memory.rss); const samples = data.memory_scopes?.[activeScope] || [];
memoryChart.innerHTML = `<polyline points="${line(values)}" fill="none" stroke="var(--lumi-primary)" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"></polyline>`; 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 `<line x1="10" x2="285" y1="${y.toFixed(1)}" y2="${y.toFixed(1)}" stroke="var(--lumi-border)" stroke-width="1"></line><text x="288" y="${(y + 3).toFixed(1)}" fill="var(--lumi-text-muted)" font-size="8">${bytes(value)}</text>`;
}).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 `<line x1="${x}" x2="${x}" y1="16" y2="104" stroke="var(--lumi-border)" stroke-width="1" stroke-dasharray="3 4"></line><text x="${x}" y="116" fill="var(--lumi-text-muted)" font-size="8" text-anchor="middle">${label}</text>`;
}).join("");
const labels = labelSamples.map((sample) => {
const x = scaleX(sample, samples, scopeMs, now);
const y = scaleY(sample.rss, max);
return `<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" fill="var(--lumi-primary)"></circle><text x="${x.toFixed(1)}" y="${Math.max(10, y - 7).toFixed(1)}" fill="var(--lumi-text)" font-size="8" text-anchor="middle">${bytes(sample.rss)}</text>`;
}).join("");
memoryChart.innerHTML = `
${grid}
${vertical}
${points ? `<polyline points="${points}" fill="none" stroke="var(--lumi-primary)" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"></polyline>` : ""}
${labels}
`;
}; };
const drawLogs = (logs) => { const drawLogs = (logs) => {
if (!logChart) return; if (!logChart) return;
const entries = [["error", logs.error], ["warn", logs.warn], ["info", logs.info], ["debug", logs.debug]]; const counts = logs.levels || logs;
const max = Math.max(...entries.map(([, value]) => value), 1); const entries = levels.map(([level, label, token]) => [level, label, token, Number(counts[level] || 0)]);
logChart.innerHTML = entries.map(([label, value], index) => { const max = Math.max(...entries.map(([, , , value]) => value), 1);
const height = Math.max(4, (value / max) * 86); const range = encodeURIComponent(String(logs.scope_ms || 5 * 60 * 1000));
const x = 24 + index * 68; logChart.innerHTML = entries.map(([level, label, token, value], index) => {
const y = 100 - height; const height = Math.max(4, (value / max) * 76);
return `<rect x="${x}" y="${y}" width="38" height="${height}" rx="6" fill="var(--lumi-${label === "error" ? "danger" : label === "warn" ? "warning" : label === "info" ? "info" : "text-muted"})"></rect><text x="${x + 19}" y="116" text-anchor="middle" fill="var(--lumi-text-muted)" font-size="10">${label}</text>`; const x = 22 + index * 68;
const y = 92 - height;
const href = `/admin/logs?range=${range}&level=${encodeURIComponent(level)}&limit=500`;
return `<a href="${href}" aria-label="${escapeAttr(label)}: ${value} logs"><rect x="${x}" y="${y}" width="38" height="${height}" rx="6" fill="var(--lumi-${token})"></rect><text x="${x + 19}" y="${Math.max(14, y - 5)}" text-anchor="middle" fill="var(--lumi-text)" font-size="10">${value}</text><text x="${x + 19}" y="112" text-anchor="middle" fill="var(--lumi-text-muted)" font-size="10">${level}</text></a>`;
}).join(""); }).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 `<a class="badge ${badge}" href="${href}">${label}: ${value}</a>`;
}).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 () => { const refresh = async () => {
try { 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(); const data = await response.json();
if (!response.ok) throw new Error(data.error || "Metrics unavailable."); 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("uptime", duration(data.uptime_seconds));
setMetric("rss", bytes(data.memory.rss)); setMetric("rss", bytes(data.memory.rss));
setMetric("heap", `${bytes(data.memory.heap_used)} / ${bytes(data.memory.heap_total)}`); setMetric("heap", `${bytes(data.memory.heap_used)} / ${bytes(data.memory.heap_total)}`);
@ -67,7 +130,7 @@
status.textContent = "Live"; status.textContent = "Live";
status.className = "status-indicator status-success"; status.className = "status-indicator status-success";
} }
drawMemory(); drawMemory(data);
drawLogs(data.logs); drawLogs(data.logs);
} catch (error) { } catch (error) {
if (status) { 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(); refresh();
window.setInterval(refresh, 10000); window.setInterval(refresh, 10000);
})(); })();

View File

@ -490,6 +490,20 @@ input[type="color"] {
padding: 0 var(--lumi-space-2); 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 { .status-warning {
color: var(--lumi-warning); color: var(--lumi-warning);
} }
@ -817,6 +831,18 @@ input[type="color"] {
margin-top: var(--lumi-space-4); 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-metric-grid > div,
.dashboard-chart-card { .dashboard-chart-card {
min-width: 0; min-width: 0;
@ -856,13 +882,46 @@ input[type="color"] {
background: var(--lumi-surface); 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 { .log-controls label {
display: grid; display: flex;
flex-direction: column;
gap: var(--lumi-space-1); gap: var(--lumi-space-1);
min-width: 9rem; min-width: 9rem;
} }
.log-controls label:first-child { .log-controls label:first-child {
flex: 1 1 16rem;
min-width: min(18rem, 100%); min-width: min(18rem, 100%);
} }
@ -872,6 +931,18 @@ input[type="color"] {
font-weight: 700; 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 { .modal-backdrop {
padding: var(--lumi-space-4); padding: var(--lumi-space-4);
background: rgba(5, 10, 12, 0.62); background: rgba(5, 10, 12, 0.62);

View File

@ -123,8 +123,9 @@
if (!response.ok) throw new Error(`Save failed for ${form.action || "settings form"}.`); if (!response.ok) throw new Error(`Save failed for ${form.action || "settings form"}.`);
form._lumiSnapshot = snapshotForm(form); form._lumiSnapshot = snapshotForm(form);
} }
status.textContent = "Saved."; status.textContent = "Saved. Reloading...";
updateDirtyState(); window.removeEventListener("beforeunload", warnDirtyNavigation);
window.setTimeout(() => window.location.reload(), 75);
} catch (error) { } catch (error) {
status.textContent = error.message || "Save failed."; status.textContent = error.message || "Save failed.";
bar.classList.add("has-error"); bar.classList.add("has-error");

View File

@ -31,6 +31,10 @@ body {
} }
.sidebar-brand { .sidebar-brand {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
gap: var(--lumi-space-2); gap: var(--lumi-space-2);
} }
@ -39,6 +43,21 @@ body {
padding: var(--lumi-space-2); 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 { .sidebar-nav {
gap: var(--lumi-space-2); gap: var(--lumi-space-2);
scrollbar-width: thin; scrollbar-width: thin;
@ -186,6 +205,14 @@ body {
body.sidebar-collapsed .sidebar { body.sidebar-collapsed .sidebar {
width: 5.5rem; 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) { @media (max-width: 1100px) and (min-width: 901px) {
@ -210,6 +237,10 @@ body {
padding-top: var(--lumi-space-3); padding-top: var(--lumi-space-3);
} }
.sidebar-brand {
align-items: center;
}
body.sidebar-open { body.sidebar-open {
overflow: hidden; overflow: hidden;
} }

View File

@ -640,8 +640,9 @@ body {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 10px; gap: 10px;
align-items: center; align-items: flex-end;
justify-content: flex-end; justify-content: center;
width: 100%;
} }
.command-subtitle { .command-subtitle {
@ -1440,7 +1441,7 @@ body .modal-backdrop.destructive-confirm-modal {
overflow: hidden; overflow: hidden;
color: var(--ink); color: var(--ink);
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: pre-line; white-space: normal;
vertical-align: bottom; vertical-align: bottom;
} }

View File

@ -1251,6 +1251,12 @@ function buildPlatformLabels(platforms) {
const LOG_LEVELS = new Set(["debug", "info", "warn", "error"]); const LOG_LEVELS = new Set(["debug", "info", "warn", "error"]);
const LOG_LIMITS = new Set([50, 100, 250, 500]); const LOG_LIMITS = new Set([50, 100, 250, 500]);
const DEFAULT_LOG_RANGE_MS = 24 * 60 * 60 * 1000; 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) { function normalizeLogLevel(value) {
const normalized = (value || "").toString().trim().toLowerCase(); const normalized = (value || "").toString().trim().toLowerCase();
@ -1295,6 +1301,143 @@ function parseLogLimit(value, { allowAll = false } = {}) {
return { limit: 50, limitValue: "50" }; 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) { function normalizePageFormat(value) {
const normalized = (value || "").toString().trim().toLowerCase(); const normalized = (value || "").toString().trim().toLowerCase();
return normalized === "markdown" ? "markdown" : "html"; return normalized === "markdown" ? "markdown" : "html";
@ -3254,7 +3397,7 @@ function createWebServer({ loadPlugins, discordClient }) {
const platforms = normalizeCustomPlatforms(row.platform, enabledPlatforms); const platforms = normalizeCustomPlatforms(row.platform, enabledPlatforms);
const description = const description =
row.mode === "advanced" row.mode === "advanced"
? `Advanced command (${row.language})` ? `Dynamic command (${row.language})`
: truncateText(row.response, 140); : truncateText(row.response, 140);
addCommand({ addCommand({
id: `custom:${trigger}`, id: `custom:${trigger}`,
@ -3696,8 +3839,12 @@ function createWebServer({ loadPlugins, discordClient }) {
app.get("/api/admin/dashboard-metrics", requireRole("admin"), (req, res) => { app.get("/api/admin/dashboard-metrics", requireRole("admin"), (req, res) => {
const plugins = getPlugins(); 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(); const memory = process.memoryUsage();
recordMemorySample(memory, now);
const count = (table) => { const count = (table) => {
try { try {
return db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get().count; return db.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get().count;
@ -3719,17 +3866,22 @@ function createWebServer({ loadPlugins, discordClient }) {
}, },
counts: { counts: {
users: count("user_profiles"), users: count("user_profiles"),
commands: count("custom_commands"), commands: countVisibleCommands(),
pages: count("custom_pages"), pages: count("custom_pages"),
logs: count("logs") 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: { logs: {
error: logs.filter((entry) => entry.level === "error").length, scope,
warn: logs.filter((entry) => entry.level === "warn").length, scope_ms: scopeMs,
info: logs.filter((entry) => entry.level === "info").length, total: logs.length,
debug: logs.filter((entry) => entry.level === "debug").length levels: countLogsByLevel(logs)
}, },
sampled_at: Date.now() sampled_at: now
}); });
}); });
@ -4477,16 +4629,16 @@ function createWebServer({ loadPlugins, discordClient }) {
return res.redirect("/admin/commands"); return res.redirect("/admin/commands");
} }
if (!isAdmin && mode === "advanced") { 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"); return res.redirect("/admin/commands");
} }
if (mode === "advanced") { if (mode === "advanced") {
if (!code) { if (!code) {
setFlash(req, "error", "Advanced commands require code."); setFlash(req, "error", "Dynamic commands require code.");
return res.redirect("/admin/commands"); return res.redirect("/admin/commands");
} }
} else if (!response) { } else if (!response) {
setFlash(req, "error", "Plain commands require a response."); setFlash(req, "error", "Static commands require a response.");
return res.redirect("/admin/commands"); return res.redirect("/admin/commands");
} }
const now = Date.now(); const now = Date.now();
@ -4543,7 +4695,7 @@ function createWebServer({ loadPlugins, discordClient }) {
.prepare("SELECT mode FROM custom_commands WHERE id = ?") .prepare("SELECT mode FROM custom_commands WHERE id = ?")
.get(req.params.id); .get(req.params.id);
if (existing?.mode === "advanced" && !isAdmin) { 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"); return res.redirect("/admin/commands");
} }
const availablePlatforms = getPlatformStatus() const availablePlatforms = getPlatformStatus()
@ -4567,16 +4719,16 @@ function createWebServer({ loadPlugins, discordClient }) {
return res.redirect("/admin/commands"); return res.redirect("/admin/commands");
} }
if (!isAdmin && mode === "advanced") { 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"); return res.redirect("/admin/commands");
} }
if (mode === "advanced") { if (mode === "advanced") {
if (!code) { if (!code) {
setFlash(req, "error", "Advanced commands require code."); setFlash(req, "error", "Dynamic commands require code.");
return res.redirect("/admin/commands"); return res.redirect("/admin/commands");
} }
} else if (!response) { } else if (!response) {
setFlash(req, "error", "Plain commands require a response."); setFlash(req, "error", "Static commands require a response.");
return res.redirect("/admin/commands"); return res.redirect("/admin/commands");
} }
const preview = isAdmin && mode === "advanced" const preview = isAdmin && mode === "advanced"
@ -4613,7 +4765,7 @@ function createWebServer({ loadPlugins, discordClient }) {
.prepare("SELECT mode, language, code FROM custom_commands WHERE id = ?") .prepare("SELECT mode, language, code FROM custom_commands WHERE id = ?")
.get(req.params.id); .get(req.params.id);
if (!command || command.mode !== "advanced" || !command.code) { 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"); return res.redirect("/admin/commands");
} }
const preview = await generateCommandPreview(command); 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 { try {
const remote = getSetting("git_remote", "origin"); const remote = getSetting("git_remote", "origin");
const branch = getSetting("git_branch", "main"); const branch = getSetting("git_branch", "main");
pullUpdates(remote, branch); await pullUpdates(remote, branch);
setFlash(req, "success", "Update applied. Restarting..."); setFlash(req, "success", "Update applied. Restarting...");
res.redirect("/admin"); res.redirect("/admin");
requestRestart(); requestRestart();

View File

@ -30,8 +30,8 @@
<div class="field"> <div class="field">
<label>Mode</label> <label>Mode</label>
<select name="mode" class="js-command-mode"> <select name="mode" class="js-command-mode">
<option value="plain" selected>Plain</option> <option value="plain" selected>Static</option>
<option value="advanced">Advanced</option> <option value="advanced">Dynamic</option>
</select> </select>
</div> </div>
<div class="field js-field-language"> <div class="field js-field-language">
@ -51,16 +51,16 @@
</div> </div>
<% if (isAdmin) { %> <% if (isAdmin) { %>
<div class="field full js-field-code"> <div class="field full js-field-code">
<label>Advanced code</label> <label>Dynamic code</label>
<textarea name="code" rows="6" placeholder="function run(ctx) { return `Hello ${ctx.user.username}`; }"></textarea> <textarea name="code" rows="6" placeholder="function run(ctx) { return `Hello ${ctx.user.username}`; }"></textarea>
</div> </div>
<% } %> <% } %>
<button type="submit" class="button">Create command</button> <button type="submit" class="button">Create command</button>
</form> </form>
<% if (isAdmin) { %> <% if (isAdmin) { %>
<p class="hint">Advanced commands must export a <code>run(ctx)</code> function. Return a string to reply.</p> <p class="hint">Dynamic commands must export a <code>run(ctx)</code> function. Return a string to reply.</p>
<% } else { %> <% } else { %>
<p class="hint">Moderators can create plain text commands only.</p> <p class="hint">Moderators can create static text commands only.</p>
<% } %> <% } %>
<h2>Existing commands</h2> <h2>Existing commands</h2>
<% if (!commands.length) { %> <% if (!commands.length) { %>
@ -167,8 +167,8 @@
<div class="field"> <div class="field">
<label>Mode</label> <label>Mode</label>
<select name="mode" class="js-command-mode"> <select name="mode" class="js-command-mode">
<option value="plain" <%= command.mode === 'plain' ? 'selected' : '' %>>Plain</option> <option value="plain" <%= command.mode === 'plain' ? 'selected' : '' %>>Static</option>
<option value="advanced" <%= command.mode === 'advanced' ? 'selected' : '' %>>Advanced</option> <option value="advanced" <%= command.mode === 'advanced' ? 'selected' : '' %>>Dynamic</option>
</select> </select>
</div> </div>
<div class="field js-field-language"> <div class="field js-field-language">
@ -188,14 +188,14 @@
</div> </div>
<% if (isAdmin) { %> <% if (isAdmin) { %>
<div class="field full js-field-code"> <div class="field full js-field-code">
<label>Advanced code</label> <label>Dynamic code</label>
<textarea name="code" rows="6"><%= command.code || '' %></textarea> <textarea name="code" rows="6"><%= command.code || '' %></textarea>
</div> </div>
<% } %> <% } %>
<button type="submit" class="button">Save</button> <button type="submit" class="button">Save</button>
</form> </form>
<% if (!isAdmin && command.mode === 'advanced') { %> <% if (!isAdmin && command.mode === 'advanced') { %>
<p class="hint">Advanced commands can only be edited by admins.</p> <p class="hint">Dynamic commands can only be edited by admins.</p>
<% } %> <% } %>
</td> </td>
</tr> </tr>

View File

@ -51,6 +51,11 @@
</div> </div>
<span class="status-indicator status-success" data-metrics-status>Loading</span> <span class="status-indicator status-success" data-metrics-status>Loading</span>
</div> </div>
<div class="dashboard-scope-tabs" role="group" aria-label="Memory and log scope">
<button type="button" class="button subtle" data-dashboard-scope="5m">5m</button>
<button type="button" class="button subtle" data-dashboard-scope="24h">24h</button>
<button type="button" class="button subtle" data-dashboard-scope="7d">7d</button>
</div>
<div class="dashboard-metric-grid"> <div class="dashboard-metric-grid">
<div><span>Uptime</span><strong data-metric="uptime">-</strong></div> <div><span>Uptime</span><strong data-metric="uptime">-</strong></div>
<div><span>Memory RSS</span><strong data-metric="rss">-</strong></div> <div><span>Memory RSS</span><strong data-metric="rss">-</strong></div>
@ -61,12 +66,13 @@
</div> </div>
<div class="dashboard-chart-grid"> <div class="dashboard-chart-grid">
<figure class="dashboard-chart-card"> <figure class="dashboard-chart-card">
<figcaption>Memory trend</figcaption> <figcaption>Memory trend <span class="hint" data-memory-scope-label>5m</span></figcaption>
<svg viewBox="0 0 300 120" role="img" aria-label="Memory trend" data-memory-chart></svg> <svg viewBox="0 0 300 120" role="img" aria-label="Memory trend" data-memory-chart></svg>
</figure> </figure>
<figure class="dashboard-chart-card"> <figure class="dashboard-chart-card">
<figcaption>Recent logs by severity</figcaption> <figcaption>Recent logs by severity <span class="hint" data-log-scope-label>5m</span></figcaption>
<svg viewBox="0 0 300 120" role="img" aria-label="Recent logs by severity" data-log-chart></svg> <svg viewBox="0 0 300 120" role="img" aria-label="Recent logs by severity" data-log-chart></svg>
<div class="dashboard-log-summary" data-log-summary></div>
</figure> </figure>
</div> </div>
</section> </section>

View File

@ -31,6 +31,7 @@
<span>Range</span> <span>Range</span>
<select class="table-search" data-log-range aria-label="Filter by time range"> <select class="table-search" data-log-range aria-label="Filter by time range">
<option value="all" <%= filters.range === 'all' ? 'selected' : '' %>>All time</option> <option value="all" <%= filters.range === 'all' ? 'selected' : '' %>>All time</option>
<option value="<%= 5 * 60 * 1000 %>" <%= filters.range === `${5 * 60 * 1000}` ? 'selected' : '' %>>Last 5 minutes</option>
<option value="<%= 60 * 60 * 1000 %>" <%= filters.range === `${60 * 60 * 1000}` ? 'selected' : '' %>>Last hour</option> <option value="<%= 60 * 60 * 1000 %>" <%= filters.range === `${60 * 60 * 1000}` ? 'selected' : '' %>>Last hour</option>
<option value="<%= 24 * 60 * 60 * 1000 %>" <%= filters.range === `${24 * 60 * 60 * 1000}` ? 'selected' : '' %>>Last 24 hours</option> <option value="<%= 24 * 60 * 60 * 1000 %>" <%= filters.range === `${24 * 60 * 60 * 1000}` ? 'selected' : '' %>>Last 24 hours</option>
<option value="<%= 7 * 24 * 60 * 60 * 1000 %>" <%= filters.range === `${7 * 24 * 60 * 60 * 1000}` ? 'selected' : '' %>>Last week</option> <option value="<%= 7 * 24 * 60 * 60 * 1000 %>" <%= filters.range === `${7 * 24 * 60 * 60 * 1000}` ? 'selected' : '' %>>Last week</option>
@ -94,6 +95,7 @@
<label>Timespan</label> <label>Timespan</label>
<select name="range"> <select name="range">
<option value="all">All time</option> <option value="all">All time</option>
<option value="<%= 5 * 60 * 1000 %>">Last 5 minutes</option>
<option value="<%= 60 * 60 * 1000 %>">Last hour</option> <option value="<%= 60 * 60 * 1000 %>">Last hour</option>
<option value="<%= 24 * 60 * 60 * 1000 %>">Last 24 hours</option> <option value="<%= 24 * 60 * 60 * 1000 %>">Last 24 hours</option>
<option value="<%= 7 * 24 * 60 * 60 * 1000 %>">Last week</option> <option value="<%= 7 * 24 * 60 * 60 * 1000 %>">Last week</option>

View File

@ -32,8 +32,9 @@
<input name="auto_update_interval_minutes" value="<%= settings.auto_update_interval_minutes || 60 %>" /> <input name="auto_update_interval_minutes" value="<%= settings.auto_update_interval_minutes || 60 %>" />
</div> </div>
<div class="field"> <div class="field">
<label>Git remote</label> <label>Git remote / repository target</label>
<input name="git_remote" value="<%= settings.git_remote || 'origin' %>" /> <input name="git_remote" value="<%= settings.git_remote || 'origin' %>" placeholder="origin or https://git.example/owner/repo(.git)" />
<p class="hint">Use a remote alias such as <code>origin</code> or a repository URL. The <code>.git</code> suffix is optional.</p>
</div> </div>
<div class="field"> <div class="field">
<label>Git branch</label> <label>Git branch</label>

View File

@ -110,7 +110,7 @@
</ul> </ul>
<% } %> <% } %>
</div> </div>
<div class="inline-actions"> <div class="inline-actions update-action-row">
<form method="post" action="/admin/updates/core/check" data-update-action> <form method="post" action="/admin/updates/core/check" data-update-action>
<input type="hidden" name="source" value="<%= selectedSource %>" /> <input type="hidden" name="source" value="<%= selectedSource %>" />
<button class="button subtle" type="submit">Check core</button> <button class="button subtle" type="submit">Check core</button>
@ -188,7 +188,7 @@
<% }) %> <% }) %>
</ul> </ul>
<% } %> <% } %>
<div class="inline-actions"> <div class="inline-actions update-action-row">
<form method="post" action="/admin/updates/plugins/<%= plugin.id %>/check" data-update-action> <form method="post" action="/admin/updates/plugins/<%= plugin.id %>/check" data-update-action>
<input type="hidden" name="source" value="<%= selectedSource %>" /> <input type="hidden" name="source" value="<%= selectedSource %>" />
<button class="button subtle" type="submit">Check plugin</button> <button class="button subtle" type="submit">Check plugin</button>

View File

@ -31,8 +31,8 @@
</span> </span>
<span class="title"><%= siteTitle %></span> <span class="title"><%= siteTitle %></span>
</a> </a>
<button class="icon-button sidebar-toggle" type="button" data-sidebar-toggle aria-label="Toggle menu"> <button class="icon-button sidebar-toggle" type="button" data-sidebar-toggle aria-label="Toggle sidebar">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg> <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14M16 8l-4 4 4 4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button> </button>
</div> </div>
<nav class="sidebar-nav"> <nav class="sidebar-nav">