create-volt 0.43.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/package.json +1 -1
- package/templates/blog/package.json +2 -1
- package/templates/blog/server.js +115 -1
- package/templates/blog/setup/logs.html +29 -0
- package/templates/blog/setup/logs.js +58 -0
- package/templates/default/package.json +2 -1
- package/templates/default/server.js +115 -1
- package/templates/default/setup/logs.html +29 -0
- package/templates/default/setup/logs.js +58 -0
- package/templates/docs/package.json +2 -1
- package/templates/docs/server.js +115 -1
- package/templates/docs/setup/logs.html +29 -0
- package/templates/docs/setup/logs.js +58 -0
- package/templates/starter/package.json +2 -1
- package/templates/starter/server.js +114 -0
- package/templates/starter/setup/logs.html +29 -0
- package/templates/starter/setup/logs.js +58 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@ All notable changes to `create-volt` are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/), and this project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [0.44.0] - 2026-06-29
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **`--logs` — a built-in log viewer** on its own localhost port (like `--studio`):
|
|
11
|
+
`npm run logs`. Tails pm2 stdout/stderr **out of the box**; an Analytics tab
|
|
12
|
+
parses Apache/nginx access logs via `mir-sentinel` (optional dep) → top
|
|
13
|
+
paths/status/IPs + bot/attack counts. **Add more sources** (other apps, servers,
|
|
14
|
+
mounted/tunneled paths) right in the viewer — saved to `.volt/logs.json`.
|
|
15
|
+
Localhost-only; SSH-tunnel the port for a remote box.
|
|
16
|
+
|
|
7
17
|
## [0.43.0] - 2026-06-30
|
|
8
18
|
|
|
9
19
|
### Added
|
|
@@ -574,6 +584,7 @@ All notable changes to `create-volt` are documented here. The format follows
|
|
|
574
584
|
watching and full-page hot reload. Supports `--skip-install` and `--force`,
|
|
575
585
|
and auto-detects npm / pnpm / yarn / bun for the install step.
|
|
576
586
|
|
|
587
|
+
[0.44.0]: https://github.com/MIR-2025/volt/releases/tag/v0.44.0
|
|
577
588
|
[0.43.0]: https://github.com/MIR-2025/volt/releases/tag/v0.43.0
|
|
578
589
|
[0.42.0]: https://github.com/MIR-2025/volt/releases/tag/v0.42.0
|
|
579
590
|
[0.41.0]: https://github.com/MIR-2025/volt/releases/tag/v0.41.0
|
package/package.json
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"pm2": "npx --yes pm2 start ecosystem.config.cjs",
|
|
12
12
|
"pm2:restart": "npx --yes pm2 restart ecosystem.config.cjs",
|
|
13
13
|
"pm2:stop": "npx --yes pm2 delete ecosystem.config.cjs",
|
|
14
|
-
"pm2:logs": "npx --yes pm2 logs"
|
|
14
|
+
"pm2:logs": "npx --yes pm2 logs",
|
|
15
|
+
"logs": "node server.js --logs"
|
|
15
16
|
},
|
|
16
17
|
"dependencies": {
|
|
17
18
|
"express": "^4.22.2",
|
package/templates/blog/server.js
CHANGED
|
@@ -13,6 +13,7 @@ import fs from "node:fs";
|
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { spawn, spawnSync } from "node:child_process";
|
|
15
15
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
16
|
+
import os from "node:os";
|
|
16
17
|
import express from "express";
|
|
17
18
|
import { Server as SocketServer } from "socket.io";
|
|
18
19
|
|
|
@@ -614,7 +615,118 @@ async function startStudio() {
|
|
|
614
615
|
});
|
|
615
616
|
}
|
|
616
617
|
|
|
617
|
-
// ---
|
|
618
|
+
// --- `--logs`: a disposable, localhost-only log viewer (its own port, like
|
|
619
|
+
// --studio). Tails pm2 stdout/stderr; with mir-sentinel installed, an Analytics
|
|
620
|
+
// tab parses Apache/nginx access logs (ACCESS_LOG). Shell access is the auth;
|
|
621
|
+
// for a remote box, SSH-tunnel the port. ---
|
|
622
|
+
async function startLogs() {
|
|
623
|
+
loadEnv();
|
|
624
|
+
const PORT = configPort();
|
|
625
|
+
const name = (() => {
|
|
626
|
+
try {
|
|
627
|
+
return JSON.parse(fs.readFileSync(PKG_PATH, "utf8")).name || "app";
|
|
628
|
+
} catch {
|
|
629
|
+
return "app";
|
|
630
|
+
}
|
|
631
|
+
})();
|
|
632
|
+
const logsDir = path.join(os.homedir(), ".pm2", "logs");
|
|
633
|
+
// Sources: pm2 stdout/stderr work out of the box; ACCESS_LOG adds an Apache/nginx
|
|
634
|
+
// log; users add more (other apps/servers/mounted or tunneled paths) via
|
|
635
|
+
// .volt/logs.json, editable here in the viewer. Re-read per request so additions
|
|
636
|
+
// show live. For a remote box, ship its log here or SSH-tunnel + run --logs there.
|
|
637
|
+
const LOGS_JSON = path.join(__dirname, ".volt", "logs.json");
|
|
638
|
+
const readExtra = () => {
|
|
639
|
+
try {
|
|
640
|
+
const a = JSON.parse(fs.readFileSync(LOGS_JSON, "utf8"));
|
|
641
|
+
return Array.isArray(a) ? a.filter((x) => x && x.label && x.file) : [];
|
|
642
|
+
} catch {
|
|
643
|
+
return [];
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
const sources = () => {
|
|
647
|
+
const s = { app: path.join(logsDir, `${name}-out.log`), error: path.join(logsDir, `${name}-error.log`) };
|
|
648
|
+
if (process.env.ACCESS_LOG) s.access = process.env.ACCESS_LOG;
|
|
649
|
+
for (const x of readExtra()) s[x.label] = x.file;
|
|
650
|
+
return s;
|
|
651
|
+
};
|
|
652
|
+
const tail = (f, n) => (f && fs.existsSync(f) ? fs.readFileSync(f, "utf8").split(/\r?\n/).filter(Boolean).slice(-n) : []);
|
|
653
|
+
let parseLine = null;
|
|
654
|
+
try {
|
|
655
|
+
parseLine = (await import("mir-sentinel")).parseLine;
|
|
656
|
+
} catch {
|
|
657
|
+
/* analytics optional */
|
|
658
|
+
}
|
|
659
|
+
const top = (arr, key) => {
|
|
660
|
+
const m = {};
|
|
661
|
+
for (const x of arr) if (x && x[key]) m[x[key]] = (m[x[key]] || 0) + 1;
|
|
662
|
+
return Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
663
|
+
};
|
|
664
|
+
const assets = {
|
|
665
|
+
"/": ["text/html; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "logs.html"))],
|
|
666
|
+
"/logs.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "logs.js"))],
|
|
667
|
+
};
|
|
668
|
+
const json = (res, o) => {
|
|
669
|
+
res.setHeader("Content-Type", "application/json");
|
|
670
|
+
res.end(JSON.stringify(o));
|
|
671
|
+
};
|
|
672
|
+
const server = http.createServer((req, res) => {
|
|
673
|
+
const u = new URL(req.url, "http://localhost");
|
|
674
|
+
const p = u.pathname;
|
|
675
|
+
if (assets[p]) {
|
|
676
|
+
res.setHeader("Content-Type", assets[p][0]);
|
|
677
|
+
return res.end(assets[p][1]);
|
|
678
|
+
}
|
|
679
|
+
if (p === "/api/sources") return json(res, { sources: Object.keys(sources()), extra: readExtra(), analytics: !!parseLine });
|
|
680
|
+
if (p === "/api/tail") {
|
|
681
|
+
const f = sources()[u.searchParams.get("source")];
|
|
682
|
+
return f ? json(res, { ok: true, lines: tail(f, Math.min(2000, Number(u.searchParams.get("lines")) || 300)) }) : json(res, { ok: false });
|
|
683
|
+
}
|
|
684
|
+
if (p === "/api/analytics") {
|
|
685
|
+
if (!parseLine) return json(res, { ok: false, error: "npm i mir-sentinel for analytics" });
|
|
686
|
+
const f = sources()[u.searchParams.get("source")];
|
|
687
|
+
if (!f) return json(res, { ok: false });
|
|
688
|
+
const parsed = tail(f, 5000).map((l) => parseLine(l));
|
|
689
|
+
return json(res, { ok: true, total: parsed.length, paths: top(parsed, "path"), statuses: top(parsed, "status"), ips: top(parsed, "ip"), bots: parsed.filter((x) => x && x.bot).length, attacks: parsed.filter((x) => x && x.attack).length });
|
|
690
|
+
}
|
|
691
|
+
// add/remove a source ("add servers") — written to .volt/logs.json
|
|
692
|
+
if (req.method === "POST" && (p === "/api/source" || p === "/api/source/remove")) {
|
|
693
|
+
let body = "";
|
|
694
|
+
req.on("data", (c) => (body += c));
|
|
695
|
+
req.on("end", () => {
|
|
696
|
+
try {
|
|
697
|
+
const { label, file } = JSON.parse(body || "{}");
|
|
698
|
+
if (!/^[a-z0-9][a-z0-9 _-]*$/i.test(label || "")) throw new Error("label: letters, numbers, spaces, - _");
|
|
699
|
+
let list = readExtra().filter((x) => x.label !== label);
|
|
700
|
+
if (p === "/api/source") list.push({ label, file: String(file || "") });
|
|
701
|
+
fs.mkdirSync(path.dirname(LOGS_JSON), { recursive: true });
|
|
702
|
+
fs.writeFileSync(LOGS_JSON, JSON.stringify(list, null, 2));
|
|
703
|
+
json(res, { ok: true });
|
|
704
|
+
} catch (e) {
|
|
705
|
+
res.statusCode = 400;
|
|
706
|
+
json(res, { ok: false, error: e.message });
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
res.statusCode = 404;
|
|
712
|
+
res.end("not found");
|
|
713
|
+
});
|
|
714
|
+
server.on("error", (e) => {
|
|
715
|
+
if (e.code === "EADDRINUSE") {
|
|
716
|
+
console.error(`\n[volt] Logs port ${PORT} is in use — set CONFIG_PORT in .env or pass --port <n>.`);
|
|
717
|
+
process.exit(1);
|
|
718
|
+
}
|
|
719
|
+
throw e;
|
|
720
|
+
});
|
|
721
|
+
server.listen(PORT, "127.0.0.1", () => {
|
|
722
|
+
const url = `http://localhost:${PORT}`;
|
|
723
|
+
console.log(`\nVolt logs at ${url} (${parseLine ? "analytics on" : "raw tail — npm i mir-sentinel for analytics"})`);
|
|
724
|
+
console.log(" localhost only; for a remote box: ssh -L " + PORT + ":localhost:" + PORT + " you@server");
|
|
725
|
+
openBrowser(url);
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// --- gate: studio / logs / setup (first run, --edit) / the app ---
|
|
618
730
|
const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
|
|
619
731
|
// In production / on a PaaS there's no interactive wizard: config comes from the
|
|
620
732
|
// platform's env vars (a Dockerfile sets NODE_ENV=production). Only fall back to
|
|
@@ -622,6 +734,8 @@ const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
|
|
|
622
734
|
const configured = fs.existsSync(ENV_PATH) || process.env.VOLT_ADDONS != null || process.env.NODE_ENV === "production";
|
|
623
735
|
if (process.argv.includes("--studio")) {
|
|
624
736
|
startStudio();
|
|
737
|
+
} else if (process.argv.includes("--logs")) {
|
|
738
|
+
startLogs();
|
|
625
739
|
} else if (editMode || !configured) {
|
|
626
740
|
startSetup();
|
|
627
741
|
} else {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
5
|
+
<title>Logs — Volt</title><meta name="robots" content="noindex" /><link rel="icon" href="/favicon.webp" />
|
|
6
|
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
|
7
|
+
<style>body{background:#0f1115;color:#e7e9ee}pre{background:#0b0d11;color:#cfe3ff;border:1px solid #232a36;border-radius:8px;padding:12px;max-height:72vh;overflow:auto;font-size:12.5px;white-space:pre-wrap}</style>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div class="container-fluid py-3">
|
|
11
|
+
<div class="d-flex gap-2 align-items-center mb-2 flex-wrap">
|
|
12
|
+
<strong class="me-2">Volt logs</strong>
|
|
13
|
+
<select id="src" class="form-select form-select-sm" style="max-width:200px"></select>
|
|
14
|
+
<select id="view" class="form-select form-select-sm" style="max-width:160px"><option value="tail">Raw tail</option><option value="analytics">Analytics</option></select>
|
|
15
|
+
<button id="refresh" class="btn btn-sm btn-outline-secondary">Refresh</button>
|
|
16
|
+
<button id="toggleadd" class="btn btn-sm btn-outline-secondary" title="Add another log source (server, app, mounted/tunneled path)">+ source</button>
|
|
17
|
+
<label class="form-check-label small ms-1"><input id="follow" type="checkbox" class="form-check-input" /> follow</label>
|
|
18
|
+
<input id="filter" class="form-control form-control-sm ms-auto" style="max-width:240px" placeholder="filter…" />
|
|
19
|
+
</div>
|
|
20
|
+
<div id="addbar" class="d-flex gap-2 mb-2" style="display:none">
|
|
21
|
+
<input id="newlabel" class="form-control form-control-sm" style="max-width:160px" placeholder="label (e.g. prod)" />
|
|
22
|
+
<input id="newfile" class="form-control form-control-sm" style="max-width:420px" placeholder="/var/log/nginx/access.log (local, mounted, or shipped path)" />
|
|
23
|
+
<button id="addsrc" class="btn btn-sm btn-primary">Add</button>
|
|
24
|
+
</div>
|
|
25
|
+
<div id="out"></div>
|
|
26
|
+
</div>
|
|
27
|
+
<script type="module" src="/logs.js"></script>
|
|
28
|
+
</body>
|
|
29
|
+
</html>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// logs.js — the --logs viewer client (raw tail + mir-sentinel analytics).
|
|
2
|
+
const $ = (s) => document.querySelector(s);
|
|
3
|
+
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
|
4
|
+
let timer = null;
|
|
5
|
+
|
|
6
|
+
async function loadSources() {
|
|
7
|
+
const { sources = [] } = await (await fetch("/api/sources")).json();
|
|
8
|
+
$("#src").innerHTML = sources.map((s) => `<option value="${esc(s)}">${esc(s)}</option>`).join("") || `<option>(no logs found)</option>`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function render() {
|
|
12
|
+
const src = $("#src").value;
|
|
13
|
+
if (!src) return;
|
|
14
|
+
if ($("#view").value === "analytics") {
|
|
15
|
+
const a = await (await fetch(`/api/analytics?source=${encodeURIComponent(src)}`)).json();
|
|
16
|
+
if (!a.ok) {
|
|
17
|
+
$("#out").innerHTML = `<div class="text-muted small">${esc(a.error || "no analytics")}</div>`;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const tbl = (title, rows) => `<h6 class="mt-3">${title}</h6><table class="table table-dark table-sm mb-0"><tbody>${(rows || []).map(([k, v]) => `<tr><td>${esc(String(k))}</td><td class="text-end">${v}</td></tr>`).join("")}</tbody></table>`;
|
|
21
|
+
$("#out").innerHTML = `<div class="small text-muted mb-2">${a.total} lines · ${a.bots} bot · ${a.attacks} attack</div>` + tbl("Top paths", a.paths) + tbl("Status codes", a.statuses) + tbl("Top IPs", a.ips);
|
|
22
|
+
} else {
|
|
23
|
+
const { lines = [] } = await (await fetch(`/api/tail?source=${encodeURIComponent(src)}&lines=400`)).json();
|
|
24
|
+
const filter = $("#filter").value.toLowerCase();
|
|
25
|
+
const shown = filter ? lines.filter((l) => l.toLowerCase().includes(filter)) : lines;
|
|
26
|
+
const pre = document.createElement("pre");
|
|
27
|
+
pre.textContent = shown.join("\n") || "(empty)";
|
|
28
|
+
$("#out").innerHTML = "";
|
|
29
|
+
$("#out").appendChild(pre);
|
|
30
|
+
pre.scrollTop = pre.scrollHeight;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
$("#toggleadd").onclick = () => {
|
|
35
|
+
const b = $("#addbar");
|
|
36
|
+
b.style.display = b.style.display === "none" ? "flex" : "none";
|
|
37
|
+
};
|
|
38
|
+
$("#addsrc").onclick = async () => {
|
|
39
|
+
const label = $("#newlabel").value.trim();
|
|
40
|
+
const file = $("#newfile").value.trim();
|
|
41
|
+
if (!label || !file) return;
|
|
42
|
+
const r = await (await fetch("/api/source", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ label, file }) })).json();
|
|
43
|
+
if (!r.ok) return alert(r.error || "could not add source");
|
|
44
|
+
$("#newlabel").value = $("#newfile").value = "";
|
|
45
|
+
$("#addbar").style.display = "none";
|
|
46
|
+
await loadSources();
|
|
47
|
+
$("#src").value = label;
|
|
48
|
+
render();
|
|
49
|
+
};
|
|
50
|
+
$("#refresh").onclick = render;
|
|
51
|
+
$("#src").onchange = render;
|
|
52
|
+
$("#view").onchange = render;
|
|
53
|
+
$("#filter").oninput = render;
|
|
54
|
+
$("#follow").onchange = (e) => {
|
|
55
|
+
clearInterval(timer);
|
|
56
|
+
if (e.target.checked) timer = setInterval(render, 3000);
|
|
57
|
+
};
|
|
58
|
+
loadSources().then(render);
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"pm2": "npx --yes pm2 start ecosystem.config.cjs",
|
|
12
12
|
"pm2:restart": "npx --yes pm2 restart ecosystem.config.cjs",
|
|
13
13
|
"pm2:stop": "npx --yes pm2 delete ecosystem.config.cjs",
|
|
14
|
-
"pm2:logs": "npx --yes pm2 logs"
|
|
14
|
+
"pm2:logs": "npx --yes pm2 logs",
|
|
15
|
+
"logs": "node server.js --logs"
|
|
15
16
|
},
|
|
16
17
|
"dependencies": {
|
|
17
18
|
"express": "^4.22.2",
|
|
@@ -13,6 +13,7 @@ import fs from "node:fs";
|
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { spawn, spawnSync } from "node:child_process";
|
|
15
15
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
16
|
+
import os from "node:os";
|
|
16
17
|
import express from "express";
|
|
17
18
|
import { Server as SocketServer } from "socket.io";
|
|
18
19
|
|
|
@@ -614,7 +615,118 @@ async function startStudio() {
|
|
|
614
615
|
});
|
|
615
616
|
}
|
|
616
617
|
|
|
617
|
-
// ---
|
|
618
|
+
// --- `--logs`: a disposable, localhost-only log viewer (its own port, like
|
|
619
|
+
// --studio). Tails pm2 stdout/stderr; with mir-sentinel installed, an Analytics
|
|
620
|
+
// tab parses Apache/nginx access logs (ACCESS_LOG). Shell access is the auth;
|
|
621
|
+
// for a remote box, SSH-tunnel the port. ---
|
|
622
|
+
async function startLogs() {
|
|
623
|
+
loadEnv();
|
|
624
|
+
const PORT = configPort();
|
|
625
|
+
const name = (() => {
|
|
626
|
+
try {
|
|
627
|
+
return JSON.parse(fs.readFileSync(PKG_PATH, "utf8")).name || "app";
|
|
628
|
+
} catch {
|
|
629
|
+
return "app";
|
|
630
|
+
}
|
|
631
|
+
})();
|
|
632
|
+
const logsDir = path.join(os.homedir(), ".pm2", "logs");
|
|
633
|
+
// Sources: pm2 stdout/stderr work out of the box; ACCESS_LOG adds an Apache/nginx
|
|
634
|
+
// log; users add more (other apps/servers/mounted or tunneled paths) via
|
|
635
|
+
// .volt/logs.json, editable here in the viewer. Re-read per request so additions
|
|
636
|
+
// show live. For a remote box, ship its log here or SSH-tunnel + run --logs there.
|
|
637
|
+
const LOGS_JSON = path.join(__dirname, ".volt", "logs.json");
|
|
638
|
+
const readExtra = () => {
|
|
639
|
+
try {
|
|
640
|
+
const a = JSON.parse(fs.readFileSync(LOGS_JSON, "utf8"));
|
|
641
|
+
return Array.isArray(a) ? a.filter((x) => x && x.label && x.file) : [];
|
|
642
|
+
} catch {
|
|
643
|
+
return [];
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
const sources = () => {
|
|
647
|
+
const s = { app: path.join(logsDir, `${name}-out.log`), error: path.join(logsDir, `${name}-error.log`) };
|
|
648
|
+
if (process.env.ACCESS_LOG) s.access = process.env.ACCESS_LOG;
|
|
649
|
+
for (const x of readExtra()) s[x.label] = x.file;
|
|
650
|
+
return s;
|
|
651
|
+
};
|
|
652
|
+
const tail = (f, n) => (f && fs.existsSync(f) ? fs.readFileSync(f, "utf8").split(/\r?\n/).filter(Boolean).slice(-n) : []);
|
|
653
|
+
let parseLine = null;
|
|
654
|
+
try {
|
|
655
|
+
parseLine = (await import("mir-sentinel")).parseLine;
|
|
656
|
+
} catch {
|
|
657
|
+
/* analytics optional */
|
|
658
|
+
}
|
|
659
|
+
const top = (arr, key) => {
|
|
660
|
+
const m = {};
|
|
661
|
+
for (const x of arr) if (x && x[key]) m[x[key]] = (m[x[key]] || 0) + 1;
|
|
662
|
+
return Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
663
|
+
};
|
|
664
|
+
const assets = {
|
|
665
|
+
"/": ["text/html; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "logs.html"))],
|
|
666
|
+
"/logs.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "logs.js"))],
|
|
667
|
+
};
|
|
668
|
+
const json = (res, o) => {
|
|
669
|
+
res.setHeader("Content-Type", "application/json");
|
|
670
|
+
res.end(JSON.stringify(o));
|
|
671
|
+
};
|
|
672
|
+
const server = http.createServer((req, res) => {
|
|
673
|
+
const u = new URL(req.url, "http://localhost");
|
|
674
|
+
const p = u.pathname;
|
|
675
|
+
if (assets[p]) {
|
|
676
|
+
res.setHeader("Content-Type", assets[p][0]);
|
|
677
|
+
return res.end(assets[p][1]);
|
|
678
|
+
}
|
|
679
|
+
if (p === "/api/sources") return json(res, { sources: Object.keys(sources()), extra: readExtra(), analytics: !!parseLine });
|
|
680
|
+
if (p === "/api/tail") {
|
|
681
|
+
const f = sources()[u.searchParams.get("source")];
|
|
682
|
+
return f ? json(res, { ok: true, lines: tail(f, Math.min(2000, Number(u.searchParams.get("lines")) || 300)) }) : json(res, { ok: false });
|
|
683
|
+
}
|
|
684
|
+
if (p === "/api/analytics") {
|
|
685
|
+
if (!parseLine) return json(res, { ok: false, error: "npm i mir-sentinel for analytics" });
|
|
686
|
+
const f = sources()[u.searchParams.get("source")];
|
|
687
|
+
if (!f) return json(res, { ok: false });
|
|
688
|
+
const parsed = tail(f, 5000).map((l) => parseLine(l));
|
|
689
|
+
return json(res, { ok: true, total: parsed.length, paths: top(parsed, "path"), statuses: top(parsed, "status"), ips: top(parsed, "ip"), bots: parsed.filter((x) => x && x.bot).length, attacks: parsed.filter((x) => x && x.attack).length });
|
|
690
|
+
}
|
|
691
|
+
// add/remove a source ("add servers") — written to .volt/logs.json
|
|
692
|
+
if (req.method === "POST" && (p === "/api/source" || p === "/api/source/remove")) {
|
|
693
|
+
let body = "";
|
|
694
|
+
req.on("data", (c) => (body += c));
|
|
695
|
+
req.on("end", () => {
|
|
696
|
+
try {
|
|
697
|
+
const { label, file } = JSON.parse(body || "{}");
|
|
698
|
+
if (!/^[a-z0-9][a-z0-9 _-]*$/i.test(label || "")) throw new Error("label: letters, numbers, spaces, - _");
|
|
699
|
+
let list = readExtra().filter((x) => x.label !== label);
|
|
700
|
+
if (p === "/api/source") list.push({ label, file: String(file || "") });
|
|
701
|
+
fs.mkdirSync(path.dirname(LOGS_JSON), { recursive: true });
|
|
702
|
+
fs.writeFileSync(LOGS_JSON, JSON.stringify(list, null, 2));
|
|
703
|
+
json(res, { ok: true });
|
|
704
|
+
} catch (e) {
|
|
705
|
+
res.statusCode = 400;
|
|
706
|
+
json(res, { ok: false, error: e.message });
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
res.statusCode = 404;
|
|
712
|
+
res.end("not found");
|
|
713
|
+
});
|
|
714
|
+
server.on("error", (e) => {
|
|
715
|
+
if (e.code === "EADDRINUSE") {
|
|
716
|
+
console.error(`\n[volt] Logs port ${PORT} is in use — set CONFIG_PORT in .env or pass --port <n>.`);
|
|
717
|
+
process.exit(1);
|
|
718
|
+
}
|
|
719
|
+
throw e;
|
|
720
|
+
});
|
|
721
|
+
server.listen(PORT, "127.0.0.1", () => {
|
|
722
|
+
const url = `http://localhost:${PORT}`;
|
|
723
|
+
console.log(`\nVolt logs at ${url} (${parseLine ? "analytics on" : "raw tail — npm i mir-sentinel for analytics"})`);
|
|
724
|
+
console.log(" localhost only; for a remote box: ssh -L " + PORT + ":localhost:" + PORT + " you@server");
|
|
725
|
+
openBrowser(url);
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// --- gate: studio / logs / setup (first run, --edit) / the app ---
|
|
618
730
|
const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
|
|
619
731
|
// In production / on a PaaS there's no interactive wizard: config comes from the
|
|
620
732
|
// platform's env vars (a Dockerfile sets NODE_ENV=production). Only fall back to
|
|
@@ -622,6 +734,8 @@ const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
|
|
|
622
734
|
const configured = fs.existsSync(ENV_PATH) || process.env.VOLT_ADDONS != null || process.env.NODE_ENV === "production";
|
|
623
735
|
if (process.argv.includes("--studio")) {
|
|
624
736
|
startStudio();
|
|
737
|
+
} else if (process.argv.includes("--logs")) {
|
|
738
|
+
startLogs();
|
|
625
739
|
} else if (editMode || !configured) {
|
|
626
740
|
startSetup();
|
|
627
741
|
} else {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
5
|
+
<title>Logs — Volt</title><meta name="robots" content="noindex" /><link rel="icon" href="/favicon.webp" />
|
|
6
|
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
|
7
|
+
<style>body{background:#0f1115;color:#e7e9ee}pre{background:#0b0d11;color:#cfe3ff;border:1px solid #232a36;border-radius:8px;padding:12px;max-height:72vh;overflow:auto;font-size:12.5px;white-space:pre-wrap}</style>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div class="container-fluid py-3">
|
|
11
|
+
<div class="d-flex gap-2 align-items-center mb-2 flex-wrap">
|
|
12
|
+
<strong class="me-2">Volt logs</strong>
|
|
13
|
+
<select id="src" class="form-select form-select-sm" style="max-width:200px"></select>
|
|
14
|
+
<select id="view" class="form-select form-select-sm" style="max-width:160px"><option value="tail">Raw tail</option><option value="analytics">Analytics</option></select>
|
|
15
|
+
<button id="refresh" class="btn btn-sm btn-outline-secondary">Refresh</button>
|
|
16
|
+
<button id="toggleadd" class="btn btn-sm btn-outline-secondary" title="Add another log source (server, app, mounted/tunneled path)">+ source</button>
|
|
17
|
+
<label class="form-check-label small ms-1"><input id="follow" type="checkbox" class="form-check-input" /> follow</label>
|
|
18
|
+
<input id="filter" class="form-control form-control-sm ms-auto" style="max-width:240px" placeholder="filter…" />
|
|
19
|
+
</div>
|
|
20
|
+
<div id="addbar" class="d-flex gap-2 mb-2" style="display:none">
|
|
21
|
+
<input id="newlabel" class="form-control form-control-sm" style="max-width:160px" placeholder="label (e.g. prod)" />
|
|
22
|
+
<input id="newfile" class="form-control form-control-sm" style="max-width:420px" placeholder="/var/log/nginx/access.log (local, mounted, or shipped path)" />
|
|
23
|
+
<button id="addsrc" class="btn btn-sm btn-primary">Add</button>
|
|
24
|
+
</div>
|
|
25
|
+
<div id="out"></div>
|
|
26
|
+
</div>
|
|
27
|
+
<script type="module" src="/logs.js"></script>
|
|
28
|
+
</body>
|
|
29
|
+
</html>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// logs.js — the --logs viewer client (raw tail + mir-sentinel analytics).
|
|
2
|
+
const $ = (s) => document.querySelector(s);
|
|
3
|
+
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
|
4
|
+
let timer = null;
|
|
5
|
+
|
|
6
|
+
async function loadSources() {
|
|
7
|
+
const { sources = [] } = await (await fetch("/api/sources")).json();
|
|
8
|
+
$("#src").innerHTML = sources.map((s) => `<option value="${esc(s)}">${esc(s)}</option>`).join("") || `<option>(no logs found)</option>`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function render() {
|
|
12
|
+
const src = $("#src").value;
|
|
13
|
+
if (!src) return;
|
|
14
|
+
if ($("#view").value === "analytics") {
|
|
15
|
+
const a = await (await fetch(`/api/analytics?source=${encodeURIComponent(src)}`)).json();
|
|
16
|
+
if (!a.ok) {
|
|
17
|
+
$("#out").innerHTML = `<div class="text-muted small">${esc(a.error || "no analytics")}</div>`;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const tbl = (title, rows) => `<h6 class="mt-3">${title}</h6><table class="table table-dark table-sm mb-0"><tbody>${(rows || []).map(([k, v]) => `<tr><td>${esc(String(k))}</td><td class="text-end">${v}</td></tr>`).join("")}</tbody></table>`;
|
|
21
|
+
$("#out").innerHTML = `<div class="small text-muted mb-2">${a.total} lines · ${a.bots} bot · ${a.attacks} attack</div>` + tbl("Top paths", a.paths) + tbl("Status codes", a.statuses) + tbl("Top IPs", a.ips);
|
|
22
|
+
} else {
|
|
23
|
+
const { lines = [] } = await (await fetch(`/api/tail?source=${encodeURIComponent(src)}&lines=400`)).json();
|
|
24
|
+
const filter = $("#filter").value.toLowerCase();
|
|
25
|
+
const shown = filter ? lines.filter((l) => l.toLowerCase().includes(filter)) : lines;
|
|
26
|
+
const pre = document.createElement("pre");
|
|
27
|
+
pre.textContent = shown.join("\n") || "(empty)";
|
|
28
|
+
$("#out").innerHTML = "";
|
|
29
|
+
$("#out").appendChild(pre);
|
|
30
|
+
pre.scrollTop = pre.scrollHeight;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
$("#toggleadd").onclick = () => {
|
|
35
|
+
const b = $("#addbar");
|
|
36
|
+
b.style.display = b.style.display === "none" ? "flex" : "none";
|
|
37
|
+
};
|
|
38
|
+
$("#addsrc").onclick = async () => {
|
|
39
|
+
const label = $("#newlabel").value.trim();
|
|
40
|
+
const file = $("#newfile").value.trim();
|
|
41
|
+
if (!label || !file) return;
|
|
42
|
+
const r = await (await fetch("/api/source", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ label, file }) })).json();
|
|
43
|
+
if (!r.ok) return alert(r.error || "could not add source");
|
|
44
|
+
$("#newlabel").value = $("#newfile").value = "";
|
|
45
|
+
$("#addbar").style.display = "none";
|
|
46
|
+
await loadSources();
|
|
47
|
+
$("#src").value = label;
|
|
48
|
+
render();
|
|
49
|
+
};
|
|
50
|
+
$("#refresh").onclick = render;
|
|
51
|
+
$("#src").onchange = render;
|
|
52
|
+
$("#view").onchange = render;
|
|
53
|
+
$("#filter").oninput = render;
|
|
54
|
+
$("#follow").onchange = (e) => {
|
|
55
|
+
clearInterval(timer);
|
|
56
|
+
if (e.target.checked) timer = setInterval(render, 3000);
|
|
57
|
+
};
|
|
58
|
+
loadSources().then(render);
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"pm2": "npx --yes pm2 start ecosystem.config.cjs",
|
|
12
12
|
"pm2:restart": "npx --yes pm2 restart ecosystem.config.cjs",
|
|
13
13
|
"pm2:stop": "npx --yes pm2 delete ecosystem.config.cjs",
|
|
14
|
-
"pm2:logs": "npx --yes pm2 logs"
|
|
14
|
+
"pm2:logs": "npx --yes pm2 logs",
|
|
15
|
+
"logs": "node server.js --logs"
|
|
15
16
|
},
|
|
16
17
|
"dependencies": {
|
|
17
18
|
"express": "^4.22.2",
|
package/templates/docs/server.js
CHANGED
|
@@ -13,6 +13,7 @@ import fs from "node:fs";
|
|
|
13
13
|
import path from "node:path";
|
|
14
14
|
import { spawn, spawnSync } from "node:child_process";
|
|
15
15
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
16
|
+
import os from "node:os";
|
|
16
17
|
import express from "express";
|
|
17
18
|
import { Server as SocketServer } from "socket.io";
|
|
18
19
|
|
|
@@ -614,7 +615,118 @@ async function startStudio() {
|
|
|
614
615
|
});
|
|
615
616
|
}
|
|
616
617
|
|
|
617
|
-
// ---
|
|
618
|
+
// --- `--logs`: a disposable, localhost-only log viewer (its own port, like
|
|
619
|
+
// --studio). Tails pm2 stdout/stderr; with mir-sentinel installed, an Analytics
|
|
620
|
+
// tab parses Apache/nginx access logs (ACCESS_LOG). Shell access is the auth;
|
|
621
|
+
// for a remote box, SSH-tunnel the port. ---
|
|
622
|
+
async function startLogs() {
|
|
623
|
+
loadEnv();
|
|
624
|
+
const PORT = configPort();
|
|
625
|
+
const name = (() => {
|
|
626
|
+
try {
|
|
627
|
+
return JSON.parse(fs.readFileSync(PKG_PATH, "utf8")).name || "app";
|
|
628
|
+
} catch {
|
|
629
|
+
return "app";
|
|
630
|
+
}
|
|
631
|
+
})();
|
|
632
|
+
const logsDir = path.join(os.homedir(), ".pm2", "logs");
|
|
633
|
+
// Sources: pm2 stdout/stderr work out of the box; ACCESS_LOG adds an Apache/nginx
|
|
634
|
+
// log; users add more (other apps/servers/mounted or tunneled paths) via
|
|
635
|
+
// .volt/logs.json, editable here in the viewer. Re-read per request so additions
|
|
636
|
+
// show live. For a remote box, ship its log here or SSH-tunnel + run --logs there.
|
|
637
|
+
const LOGS_JSON = path.join(__dirname, ".volt", "logs.json");
|
|
638
|
+
const readExtra = () => {
|
|
639
|
+
try {
|
|
640
|
+
const a = JSON.parse(fs.readFileSync(LOGS_JSON, "utf8"));
|
|
641
|
+
return Array.isArray(a) ? a.filter((x) => x && x.label && x.file) : [];
|
|
642
|
+
} catch {
|
|
643
|
+
return [];
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
const sources = () => {
|
|
647
|
+
const s = { app: path.join(logsDir, `${name}-out.log`), error: path.join(logsDir, `${name}-error.log`) };
|
|
648
|
+
if (process.env.ACCESS_LOG) s.access = process.env.ACCESS_LOG;
|
|
649
|
+
for (const x of readExtra()) s[x.label] = x.file;
|
|
650
|
+
return s;
|
|
651
|
+
};
|
|
652
|
+
const tail = (f, n) => (f && fs.existsSync(f) ? fs.readFileSync(f, "utf8").split(/\r?\n/).filter(Boolean).slice(-n) : []);
|
|
653
|
+
let parseLine = null;
|
|
654
|
+
try {
|
|
655
|
+
parseLine = (await import("mir-sentinel")).parseLine;
|
|
656
|
+
} catch {
|
|
657
|
+
/* analytics optional */
|
|
658
|
+
}
|
|
659
|
+
const top = (arr, key) => {
|
|
660
|
+
const m = {};
|
|
661
|
+
for (const x of arr) if (x && x[key]) m[x[key]] = (m[x[key]] || 0) + 1;
|
|
662
|
+
return Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
663
|
+
};
|
|
664
|
+
const assets = {
|
|
665
|
+
"/": ["text/html; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "logs.html"))],
|
|
666
|
+
"/logs.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "logs.js"))],
|
|
667
|
+
};
|
|
668
|
+
const json = (res, o) => {
|
|
669
|
+
res.setHeader("Content-Type", "application/json");
|
|
670
|
+
res.end(JSON.stringify(o));
|
|
671
|
+
};
|
|
672
|
+
const server = http.createServer((req, res) => {
|
|
673
|
+
const u = new URL(req.url, "http://localhost");
|
|
674
|
+
const p = u.pathname;
|
|
675
|
+
if (assets[p]) {
|
|
676
|
+
res.setHeader("Content-Type", assets[p][0]);
|
|
677
|
+
return res.end(assets[p][1]);
|
|
678
|
+
}
|
|
679
|
+
if (p === "/api/sources") return json(res, { sources: Object.keys(sources()), extra: readExtra(), analytics: !!parseLine });
|
|
680
|
+
if (p === "/api/tail") {
|
|
681
|
+
const f = sources()[u.searchParams.get("source")];
|
|
682
|
+
return f ? json(res, { ok: true, lines: tail(f, Math.min(2000, Number(u.searchParams.get("lines")) || 300)) }) : json(res, { ok: false });
|
|
683
|
+
}
|
|
684
|
+
if (p === "/api/analytics") {
|
|
685
|
+
if (!parseLine) return json(res, { ok: false, error: "npm i mir-sentinel for analytics" });
|
|
686
|
+
const f = sources()[u.searchParams.get("source")];
|
|
687
|
+
if (!f) return json(res, { ok: false });
|
|
688
|
+
const parsed = tail(f, 5000).map((l) => parseLine(l));
|
|
689
|
+
return json(res, { ok: true, total: parsed.length, paths: top(parsed, "path"), statuses: top(parsed, "status"), ips: top(parsed, "ip"), bots: parsed.filter((x) => x && x.bot).length, attacks: parsed.filter((x) => x && x.attack).length });
|
|
690
|
+
}
|
|
691
|
+
// add/remove a source ("add servers") — written to .volt/logs.json
|
|
692
|
+
if (req.method === "POST" && (p === "/api/source" || p === "/api/source/remove")) {
|
|
693
|
+
let body = "";
|
|
694
|
+
req.on("data", (c) => (body += c));
|
|
695
|
+
req.on("end", () => {
|
|
696
|
+
try {
|
|
697
|
+
const { label, file } = JSON.parse(body || "{}");
|
|
698
|
+
if (!/^[a-z0-9][a-z0-9 _-]*$/i.test(label || "")) throw new Error("label: letters, numbers, spaces, - _");
|
|
699
|
+
let list = readExtra().filter((x) => x.label !== label);
|
|
700
|
+
if (p === "/api/source") list.push({ label, file: String(file || "") });
|
|
701
|
+
fs.mkdirSync(path.dirname(LOGS_JSON), { recursive: true });
|
|
702
|
+
fs.writeFileSync(LOGS_JSON, JSON.stringify(list, null, 2));
|
|
703
|
+
json(res, { ok: true });
|
|
704
|
+
} catch (e) {
|
|
705
|
+
res.statusCode = 400;
|
|
706
|
+
json(res, { ok: false, error: e.message });
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
res.statusCode = 404;
|
|
712
|
+
res.end("not found");
|
|
713
|
+
});
|
|
714
|
+
server.on("error", (e) => {
|
|
715
|
+
if (e.code === "EADDRINUSE") {
|
|
716
|
+
console.error(`\n[volt] Logs port ${PORT} is in use — set CONFIG_PORT in .env or pass --port <n>.`);
|
|
717
|
+
process.exit(1);
|
|
718
|
+
}
|
|
719
|
+
throw e;
|
|
720
|
+
});
|
|
721
|
+
server.listen(PORT, "127.0.0.1", () => {
|
|
722
|
+
const url = `http://localhost:${PORT}`;
|
|
723
|
+
console.log(`\nVolt logs at ${url} (${parseLine ? "analytics on" : "raw tail — npm i mir-sentinel for analytics"})`);
|
|
724
|
+
console.log(" localhost only; for a remote box: ssh -L " + PORT + ":localhost:" + PORT + " you@server");
|
|
725
|
+
openBrowser(url);
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// --- gate: studio / logs / setup (first run, --edit) / the app ---
|
|
618
730
|
const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
|
|
619
731
|
// In production / on a PaaS there's no interactive wizard: config comes from the
|
|
620
732
|
// platform's env vars (a Dockerfile sets NODE_ENV=production). Only fall back to
|
|
@@ -622,6 +734,8 @@ const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
|
|
|
622
734
|
const configured = fs.existsSync(ENV_PATH) || process.env.VOLT_ADDONS != null || process.env.NODE_ENV === "production";
|
|
623
735
|
if (process.argv.includes("--studio")) {
|
|
624
736
|
startStudio();
|
|
737
|
+
} else if (process.argv.includes("--logs")) {
|
|
738
|
+
startLogs();
|
|
625
739
|
} else if (editMode || !configured) {
|
|
626
740
|
startSetup();
|
|
627
741
|
} else {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
5
|
+
<title>Logs — Volt</title><meta name="robots" content="noindex" /><link rel="icon" href="/favicon.webp" />
|
|
6
|
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
|
7
|
+
<style>body{background:#0f1115;color:#e7e9ee}pre{background:#0b0d11;color:#cfe3ff;border:1px solid #232a36;border-radius:8px;padding:12px;max-height:72vh;overflow:auto;font-size:12.5px;white-space:pre-wrap}</style>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div class="container-fluid py-3">
|
|
11
|
+
<div class="d-flex gap-2 align-items-center mb-2 flex-wrap">
|
|
12
|
+
<strong class="me-2">Volt logs</strong>
|
|
13
|
+
<select id="src" class="form-select form-select-sm" style="max-width:200px"></select>
|
|
14
|
+
<select id="view" class="form-select form-select-sm" style="max-width:160px"><option value="tail">Raw tail</option><option value="analytics">Analytics</option></select>
|
|
15
|
+
<button id="refresh" class="btn btn-sm btn-outline-secondary">Refresh</button>
|
|
16
|
+
<button id="toggleadd" class="btn btn-sm btn-outline-secondary" title="Add another log source (server, app, mounted/tunneled path)">+ source</button>
|
|
17
|
+
<label class="form-check-label small ms-1"><input id="follow" type="checkbox" class="form-check-input" /> follow</label>
|
|
18
|
+
<input id="filter" class="form-control form-control-sm ms-auto" style="max-width:240px" placeholder="filter…" />
|
|
19
|
+
</div>
|
|
20
|
+
<div id="addbar" class="d-flex gap-2 mb-2" style="display:none">
|
|
21
|
+
<input id="newlabel" class="form-control form-control-sm" style="max-width:160px" placeholder="label (e.g. prod)" />
|
|
22
|
+
<input id="newfile" class="form-control form-control-sm" style="max-width:420px" placeholder="/var/log/nginx/access.log (local, mounted, or shipped path)" />
|
|
23
|
+
<button id="addsrc" class="btn btn-sm btn-primary">Add</button>
|
|
24
|
+
</div>
|
|
25
|
+
<div id="out"></div>
|
|
26
|
+
</div>
|
|
27
|
+
<script type="module" src="/logs.js"></script>
|
|
28
|
+
</body>
|
|
29
|
+
</html>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// logs.js — the --logs viewer client (raw tail + mir-sentinel analytics).
|
|
2
|
+
const $ = (s) => document.querySelector(s);
|
|
3
|
+
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
|
4
|
+
let timer = null;
|
|
5
|
+
|
|
6
|
+
async function loadSources() {
|
|
7
|
+
const { sources = [] } = await (await fetch("/api/sources")).json();
|
|
8
|
+
$("#src").innerHTML = sources.map((s) => `<option value="${esc(s)}">${esc(s)}</option>`).join("") || `<option>(no logs found)</option>`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function render() {
|
|
12
|
+
const src = $("#src").value;
|
|
13
|
+
if (!src) return;
|
|
14
|
+
if ($("#view").value === "analytics") {
|
|
15
|
+
const a = await (await fetch(`/api/analytics?source=${encodeURIComponent(src)}`)).json();
|
|
16
|
+
if (!a.ok) {
|
|
17
|
+
$("#out").innerHTML = `<div class="text-muted small">${esc(a.error || "no analytics")}</div>`;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const tbl = (title, rows) => `<h6 class="mt-3">${title}</h6><table class="table table-dark table-sm mb-0"><tbody>${(rows || []).map(([k, v]) => `<tr><td>${esc(String(k))}</td><td class="text-end">${v}</td></tr>`).join("")}</tbody></table>`;
|
|
21
|
+
$("#out").innerHTML = `<div class="small text-muted mb-2">${a.total} lines · ${a.bots} bot · ${a.attacks} attack</div>` + tbl("Top paths", a.paths) + tbl("Status codes", a.statuses) + tbl("Top IPs", a.ips);
|
|
22
|
+
} else {
|
|
23
|
+
const { lines = [] } = await (await fetch(`/api/tail?source=${encodeURIComponent(src)}&lines=400`)).json();
|
|
24
|
+
const filter = $("#filter").value.toLowerCase();
|
|
25
|
+
const shown = filter ? lines.filter((l) => l.toLowerCase().includes(filter)) : lines;
|
|
26
|
+
const pre = document.createElement("pre");
|
|
27
|
+
pre.textContent = shown.join("\n") || "(empty)";
|
|
28
|
+
$("#out").innerHTML = "";
|
|
29
|
+
$("#out").appendChild(pre);
|
|
30
|
+
pre.scrollTop = pre.scrollHeight;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
$("#toggleadd").onclick = () => {
|
|
35
|
+
const b = $("#addbar");
|
|
36
|
+
b.style.display = b.style.display === "none" ? "flex" : "none";
|
|
37
|
+
};
|
|
38
|
+
$("#addsrc").onclick = async () => {
|
|
39
|
+
const label = $("#newlabel").value.trim();
|
|
40
|
+
const file = $("#newfile").value.trim();
|
|
41
|
+
if (!label || !file) return;
|
|
42
|
+
const r = await (await fetch("/api/source", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ label, file }) })).json();
|
|
43
|
+
if (!r.ok) return alert(r.error || "could not add source");
|
|
44
|
+
$("#newlabel").value = $("#newfile").value = "";
|
|
45
|
+
$("#addbar").style.display = "none";
|
|
46
|
+
await loadSources();
|
|
47
|
+
$("#src").value = label;
|
|
48
|
+
render();
|
|
49
|
+
};
|
|
50
|
+
$("#refresh").onclick = render;
|
|
51
|
+
$("#src").onchange = render;
|
|
52
|
+
$("#view").onchange = render;
|
|
53
|
+
$("#filter").oninput = render;
|
|
54
|
+
$("#follow").onchange = (e) => {
|
|
55
|
+
clearInterval(timer);
|
|
56
|
+
if (e.target.checked) timer = setInterval(render, 3000);
|
|
57
|
+
};
|
|
58
|
+
loadSources().then(render);
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
"pm2": "npx --yes pm2 start ecosystem.config.cjs",
|
|
12
12
|
"pm2:restart": "npx --yes pm2 restart ecosystem.config.cjs",
|
|
13
13
|
"pm2:stop": "npx --yes pm2 delete ecosystem.config.cjs",
|
|
14
|
-
"pm2:logs": "npx --yes pm2 logs"
|
|
14
|
+
"pm2:logs": "npx --yes pm2 logs",
|
|
15
|
+
"logs": "node server.js --logs"
|
|
15
16
|
},
|
|
16
17
|
"dependencies": {
|
|
17
18
|
"express": "^4.22.2",
|
|
@@ -14,6 +14,7 @@ import path from "node:path";
|
|
|
14
14
|
import crypto from "node:crypto";
|
|
15
15
|
import { spawn, spawnSync } from "node:child_process";
|
|
16
16
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
17
|
+
import os from "node:os";
|
|
17
18
|
import express from "express";
|
|
18
19
|
import { Server as SocketServer } from "socket.io";
|
|
19
20
|
|
|
@@ -640,6 +641,117 @@ async function startStudio() {
|
|
|
640
641
|
});
|
|
641
642
|
}
|
|
642
643
|
|
|
644
|
+
// --- `--logs`: a disposable, localhost-only log viewer (its own port, like
|
|
645
|
+
// --studio). Tails pm2 stdout/stderr; with mir-sentinel installed, an Analytics
|
|
646
|
+
// tab parses Apache/nginx access logs (ACCESS_LOG). Shell access is the auth;
|
|
647
|
+
// for a remote box, SSH-tunnel the port. ---
|
|
648
|
+
async function startLogs() {
|
|
649
|
+
loadEnv();
|
|
650
|
+
const PORT = configPort();
|
|
651
|
+
const name = (() => {
|
|
652
|
+
try {
|
|
653
|
+
return JSON.parse(fs.readFileSync(PKG_PATH, "utf8")).name || "app";
|
|
654
|
+
} catch {
|
|
655
|
+
return "app";
|
|
656
|
+
}
|
|
657
|
+
})();
|
|
658
|
+
const logsDir = path.join(os.homedir(), ".pm2", "logs");
|
|
659
|
+
// Sources: pm2 stdout/stderr work out of the box; ACCESS_LOG adds an Apache/nginx
|
|
660
|
+
// log; users add more (other apps/servers/mounted or tunneled paths) via
|
|
661
|
+
// .volt/logs.json, editable here in the viewer. Re-read per request so additions
|
|
662
|
+
// show live. For a remote box, ship its log here or SSH-tunnel + run --logs there.
|
|
663
|
+
const LOGS_JSON = path.join(__dirname, ".volt", "logs.json");
|
|
664
|
+
const readExtra = () => {
|
|
665
|
+
try {
|
|
666
|
+
const a = JSON.parse(fs.readFileSync(LOGS_JSON, "utf8"));
|
|
667
|
+
return Array.isArray(a) ? a.filter((x) => x && x.label && x.file) : [];
|
|
668
|
+
} catch {
|
|
669
|
+
return [];
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
const sources = () => {
|
|
673
|
+
const s = { app: path.join(logsDir, `${name}-out.log`), error: path.join(logsDir, `${name}-error.log`) };
|
|
674
|
+
if (process.env.ACCESS_LOG) s.access = process.env.ACCESS_LOG;
|
|
675
|
+
for (const x of readExtra()) s[x.label] = x.file;
|
|
676
|
+
return s;
|
|
677
|
+
};
|
|
678
|
+
const tail = (f, n) => (f && fs.existsSync(f) ? fs.readFileSync(f, "utf8").split(/\r?\n/).filter(Boolean).slice(-n) : []);
|
|
679
|
+
let parseLine = null;
|
|
680
|
+
try {
|
|
681
|
+
parseLine = (await import("mir-sentinel")).parseLine;
|
|
682
|
+
} catch {
|
|
683
|
+
/* analytics optional */
|
|
684
|
+
}
|
|
685
|
+
const top = (arr, key) => {
|
|
686
|
+
const m = {};
|
|
687
|
+
for (const x of arr) if (x && x[key]) m[x[key]] = (m[x[key]] || 0) + 1;
|
|
688
|
+
return Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
689
|
+
};
|
|
690
|
+
const assets = {
|
|
691
|
+
"/": ["text/html; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "logs.html"))],
|
|
692
|
+
"/logs.js": ["text/javascript; charset=utf-8", fs.readFileSync(path.join(__dirname, "setup", "logs.js"))],
|
|
693
|
+
};
|
|
694
|
+
const json = (res, o) => {
|
|
695
|
+
res.setHeader("Content-Type", "application/json");
|
|
696
|
+
res.end(JSON.stringify(o));
|
|
697
|
+
};
|
|
698
|
+
const server = http.createServer((req, res) => {
|
|
699
|
+
const u = new URL(req.url, "http://localhost");
|
|
700
|
+
const p = u.pathname;
|
|
701
|
+
if (assets[p]) {
|
|
702
|
+
res.setHeader("Content-Type", assets[p][0]);
|
|
703
|
+
return res.end(assets[p][1]);
|
|
704
|
+
}
|
|
705
|
+
if (p === "/api/sources") return json(res, { sources: Object.keys(sources()), extra: readExtra(), analytics: !!parseLine });
|
|
706
|
+
if (p === "/api/tail") {
|
|
707
|
+
const f = sources()[u.searchParams.get("source")];
|
|
708
|
+
return f ? json(res, { ok: true, lines: tail(f, Math.min(2000, Number(u.searchParams.get("lines")) || 300)) }) : json(res, { ok: false });
|
|
709
|
+
}
|
|
710
|
+
if (p === "/api/analytics") {
|
|
711
|
+
if (!parseLine) return json(res, { ok: false, error: "npm i mir-sentinel for analytics" });
|
|
712
|
+
const f = sources()[u.searchParams.get("source")];
|
|
713
|
+
if (!f) return json(res, { ok: false });
|
|
714
|
+
const parsed = tail(f, 5000).map((l) => parseLine(l));
|
|
715
|
+
return json(res, { ok: true, total: parsed.length, paths: top(parsed, "path"), statuses: top(parsed, "status"), ips: top(parsed, "ip"), bots: parsed.filter((x) => x && x.bot).length, attacks: parsed.filter((x) => x && x.attack).length });
|
|
716
|
+
}
|
|
717
|
+
// add/remove a source ("add servers") — written to .volt/logs.json
|
|
718
|
+
if (req.method === "POST" && (p === "/api/source" || p === "/api/source/remove")) {
|
|
719
|
+
let body = "";
|
|
720
|
+
req.on("data", (c) => (body += c));
|
|
721
|
+
req.on("end", () => {
|
|
722
|
+
try {
|
|
723
|
+
const { label, file } = JSON.parse(body || "{}");
|
|
724
|
+
if (!/^[a-z0-9][a-z0-9 _-]*$/i.test(label || "")) throw new Error("label: letters, numbers, spaces, - _");
|
|
725
|
+
let list = readExtra().filter((x) => x.label !== label);
|
|
726
|
+
if (p === "/api/source") list.push({ label, file: String(file || "") });
|
|
727
|
+
fs.mkdirSync(path.dirname(LOGS_JSON), { recursive: true });
|
|
728
|
+
fs.writeFileSync(LOGS_JSON, JSON.stringify(list, null, 2));
|
|
729
|
+
json(res, { ok: true });
|
|
730
|
+
} catch (e) {
|
|
731
|
+
res.statusCode = 400;
|
|
732
|
+
json(res, { ok: false, error: e.message });
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
res.statusCode = 404;
|
|
738
|
+
res.end("not found");
|
|
739
|
+
});
|
|
740
|
+
server.on("error", (e) => {
|
|
741
|
+
if (e.code === "EADDRINUSE") {
|
|
742
|
+
console.error(`\n[volt] Logs port ${PORT} is in use — set CONFIG_PORT in .env or pass --port <n>.`);
|
|
743
|
+
process.exit(1);
|
|
744
|
+
}
|
|
745
|
+
throw e;
|
|
746
|
+
});
|
|
747
|
+
server.listen(PORT, "127.0.0.1", () => {
|
|
748
|
+
const url = `http://localhost:${PORT}`;
|
|
749
|
+
console.log(`\nVolt logs at ${url} (${parseLine ? "analytics on" : "raw tail — npm i mir-sentinel for analytics"})`);
|
|
750
|
+
console.log(" localhost only; for a remote box: ssh -L " + PORT + ":localhost:" + PORT + " you@server");
|
|
751
|
+
openBrowser(url);
|
|
752
|
+
});
|
|
753
|
+
}
|
|
754
|
+
|
|
643
755
|
// --- gate: studio / setup (first run, --edit) / the app ---
|
|
644
756
|
const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
|
|
645
757
|
// In production / on a PaaS there's no interactive wizard: config comes from the
|
|
@@ -648,6 +760,8 @@ const editMode = process.argv.includes("--edit") || process.argv.includes("-e");
|
|
|
648
760
|
const configured = fs.existsSync(ENV_PATH) || process.env.VOLT_ADDONS != null || process.env.NODE_ENV === "production";
|
|
649
761
|
if (process.argv.includes("--studio")) {
|
|
650
762
|
startStudio();
|
|
763
|
+
} else if (process.argv.includes("--logs")) {
|
|
764
|
+
startLogs();
|
|
651
765
|
} else if (editMode || !configured) {
|
|
652
766
|
startSetup();
|
|
653
767
|
} else {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
5
|
+
<title>Logs — Volt</title><meta name="robots" content="noindex" /><link rel="icon" href="/favicon.webp" />
|
|
6
|
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
|
7
|
+
<style>body{background:#0f1115;color:#e7e9ee}pre{background:#0b0d11;color:#cfe3ff;border:1px solid #232a36;border-radius:8px;padding:12px;max-height:72vh;overflow:auto;font-size:12.5px;white-space:pre-wrap}</style>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div class="container-fluid py-3">
|
|
11
|
+
<div class="d-flex gap-2 align-items-center mb-2 flex-wrap">
|
|
12
|
+
<strong class="me-2">Volt logs</strong>
|
|
13
|
+
<select id="src" class="form-select form-select-sm" style="max-width:200px"></select>
|
|
14
|
+
<select id="view" class="form-select form-select-sm" style="max-width:160px"><option value="tail">Raw tail</option><option value="analytics">Analytics</option></select>
|
|
15
|
+
<button id="refresh" class="btn btn-sm btn-outline-secondary">Refresh</button>
|
|
16
|
+
<button id="toggleadd" class="btn btn-sm btn-outline-secondary" title="Add another log source (server, app, mounted/tunneled path)">+ source</button>
|
|
17
|
+
<label class="form-check-label small ms-1"><input id="follow" type="checkbox" class="form-check-input" /> follow</label>
|
|
18
|
+
<input id="filter" class="form-control form-control-sm ms-auto" style="max-width:240px" placeholder="filter…" />
|
|
19
|
+
</div>
|
|
20
|
+
<div id="addbar" class="d-flex gap-2 mb-2" style="display:none">
|
|
21
|
+
<input id="newlabel" class="form-control form-control-sm" style="max-width:160px" placeholder="label (e.g. prod)" />
|
|
22
|
+
<input id="newfile" class="form-control form-control-sm" style="max-width:420px" placeholder="/var/log/nginx/access.log (local, mounted, or shipped path)" />
|
|
23
|
+
<button id="addsrc" class="btn btn-sm btn-primary">Add</button>
|
|
24
|
+
</div>
|
|
25
|
+
<div id="out"></div>
|
|
26
|
+
</div>
|
|
27
|
+
<script type="module" src="/logs.js"></script>
|
|
28
|
+
</body>
|
|
29
|
+
</html>
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// logs.js — the --logs viewer client (raw tail + mir-sentinel analytics).
|
|
2
|
+
const $ = (s) => document.querySelector(s);
|
|
3
|
+
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
|
4
|
+
let timer = null;
|
|
5
|
+
|
|
6
|
+
async function loadSources() {
|
|
7
|
+
const { sources = [] } = await (await fetch("/api/sources")).json();
|
|
8
|
+
$("#src").innerHTML = sources.map((s) => `<option value="${esc(s)}">${esc(s)}</option>`).join("") || `<option>(no logs found)</option>`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async function render() {
|
|
12
|
+
const src = $("#src").value;
|
|
13
|
+
if (!src) return;
|
|
14
|
+
if ($("#view").value === "analytics") {
|
|
15
|
+
const a = await (await fetch(`/api/analytics?source=${encodeURIComponent(src)}`)).json();
|
|
16
|
+
if (!a.ok) {
|
|
17
|
+
$("#out").innerHTML = `<div class="text-muted small">${esc(a.error || "no analytics")}</div>`;
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const tbl = (title, rows) => `<h6 class="mt-3">${title}</h6><table class="table table-dark table-sm mb-0"><tbody>${(rows || []).map(([k, v]) => `<tr><td>${esc(String(k))}</td><td class="text-end">${v}</td></tr>`).join("")}</tbody></table>`;
|
|
21
|
+
$("#out").innerHTML = `<div class="small text-muted mb-2">${a.total} lines · ${a.bots} bot · ${a.attacks} attack</div>` + tbl("Top paths", a.paths) + tbl("Status codes", a.statuses) + tbl("Top IPs", a.ips);
|
|
22
|
+
} else {
|
|
23
|
+
const { lines = [] } = await (await fetch(`/api/tail?source=${encodeURIComponent(src)}&lines=400`)).json();
|
|
24
|
+
const filter = $("#filter").value.toLowerCase();
|
|
25
|
+
const shown = filter ? lines.filter((l) => l.toLowerCase().includes(filter)) : lines;
|
|
26
|
+
const pre = document.createElement("pre");
|
|
27
|
+
pre.textContent = shown.join("\n") || "(empty)";
|
|
28
|
+
$("#out").innerHTML = "";
|
|
29
|
+
$("#out").appendChild(pre);
|
|
30
|
+
pre.scrollTop = pre.scrollHeight;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
$("#toggleadd").onclick = () => {
|
|
35
|
+
const b = $("#addbar");
|
|
36
|
+
b.style.display = b.style.display === "none" ? "flex" : "none";
|
|
37
|
+
};
|
|
38
|
+
$("#addsrc").onclick = async () => {
|
|
39
|
+
const label = $("#newlabel").value.trim();
|
|
40
|
+
const file = $("#newfile").value.trim();
|
|
41
|
+
if (!label || !file) return;
|
|
42
|
+
const r = await (await fetch("/api/source", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ label, file }) })).json();
|
|
43
|
+
if (!r.ok) return alert(r.error || "could not add source");
|
|
44
|
+
$("#newlabel").value = $("#newfile").value = "";
|
|
45
|
+
$("#addbar").style.display = "none";
|
|
46
|
+
await loadSources();
|
|
47
|
+
$("#src").value = label;
|
|
48
|
+
render();
|
|
49
|
+
};
|
|
50
|
+
$("#refresh").onclick = render;
|
|
51
|
+
$("#src").onchange = render;
|
|
52
|
+
$("#view").onchange = render;
|
|
53
|
+
$("#filter").oninput = render;
|
|
54
|
+
$("#follow").onchange = (e) => {
|
|
55
|
+
clearInterval(timer);
|
|
56
|
+
if (e.target.checked) timer = setInterval(render, 3000);
|
|
57
|
+
};
|
|
58
|
+
loadSources().then(render);
|