updates: refine admin update and metrics flows
This commit is contained in:
parent
d1a5d3b832
commit
1716d88f56
@ -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.");
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
};
|
||||
|
||||
@ -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 = `<polyline points="${line(values)}" fill="none" stroke="var(--lumi-primary)" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"></polyline>`;
|
||||
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 `<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) => {
|
||||
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 `<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 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 `<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("");
|
||||
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 () => {
|
||||
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);
|
||||
})();
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -30,8 +30,8 @@
|
||||
<div class="field">
|
||||
<label>Mode</label>
|
||||
<select name="mode" class="js-command-mode">
|
||||
<option value="plain" selected>Plain</option>
|
||||
<option value="advanced">Advanced</option>
|
||||
<option value="plain" selected>Static</option>
|
||||
<option value="advanced">Dynamic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field js-field-language">
|
||||
@ -51,16 +51,16 @@
|
||||
</div>
|
||||
<% if (isAdmin) { %>
|
||||
<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>
|
||||
</div>
|
||||
<% } %>
|
||||
<button type="submit" class="button">Create command</button>
|
||||
</form>
|
||||
<% 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 { %>
|
||||
<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>
|
||||
<% if (!commands.length) { %>
|
||||
@ -167,8 +167,8 @@
|
||||
<div class="field">
|
||||
<label>Mode</label>
|
||||
<select name="mode" class="js-command-mode">
|
||||
<option value="plain" <%= command.mode === 'plain' ? 'selected' : '' %>>Plain</option>
|
||||
<option value="advanced" <%= command.mode === 'advanced' ? 'selected' : '' %>>Advanced</option>
|
||||
<option value="plain" <%= command.mode === 'plain' ? 'selected' : '' %>>Static</option>
|
||||
<option value="advanced" <%= command.mode === 'advanced' ? 'selected' : '' %>>Dynamic</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field js-field-language">
|
||||
@ -188,14 +188,14 @@
|
||||
</div>
|
||||
<% if (isAdmin) { %>
|
||||
<div class="field full js-field-code">
|
||||
<label>Advanced code</label>
|
||||
<label>Dynamic code</label>
|
||||
<textarea name="code" rows="6"><%= command.code || '' %></textarea>
|
||||
</div>
|
||||
<% } %>
|
||||
<button type="submit" class="button">Save</button>
|
||||
</form>
|
||||
<% 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>
|
||||
</tr>
|
||||
|
||||
@ -51,6 +51,11 @@
|
||||
</div>
|
||||
<span class="status-indicator status-success" data-metrics-status>Loading</span>
|
||||
</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><span>Uptime</span><strong data-metric="uptime">-</strong></div>
|
||||
<div><span>Memory RSS</span><strong data-metric="rss">-</strong></div>
|
||||
@ -61,12 +66,13 @@
|
||||
</div>
|
||||
<div class="dashboard-chart-grid">
|
||||
<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>
|
||||
</figure>
|
||||
<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>
|
||||
<div class="dashboard-log-summary" data-log-summary></div>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -31,6 +31,7 @@
|
||||
<span>Range</span>
|
||||
<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="<%= 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="<%= 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>
|
||||
@ -94,6 +95,7 @@
|
||||
<label>Timespan</label>
|
||||
<select name="range">
|
||||
<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="<%= 24 * 60 * 60 * 1000 %>">Last 24 hours</option>
|
||||
<option value="<%= 7 * 24 * 60 * 60 * 1000 %>">Last week</option>
|
||||
|
||||
@ -31,10 +31,11 @@
|
||||
<label>Auto update interval (minutes)</label>
|
||||
<input name="auto_update_interval_minutes" value="<%= settings.auto_update_interval_minutes || 60 %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Git remote</label>
|
||||
<input name="git_remote" value="<%= settings.git_remote || 'origin' %>" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Git remote / repository target</label>
|
||||
<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 class="field">
|
||||
<label>Git branch</label>
|
||||
<input name="git_branch" value="<%= settings.git_branch || 'main' %>" />
|
||||
|
||||
@ -110,7 +110,7 @@
|
||||
</ul>
|
||||
<% } %>
|
||||
</div>
|
||||
<div class="inline-actions">
|
||||
<div class="inline-actions update-action-row">
|
||||
<form method="post" action="/admin/updates/core/check" data-update-action>
|
||||
<input type="hidden" name="source" value="<%= selectedSource %>" />
|
||||
<button class="button subtle" type="submit">Check core</button>
|
||||
@ -188,7 +188,7 @@
|
||||
<% }) %>
|
||||
</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>
|
||||
<input type="hidden" name="source" value="<%= selectedSource %>" />
|
||||
<button class="button subtle" type="submit">Check plugin</button>
|
||||
|
||||
@ -31,8 +31,8 @@
|
||||
</span>
|
||||
<span class="title"><%= siteTitle %></span>
|
||||
</a>
|
||||
<button class="icon-button sidebar-toggle" type="button" data-sidebar-toggle aria-label="Toggle menu">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
<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="M8 5v14M16 8l-4 4 4 4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user