volt-addon-logs 0.1.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/README.md +34 -0
- package/index.js +119 -0
- package/package.json +13 -0
- package/public/logs.js +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# volt-addon-logs
|
|
2
|
+
|
|
3
|
+
A gated **log viewer** for [Volt](https://voltjs.com): tail your app's pm2
|
|
4
|
+
stdout/stderr, and — with [`mir-sentinel`](https://www.npmjs.com/package/mir-sentinel) —
|
|
5
|
+
parse Apache/nginx access logs (or request lines in pm2 stdout) into analytics.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
npm install volt-addon-logs # tail pm2 logs
|
|
11
|
+
npm install mir-sentinel # optional: enables the Analytics tab
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Enable it by adding `logs` to `VOLT_ADDONS` in `.env`.
|
|
15
|
+
|
|
16
|
+
## Security (same model as the editor)
|
|
17
|
+
|
|
18
|
+
Mounts **only** if `ADMIN_PATH` is set (fail-closed), behind magic-link auth + an
|
|
19
|
+
`ADMIN_EMAILS` allowlist. Logs leak information — never expose them
|
|
20
|
+
unauthenticated. The viewer lives at `/<ADMIN_PATH>/logs`.
|
|
21
|
+
|
|
22
|
+
## Log sources (fixed — no arbitrary paths)
|
|
23
|
+
|
|
24
|
+
| Source | File |
|
|
25
|
+
| --- | --- |
|
|
26
|
+
| `app` | `~/.pm2/logs/<app>-out.log` (pm2 stdout) |
|
|
27
|
+
| `error` | `~/.pm2/logs/<app>-error.log` (pm2 stderr) |
|
|
28
|
+
| `access` | `ACCESS_LOG` env (an Apache/nginx access log), if set |
|
|
29
|
+
|
|
30
|
+
- **Raw tail** — last N lines, with a filter box and a "follow" toggle.
|
|
31
|
+
- **Analytics** — runs lines through `mir-sentinel`'s `parseLine` → top paths,
|
|
32
|
+
status codes, IPs, and bot/attack counts. The parser is format-tolerant, so it
|
|
33
|
+
handles Apache combined, nginx, and request lines in pm2 stdout. The tab is
|
|
34
|
+
hidden until `mir-sentinel` is installed.
|
package/index.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// volt-addon-logs — a gated log viewer for Volt.
|
|
2
|
+
//
|
|
3
|
+
// Sources (no arbitrary paths — fixed, safe set):
|
|
4
|
+
// app → ~/.pm2/logs/<app>-out.log (pm2 stdout)
|
|
5
|
+
// error → ~/.pm2/logs/<app>-error.log (pm2 stderr)
|
|
6
|
+
// access → process.env.ACCESS_LOG (an Apache/nginx access log, if set)
|
|
7
|
+
//
|
|
8
|
+
// "App"/"error" tail raw lines. "Analytics" runs lines through mir-sentinel's
|
|
9
|
+
// parseLine (optional dependency) → top paths / status / IPs + bot/attack counts;
|
|
10
|
+
// works for access logs AND request lines in pm2 stdout, since the parser is
|
|
11
|
+
// format-tolerant.
|
|
12
|
+
//
|
|
13
|
+
// Security: mounts ONLY if ADMIN_PATH is set (fail-closed), behind magic-link auth
|
|
14
|
+
// + an ADMIN_EMAILS allowlist. Logs leak info — never expose them unauthenticated.
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
|
|
22
|
+
function appName() {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(fs.readFileSync(path.join(process.cwd(), "package.json"), "utf8")).name || "app";
|
|
25
|
+
} catch {
|
|
26
|
+
return "app";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function sources(env) {
|
|
30
|
+
const logs = path.join(os.homedir(), ".pm2", "logs");
|
|
31
|
+
const name = appName();
|
|
32
|
+
const out = { app: path.join(logs, `${name}-out.log`), error: path.join(logs, `${name}-error.log`) };
|
|
33
|
+
if (env.ACCESS_LOG) out.access = env.ACCESS_LOG;
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
function tail(file, n) {
|
|
37
|
+
if (!file || !fs.existsSync(file)) return [];
|
|
38
|
+
return fs.readFileSync(file, "utf8").split(/\r?\n/).filter(Boolean).slice(-n);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// optional: mir-sentinel's parseLine (Apache/nginx/pm2 request-line analytics)
|
|
42
|
+
let parserCache;
|
|
43
|
+
async function getParser() {
|
|
44
|
+
if (parserCache !== undefined) return parserCache;
|
|
45
|
+
try {
|
|
46
|
+
parserCache = (await import("mir-sentinel")).parseLine || null;
|
|
47
|
+
} catch {
|
|
48
|
+
parserCache = null;
|
|
49
|
+
}
|
|
50
|
+
return parserCache;
|
|
51
|
+
}
|
|
52
|
+
function rollup(parsed) {
|
|
53
|
+
const top = (key) => {
|
|
54
|
+
const m = {};
|
|
55
|
+
for (const p of parsed) if (p && p[key]) m[p[key]] = (m[p[key]] || 0) + 1;
|
|
56
|
+
return Object.entries(m).sort((a, b) => b[1] - a[1]).slice(0, 10);
|
|
57
|
+
};
|
|
58
|
+
return {
|
|
59
|
+
total: parsed.length,
|
|
60
|
+
paths: top("path"),
|
|
61
|
+
statuses: top("status"),
|
|
62
|
+
ips: top("ip"),
|
|
63
|
+
bots: parsed.filter((p) => p && p.bot).length,
|
|
64
|
+
attacks: parsed.filter((p) => p && p.attack).length,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const HTML = (base, hasParser) => `<!doctype html><html lang="en"><head>
|
|
69
|
+
<meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
70
|
+
<title>Logs — Volt</title><meta name="robots" content="noindex" /><link rel="icon" href="/favicon.webp" />
|
|
71
|
+
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" />
|
|
72
|
+
<style>body{background:#0f1115;color:#e7e9ee}pre{background:#0b0d11;color:#cfe3ff;border:1px solid #232a36;border-radius:8px;padding:12px;max-height:70vh;overflow:auto;font-size:12.5px}</style>
|
|
73
|
+
<script>window.__LOGS_BASE=${JSON.stringify(base)};window.__HAS_PARSER=${hasParser};</script>
|
|
74
|
+
</head><body><div class="container-fluid py-3">
|
|
75
|
+
<div class="d-flex gap-2 align-items-center mb-2">
|
|
76
|
+
<strong class="me-2">Logs</strong>
|
|
77
|
+
<select id="src" class="form-select form-select-sm" style="max-width:200px"></select>
|
|
78
|
+
<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>
|
|
79
|
+
<button id="refresh" class="btn btn-sm btn-outline-secondary">Refresh</button>
|
|
80
|
+
<label class="form-check-label small ms-2"><input id="follow" type="checkbox" class="form-check-input" /> follow</label>
|
|
81
|
+
<input id="filter" class="form-control form-control-sm ms-auto" style="max-width:240px" placeholder="filter…" />
|
|
82
|
+
</div>
|
|
83
|
+
<div id="out"></div>
|
|
84
|
+
</div>
|
|
85
|
+
<script type="module" src="${base}/logs.js"></script>
|
|
86
|
+
</body></html>`;
|
|
87
|
+
|
|
88
|
+
export function register({ app, env, requireAuth, log }) {
|
|
89
|
+
const raw = (env.ADMIN_PATH || "").trim();
|
|
90
|
+
if (!raw) return log("ADMIN_PATH not set — logs viewer disabled (fail-closed).");
|
|
91
|
+
if (!requireAuth) return log("auth add-on is required for the logs viewer — disabled.");
|
|
92
|
+
const base = "/" + raw.replace(/^\/+|\/+$/g, "") + "/logs";
|
|
93
|
+
const allow = new Set(String(env.ADMIN_EMAILS || "").split(",").map((s) => s.trim().toLowerCase()).filter(Boolean));
|
|
94
|
+
const gate = [
|
|
95
|
+
requireAuth,
|
|
96
|
+
(req, res, next) => {
|
|
97
|
+
if (allow.size && !allow.has(String(req.user?.email || "").toLowerCase())) return res.status(403).type("html").send("Not authorized.");
|
|
98
|
+
next();
|
|
99
|
+
},
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
app.get(base, gate, async (_req, res) => res.type("html").send(HTML(base, !!(await getParser()))));
|
|
103
|
+
app.get(base + "/logs.js", gate, (_req, res) => res.type("js").sendFile(path.join(__dirname, "public", "logs.js")));
|
|
104
|
+
app.get(base + "/api/sources", gate, async (_req, res) => res.json({ sources: Object.keys(sources(env)), analytics: !!(await getParser()) }));
|
|
105
|
+
app.get(base + "/api/tail", gate, (req, res) => {
|
|
106
|
+
const file = sources(env)[req.query.source];
|
|
107
|
+
if (!file) return res.status(400).json({ ok: false, error: "unknown source" });
|
|
108
|
+
res.json({ ok: true, lines: tail(file, Math.min(2000, Number(req.query.lines) || 300)) });
|
|
109
|
+
});
|
|
110
|
+
app.get(base + "/api/analytics", gate, async (req, res) => {
|
|
111
|
+
const parseLine = await getParser();
|
|
112
|
+
if (!parseLine) return res.json({ ok: false, error: "install mir-sentinel for analytics" });
|
|
113
|
+
const file = sources(env)[req.query.source];
|
|
114
|
+
if (!file) return res.status(400).json({ ok: false, error: "unknown source" });
|
|
115
|
+
res.json({ ok: true, ...rollup(tail(file, 5000).map((l) => parseLine(l))) });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
log(`logs viewer at ${base} — analytics: ${parserCache ? "on" : "install mir-sentinel"}; allowlist: ${allow.size ? [...allow].join(", ") : "(any signed-in user — set ADMIN_EMAILS!)"}`);
|
|
119
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "volt-addon-logs",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A gated log viewer for Volt: tail pm2 stdout/stderr, and (with mir-sentinel) parse Apache/nginx access logs into analytics. Behind a secret ADMIN_PATH + magic-link auth + an allowlist.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"keywords": ["volt", "volt-addon", "logs", "pm2", "log-viewer", "mir-sentinel"],
|
|
8
|
+
"files": ["index.js", "public"],
|
|
9
|
+
"optionalDependencies": {
|
|
10
|
+
"mir-sentinel": "^1.0.0"
|
|
11
|
+
},
|
|
12
|
+
"license": "MIT"
|
|
13
|
+
}
|
package/public/logs.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// logs.js — the log viewer client (raw tail + mir-sentinel analytics).
|
|
2
|
+
const base = window.__LOGS_BASE || "";
|
|
3
|
+
const $ = (s) => document.querySelector(s);
|
|
4
|
+
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
|
5
|
+
let timer = null;
|
|
6
|
+
|
|
7
|
+
async function loadSources() {
|
|
8
|
+
const { sources = [] } = await (await fetch(base + "/api/sources")).json();
|
|
9
|
+
$("#src").innerHTML = sources.map((s) => `<option value="${esc(s)}">${esc(s)}</option>`).join("") || `<option>(no logs found)</option>`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function render() {
|
|
13
|
+
const src = $("#src").value;
|
|
14
|
+
if (!src) return;
|
|
15
|
+
if ($("#view").value === "analytics") {
|
|
16
|
+
const a = await (await fetch(`${base}/api/analytics?source=${encodeURIComponent(src)}`)).json();
|
|
17
|
+
if (!a.ok) {
|
|
18
|
+
$("#out").innerHTML = `<div class="text-muted small">${esc(a.error || "no analytics")}</div>`;
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
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>`;
|
|
22
|
+
$("#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);
|
|
23
|
+
} else {
|
|
24
|
+
const { lines = [] } = await (await fetch(`${base}/api/tail?source=${encodeURIComponent(src)}&lines=400`)).json();
|
|
25
|
+
const filter = $("#filter").value.toLowerCase();
|
|
26
|
+
const shown = filter ? lines.filter((l) => l.toLowerCase().includes(filter)) : lines;
|
|
27
|
+
const pre = document.createElement("pre");
|
|
28
|
+
pre.textContent = shown.join("\n") || "(empty)";
|
|
29
|
+
$("#out").innerHTML = "";
|
|
30
|
+
$("#out").appendChild(pre);
|
|
31
|
+
pre.scrollTop = pre.scrollHeight;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
$("#refresh").onclick = render;
|
|
36
|
+
$("#src").onchange = render;
|
|
37
|
+
$("#view").onchange = render;
|
|
38
|
+
$("#filter").oninput = render;
|
|
39
|
+
$("#follow").onchange = (e) => {
|
|
40
|
+
clearInterval(timer);
|
|
41
|
+
if (e.target.checked) timer = setInterval(render, 3000);
|
|
42
|
+
};
|
|
43
|
+
loadSources().then(render);
|