@hasna/logs 0.3.26 → 0.3.28
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 +33 -10
- package/dashboard/dist/assets/index-C0wZYq1m.js +53 -0
- package/dashboard/dist/assets/index-DGNrK5qb.css +1 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/cli/index.js +8511 -177
- package/dist/count-bmj4r2zb.js +10 -0
- package/dist/{diagnose-e0w5rwbc.js → diagnose-3q5cy9ra.js} +2 -2
- package/dist/{export-c3eqjste.js → export-cngdb9fh.js} +1 -1
- package/dist/{http-zm3ph78w.js → http-r0xc3d2s.js} +79 -8
- package/dist/index-931pbyn5.js +141 -0
- package/dist/index-b5c72f1p.js +7 -0
- package/dist/{index-7w7v7hnr.js → index-by1pdzbr.js} +14 -5
- package/dist/{index-3dr7d80h.js → index-e1930v9b.js} +12 -8
- package/dist/{index-eh9bkbpa.js → index-e72k53yq.js} +10 -2
- package/dist/{index-edn08m6f.js → index-gcd14q2f.js} +9 -6
- package/dist/index-hq6kzaah.js +26 -0
- package/dist/index-j34f36wy.js +5672 -0
- package/dist/{index-5qznfyah.js → index-q27bgpr1.js} +1086 -1646
- package/dist/index-qk8dbvbc.js +1859 -0
- package/dist/index-t3x838zw.js +2583 -0
- package/dist/{index-gc0zvs88.js → index-y2y0mdtd.js} +596 -37
- package/dist/{index-ww5ggfv3.js → index-zkb3z95a.js} +12 -9
- package/dist/index.js +2990 -22
- package/dist/{jobs-ypmmc2ma.js → jobs-hsgyhfvm.js} +2 -1
- package/dist/mcp/index.js +1473 -4286
- package/dist/{query-7jwj05er.js → query-c5a43zx3.js} +3 -2
- package/dist/server/index.js +2944 -417
- package/dist/storage.js +50 -0
- package/package.json +27 -8
- package/biome.json +0 -13
- package/bun.lock +0 -376
- package/dashboard/README.md +0 -73
- package/dashboard/bun.lock +0 -526
- package/dashboard/eslint.config.js +0 -23
- package/dashboard/index.html +0 -13
- package/dashboard/package.json +0 -32
- package/dashboard/src/App.css +0 -184
- package/dashboard/src/App.tsx +0 -49
- package/dashboard/src/api.ts +0 -33
- package/dashboard/src/assets/hero.png +0 -0
- package/dashboard/src/assets/react.svg +0 -1
- package/dashboard/src/assets/vite.svg +0 -1
- package/dashboard/src/index.css +0 -111
- package/dashboard/src/main.tsx +0 -10
- package/dashboard/src/pages/Alerts.tsx +0 -69
- package/dashboard/src/pages/Issues.tsx +0 -50
- package/dashboard/src/pages/Perf.tsx +0 -75
- package/dashboard/src/pages/Projects.tsx +0 -67
- package/dashboard/src/pages/Summary.tsx +0 -67
- package/dashboard/src/pages/Tail.tsx +0 -65
- package/dashboard/tsconfig.app.json +0 -28
- package/dashboard/tsconfig.json +0 -7
- package/dashboard/tsconfig.node.json +0 -26
- package/dashboard/vite.config.ts +0 -14
- package/dist/count-x3n7qg3c.js +0 -9
- package/dist/index-997bkzr2.js +0 -15
- package/dist/index-pen6t0yc.js +0 -10794
- package/sdk/package.json +0 -27
- package/sdk/src/index.ts +0 -143
- package/sdk/src/types.ts +0 -56
- package/src/cli/entrypoints.test.ts +0 -63
- package/src/cli/index.ts +0 -471
- package/src/db/index.test.ts +0 -33
- package/src/db/index.ts +0 -189
- package/src/db/migrations/001_alert_rules.ts +0 -21
- package/src/db/migrations/002_issues.ts +0 -21
- package/src/db/migrations/003_retention.ts +0 -15
- package/src/db/migrations/004_page_auth.ts +0 -13
- package/src/db/pg-migrations.ts +0 -167
- package/src/index.ts +0 -1
- package/src/lib/alerts.test.ts +0 -67
- package/src/lib/alerts.ts +0 -117
- package/src/lib/browser-script.test.ts +0 -35
- package/src/lib/browser-script.ts +0 -31
- package/src/lib/compare.test.ts +0 -52
- package/src/lib/compare.ts +0 -85
- package/src/lib/count.test.ts +0 -44
- package/src/lib/count.ts +0 -55
- package/src/lib/diagnose.test.ts +0 -55
- package/src/lib/diagnose.ts +0 -91
- package/src/lib/export.test.ts +0 -66
- package/src/lib/export.ts +0 -65
- package/src/lib/github.ts +0 -38
- package/src/lib/health.test.ts +0 -48
- package/src/lib/health.ts +0 -51
- package/src/lib/ingest.test.ts +0 -57
- package/src/lib/ingest.ts +0 -78
- package/src/lib/issues.test.ts +0 -79
- package/src/lib/issues.ts +0 -70
- package/src/lib/jobs.test.ts +0 -69
- package/src/lib/jobs.ts +0 -63
- package/src/lib/lighthouse.ts +0 -65
- package/src/lib/package-meta.test.ts +0 -43
- package/src/lib/package-meta.ts +0 -80
- package/src/lib/page-auth.test.ts +0 -54
- package/src/lib/page-auth.ts +0 -48
- package/src/lib/parse-time.test.ts +0 -37
- package/src/lib/parse-time.ts +0 -14
- package/src/lib/perf.test.ts +0 -45
- package/src/lib/perf.ts +0 -46
- package/src/lib/projects.test.ts +0 -73
- package/src/lib/projects.ts +0 -69
- package/src/lib/query.test.ts +0 -104
- package/src/lib/query.ts +0 -84
- package/src/lib/retention.test.ts +0 -42
- package/src/lib/retention.ts +0 -62
- package/src/lib/rotate.test.ts +0 -37
- package/src/lib/rotate.ts +0 -27
- package/src/lib/scanner.ts +0 -131
- package/src/lib/scheduler.ts +0 -63
- package/src/lib/session-context.ts +0 -28
- package/src/lib/summarize.test.ts +0 -38
- package/src/lib/summarize.ts +0 -23
- package/src/mcp/http.test.ts +0 -92
- package/src/mcp/http.ts +0 -135
- package/src/mcp/index.test.ts +0 -27
- package/src/mcp/index.ts +0 -444
- package/src/server/index.ts +0 -61
- package/src/server/routes/alerts.ts +0 -32
- package/src/server/routes/issues.ts +0 -43
- package/src/server/routes/jobs.ts +0 -32
- package/src/server/routes/logs.ts +0 -113
- package/src/server/routes/perf.ts +0 -23
- package/src/server/routes/projects.ts +0 -67
- package/src/server/routes/stream.ts +0 -43
- package/src/server/server.test.ts +0 -194
- package/src/types/index.ts +0 -119
- package/tsconfig.json +0 -22
- /package/dashboard/{public → dist}/favicon.svg +0 -0
- /package/dashboard/{public → dist}/icons.svg +0 -0
package/src/cli/index.ts
DELETED
|
@@ -1,471 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bun
|
|
2
|
-
import { registerEventsCommands } from "@hasna/events/commander";
|
|
3
|
-
import { Command } from "commander"
|
|
4
|
-
import { getDb } from "../db/index.ts"
|
|
5
|
-
import { ingestLog } from "../lib/ingest.ts"
|
|
6
|
-
import { PACKAGE_VERSION } from "../lib/package-meta.ts"
|
|
7
|
-
import { searchLogs, tailLogs } from "../lib/query.ts"
|
|
8
|
-
import { summarizeLogs } from "../lib/summarize.ts"
|
|
9
|
-
import { createJob, listJobs } from "../lib/jobs.ts"
|
|
10
|
-
import { createPage, createProject, listPages, listProjects, resolveProjectId } from "../lib/projects.ts"
|
|
11
|
-
import { runJob } from "../lib/scheduler.ts"
|
|
12
|
-
import type { LogLevel } from "../types/index.ts"
|
|
13
|
-
|
|
14
|
-
// ── Color helpers ──────────────────────────────────────────
|
|
15
|
-
const C = {
|
|
16
|
-
reset: "\x1b[0m", bold: "\x1b[1m", dim: "\x1b[2m",
|
|
17
|
-
red: "\x1b[31m", yellow: "\x1b[33m", cyan: "\x1b[36m", gray: "\x1b[90m",
|
|
18
|
-
bgRed: "\x1b[41m\x1b[97m", magenta: "\x1b[35m",
|
|
19
|
-
};
|
|
20
|
-
const LEVEL_COLOR: Record<string, string> = {
|
|
21
|
-
fatal: C.bgRed, error: C.red, warn: C.yellow, info: "", debug: C.gray,
|
|
22
|
-
};
|
|
23
|
-
function colorRow(ts: string, level: string, svc: string, msg: string): string {
|
|
24
|
-
const lc = LEVEL_COLOR[level.toLowerCase()] ?? "";
|
|
25
|
-
const isTTY = process.stdout.isTTY;
|
|
26
|
-
if (!isTTY) return `${ts} ${pad(level.toUpperCase(), 5)} ${pad(svc, 12)} ${msg}`;
|
|
27
|
-
return `${C.dim}${ts}${C.reset} ${lc}${C.bold}${pad(level.toUpperCase(), 5)}${C.reset} ${C.cyan}${pad(svc, 12)}${C.reset} ${msg}`;
|
|
28
|
-
}
|
|
29
|
-
function colorLevel(level: string): string {
|
|
30
|
-
if (!process.stdout.isTTY) return pad(level.toUpperCase(), 5);
|
|
31
|
-
const lc = LEVEL_COLOR[level.toLowerCase()] ?? "";
|
|
32
|
-
return `${lc}${C.bold}${pad(level.toUpperCase(), 5)}${C.reset}`;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Resolve a project name or ID from CLI --project flag */
|
|
36
|
-
function resolveProject(nameOrId: string | undefined): string | undefined {
|
|
37
|
-
if (!nameOrId) return undefined;
|
|
38
|
-
return resolveProjectId(getDb(), nameOrId) ?? nameOrId;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const program = new Command()
|
|
42
|
-
.name("logs")
|
|
43
|
-
.description("@hasna/logs — log aggregation and monitoring")
|
|
44
|
-
.version(PACKAGE_VERSION)
|
|
45
|
-
|
|
46
|
-
// ── logs list ──────────────────────────────────────────────
|
|
47
|
-
program.command("list")
|
|
48
|
-
.description("Search and list logs")
|
|
49
|
-
.option("--project <name|id>", "Filter by project name or ID")
|
|
50
|
-
.option("--page <id>", "Filter by page ID")
|
|
51
|
-
.option("--level <levels>", "Comma-separated levels (error,warn,info,debug,fatal)")
|
|
52
|
-
.option("--service <name>", "Filter by service")
|
|
53
|
-
.option("--since <iso>", "Since timestamp or relative (1h, 24h, 7d)")
|
|
54
|
-
.option("--until <iso>", "Until timestamp or relative (e.g. logs list --since 2h --until 1h)")
|
|
55
|
-
.option("--text <query>", "Full-text search")
|
|
56
|
-
.option("--limit <n>", "Max results", "100")
|
|
57
|
-
.option("--format <fmt>", "Output format: table|json|compact", "table")
|
|
58
|
-
.action((opts) => {
|
|
59
|
-
const db = getDb()
|
|
60
|
-
const since = parseRelativeTime(opts.since)
|
|
61
|
-
const until = parseRelativeTime(opts.until)
|
|
62
|
-
const rows = searchLogs(db, {
|
|
63
|
-
project_id: resolveProject(opts.project),
|
|
64
|
-
page_id: opts.page,
|
|
65
|
-
level: opts.level ? (opts.level.split(",") as LogLevel[]) : undefined,
|
|
66
|
-
service: opts.service,
|
|
67
|
-
since,
|
|
68
|
-
until,
|
|
69
|
-
text: opts.text,
|
|
70
|
-
limit: Number(opts.limit),
|
|
71
|
-
})
|
|
72
|
-
if (opts.format === "json") { console.log(JSON.stringify(rows, null, 2)); return }
|
|
73
|
-
if (opts.format === "compact") {
|
|
74
|
-
for (const r of rows) console.log(`${r.timestamp} [${r.level.toUpperCase()}] ${r.service ?? "-"} ${r.message}`)
|
|
75
|
-
return
|
|
76
|
-
}
|
|
77
|
-
for (const r of rows) {
|
|
78
|
-
const meta = r.metadata ? ` ${r.metadata}` : ""
|
|
79
|
-
console.log(`${colorRow(r.timestamp, r.level, r.service ?? "-", r.message)}${meta}`)
|
|
80
|
-
}
|
|
81
|
-
console.log(`\n${rows.length} log(s)`)
|
|
82
|
-
})
|
|
83
|
-
|
|
84
|
-
// ── logs tail ──────────────────────────────────────────────
|
|
85
|
-
program.command("tail")
|
|
86
|
-
.description("Show most recent logs")
|
|
87
|
-
.option("--project <name|id>", "Project name or ID")
|
|
88
|
-
.option("--n <count>", "Number of logs", "50")
|
|
89
|
-
.action((opts) => {
|
|
90
|
-
const rows = tailLogs(getDb(), resolveProject(opts.project), Number(opts.n))
|
|
91
|
-
for (const r of rows) console.log(colorRow(r.timestamp, r.level, r.service ?? "-", r.message))
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
// ── logs summary ──────────────────────────────────────────
|
|
95
|
-
program.command("summary")
|
|
96
|
-
.description("Error/warn summary by service")
|
|
97
|
-
.option("--project <name|id>", "Project name or ID")
|
|
98
|
-
.option("--since <time>", "Relative time (1h, 24h, 7d)", "24h")
|
|
99
|
-
.option("--until <time>", "Upper bound time")
|
|
100
|
-
.action((opts) => {
|
|
101
|
-
const summary = summarizeLogs(getDb(), resolveProject(opts.project), parseRelativeTime(opts.since), parseRelativeTime(opts.until))
|
|
102
|
-
if (!summary.length) { console.log("No errors/warnings in this window."); return }
|
|
103
|
-
for (const s of summary) console.log(`${colorLevel(s.level)} ${C.cyan}${pad(s.service ?? "-", 15)}${C.reset} count=${s.count} latest=${s.latest}`)
|
|
104
|
-
})
|
|
105
|
-
|
|
106
|
-
// ── logs push ─────────────────────────────────────────────
|
|
107
|
-
program.command("push <message>")
|
|
108
|
-
.description("Push a log entry")
|
|
109
|
-
.option("--level <level>", "Log level", "info")
|
|
110
|
-
.option("--service <name>")
|
|
111
|
-
.option("--project <name|id>", "Project name or ID")
|
|
112
|
-
.option("--trace <id>", "Trace ID")
|
|
113
|
-
.action((message, opts) => {
|
|
114
|
-
const row = ingestLog(getDb(), { level: opts.level as LogLevel, message, service: opts.service, project_id: resolveProject(opts.project), trace_id: opts.trace })
|
|
115
|
-
console.log(`Logged: ${row.id}`)
|
|
116
|
-
})
|
|
117
|
-
|
|
118
|
-
// ── logs project ──────────────────────────────────────────
|
|
119
|
-
const projectCmd = program.command("project").description("Manage projects")
|
|
120
|
-
|
|
121
|
-
projectCmd.command("create")
|
|
122
|
-
.option("--name <name>", "Project name")
|
|
123
|
-
.option("--repo <url>", "GitHub repo")
|
|
124
|
-
.option("--url <url>", "Base URL")
|
|
125
|
-
.action((opts) => {
|
|
126
|
-
if (!opts.name) { console.error("--name is required"); process.exit(1) }
|
|
127
|
-
const p = createProject(getDb(), { name: opts.name, github_repo: opts.repo, base_url: opts.url })
|
|
128
|
-
console.log(`Created project: ${p.id} — ${p.name}`)
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
projectCmd.command("list").action(() => {
|
|
132
|
-
const projects = listProjects(getDb())
|
|
133
|
-
for (const p of projects) console.log(`${p.id} ${p.name} ${p.base_url ?? ""} ${p.github_repo ?? ""}`)
|
|
134
|
-
})
|
|
135
|
-
|
|
136
|
-
// ── logs page ─────────────────────────────────────────────
|
|
137
|
-
const pageCmd = program.command("page").description("Manage pages")
|
|
138
|
-
|
|
139
|
-
pageCmd.command("add")
|
|
140
|
-
.option("--project <name|id>", "Project name or ID")
|
|
141
|
-
.option("--url <url>")
|
|
142
|
-
.option("--name <name>")
|
|
143
|
-
.action((opts) => {
|
|
144
|
-
if (!opts.project || !opts.url) { console.error("--project and --url required"); process.exit(1) }
|
|
145
|
-
const p = createPage(getDb(), { project_id: resolveProject(opts.project), url: opts.url, name: opts.name })
|
|
146
|
-
console.log(`Page registered: ${p.id} — ${p.url}`)
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
pageCmd.command("list").option("--project <name|id>", "Project name or ID").action((opts) => {
|
|
150
|
-
if (!opts.project) { console.error("--project required"); process.exit(1) }
|
|
151
|
-
const pages = listPages(getDb(), resolveProject(opts.project))
|
|
152
|
-
for (const p of pages) console.log(`${p.id} ${p.url} last=${p.last_scanned_at ?? "never"}`)
|
|
153
|
-
})
|
|
154
|
-
|
|
155
|
-
// ── logs job ──────────────────────────────────────────────
|
|
156
|
-
const jobCmd = program.command("job").description("Manage scan jobs")
|
|
157
|
-
|
|
158
|
-
jobCmd.command("create")
|
|
159
|
-
.option("--project <name|id>", "Project name or ID")
|
|
160
|
-
.option("--schedule <cron>", "Cron expression", "*/30 * * * *")
|
|
161
|
-
.action((opts) => {
|
|
162
|
-
if (!opts.project) { console.error("--project required"); process.exit(1) }
|
|
163
|
-
const j = createJob(getDb(), { project_id: resolveProject(opts.project), schedule: opts.schedule })
|
|
164
|
-
console.log(`Job created: ${j.id} — ${j.schedule}`)
|
|
165
|
-
})
|
|
166
|
-
|
|
167
|
-
jobCmd.command("list").option("--project <name|id>", "Project name or ID").action((opts) => {
|
|
168
|
-
const jobs = listJobs(getDb(), resolveProject(opts.project))
|
|
169
|
-
for (const j of jobs) console.log(`${j.id} ${j.schedule} enabled=${j.enabled} last=${j.last_run_at ?? "never"}`)
|
|
170
|
-
})
|
|
171
|
-
|
|
172
|
-
// ── logs scan ─────────────────────────────────────────────
|
|
173
|
-
program.command("scan")
|
|
174
|
-
.description("Run an immediate scan for a job")
|
|
175
|
-
.option("--job <id>")
|
|
176
|
-
.option("--project <name|id>", "Project name or ID")
|
|
177
|
-
.action(async (opts) => {
|
|
178
|
-
if (!opts.job) { console.error("--job required"); process.exit(1) }
|
|
179
|
-
const db = getDb()
|
|
180
|
-
const job = (await import("../lib/jobs.ts")).getJob(db, opts.job)
|
|
181
|
-
if (!job) { console.error("Job not found"); process.exit(1) }
|
|
182
|
-
console.log("Running scan...")
|
|
183
|
-
await runJob(db, job.id, job.project_id, job.page_id ?? undefined)
|
|
184
|
-
console.log("Scan complete.")
|
|
185
|
-
})
|
|
186
|
-
|
|
187
|
-
// ── logs diagnose ─────────────────────────────────────────
|
|
188
|
-
program.command("diagnose")
|
|
189
|
-
.description("Health diagnosis: score, top errors, trends, failing pages")
|
|
190
|
-
.option("--project <name|id>", "Project name or ID")
|
|
191
|
-
.option("--since <time>", "Time window (1h, 24h, 7d)", "24h")
|
|
192
|
-
.option("--include <items>", "Comma-separated: top_errors,error_rate,failing_pages,perf")
|
|
193
|
-
.action(async (opts) => {
|
|
194
|
-
const { diagnose } = await import("../lib/diagnose.ts")
|
|
195
|
-
const projectId = resolveProject(opts.project)
|
|
196
|
-
if (!projectId) { console.error("--project required"); process.exit(1) }
|
|
197
|
-
const include = opts.include ? opts.include.split(",") : undefined
|
|
198
|
-
const result = diagnose(getDb(), projectId, opts.since, include)
|
|
199
|
-
const scoreColor = result.health_score >= 80 ? "\x1b[32m" : result.health_score >= 50 ? "\x1b[33m" : "\x1b[31m"
|
|
200
|
-
console.log(`\n${C.bold}Health Score:${C.reset} ${scoreColor}${result.health_score}/100${C.reset}`)
|
|
201
|
-
if (result.top_errors?.length) {
|
|
202
|
-
console.log(`\n${C.bold}Top Errors:${C.reset}`)
|
|
203
|
-
for (const e of result.top_errors) {
|
|
204
|
-
console.log(` ${C.red}${pad(String(e.count), 5)}x${C.reset} ${C.cyan}${pad(e.service ?? "-", 12)}${C.reset} ${e.message}`)
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
if (result.error_rate !== undefined) {
|
|
208
|
-
console.log(`\n${C.bold}Error Rate:${C.reset} ${result.error_rate.toFixed(2)}%`)
|
|
209
|
-
}
|
|
210
|
-
if (result.failing_pages?.length) {
|
|
211
|
-
console.log(`\n${C.bold}Failing Pages:${C.reset}`)
|
|
212
|
-
for (const p of result.failing_pages) console.log(` ${C.red}✗${C.reset} ${p.url} (${p.error_count} errors)`)
|
|
213
|
-
}
|
|
214
|
-
if (result.perf_regressions?.length) {
|
|
215
|
-
console.log(`\n${C.bold}Perf Regressions:${C.reset}`)
|
|
216
|
-
for (const r of result.perf_regressions) console.log(` ${C.yellow}⚠${C.reset} ${r.page_url} p95=${r.p95_ms}ms`)
|
|
217
|
-
}
|
|
218
|
-
console.log("")
|
|
219
|
-
})
|
|
220
|
-
|
|
221
|
-
// ── logs watch ────────────────────────────────────────────
|
|
222
|
-
program.command("watch")
|
|
223
|
-
.description("Stream new logs in real time with color coding")
|
|
224
|
-
.option("--project <name|id>", "Filter by project name or ID")
|
|
225
|
-
.option("--level <levels>", "Comma-separated levels (debug,info,warn,error,fatal)")
|
|
226
|
-
.option("--service <name>", "Filter by service name")
|
|
227
|
-
.option("--interval <ms>", "Poll interval in milliseconds (default: 500)", "500")
|
|
228
|
-
.option("--since <time>", "Start from this time (default: now)")
|
|
229
|
-
.action(async (opts) => {
|
|
230
|
-
const db = getDb()
|
|
231
|
-
const { searchLogs } = await import("../lib/query.ts")
|
|
232
|
-
|
|
233
|
-
// Resolve project name → ID if needed
|
|
234
|
-
let projectId = opts.project
|
|
235
|
-
if (projectId) {
|
|
236
|
-
const proj = db.query("SELECT id FROM projects WHERE id = ? OR name = ?").get(projectId, projectId) as { id: string } | null
|
|
237
|
-
if (proj) projectId = proj.id
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const COLORS: Record<string, string> = {
|
|
241
|
-
debug: "\x1b[90m", info: "\x1b[36m", warn: "\x1b[33m", error: "\x1b[31m", fatal: "\x1b[35m",
|
|
242
|
-
}
|
|
243
|
-
const RESET = "\x1b[0m"
|
|
244
|
-
const BOLD = "\x1b[1m"
|
|
245
|
-
|
|
246
|
-
let lastTimestamp = opts.since ? new Date(opts.since).toISOString() : new Date().toISOString()
|
|
247
|
-
let errorCount = 0
|
|
248
|
-
let warnCount = 0
|
|
249
|
-
const pollIntervalMs = Math.max(100, Number(opts.interval) || 500)
|
|
250
|
-
|
|
251
|
-
process.stdout.write(`\x1b[2J\x1b[H`) // clear screen
|
|
252
|
-
console.log(`${BOLD}@hasna/logs watch${RESET} — Ctrl+C to exit${projectId ? ` [project: ${opts.project}]` : ''}\n`)
|
|
253
|
-
|
|
254
|
-
const poll = () => {
|
|
255
|
-
const rows = searchLogs(db, {
|
|
256
|
-
project_id: projectId,
|
|
257
|
-
level: opts.level ? (opts.level.split(",") as LogLevel[]) : undefined,
|
|
258
|
-
service: opts.service,
|
|
259
|
-
since: lastTimestamp,
|
|
260
|
-
limit: 100,
|
|
261
|
-
}).reverse()
|
|
262
|
-
|
|
263
|
-
for (const row of rows) {
|
|
264
|
-
if (row.timestamp <= lastTimestamp) continue
|
|
265
|
-
lastTimestamp = row.timestamp
|
|
266
|
-
if (row.level === "error" || row.level === "fatal") errorCount++
|
|
267
|
-
if (row.level === "warn") warnCount++
|
|
268
|
-
const color = COLORS[row.level] ?? ""
|
|
269
|
-
const ts = row.timestamp.slice(11, 19)
|
|
270
|
-
const svc = (row.service ?? "-").padEnd(12)
|
|
271
|
-
const lvl = row.level.toUpperCase().padEnd(5)
|
|
272
|
-
console.log(`${color}${ts} ${BOLD}${lvl}${RESET}${color} ${svc} ${row.message}${RESET}`)
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// Update terminal title with counts
|
|
276
|
-
process.stdout.write(`\x1b]2;logs: ${errorCount}E ${warnCount}W\x07`)
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const interval = setInterval(poll, pollIntervalMs)
|
|
280
|
-
process.on("SIGINT", () => { clearInterval(interval); console.log(`\n\nErrors: ${errorCount} Warnings: ${warnCount}`); process.exit(0) })
|
|
281
|
-
})
|
|
282
|
-
|
|
283
|
-
// ── logs count ────────────────────────────────────────────
|
|
284
|
-
program.command("count")
|
|
285
|
-
.description("Count logs with optional breakdown by level or service")
|
|
286
|
-
.option("--project <name|id>", "Project name or ID")
|
|
287
|
-
.option("--service <name>", "Filter by service")
|
|
288
|
-
.option("--level <level>", "Filter by level")
|
|
289
|
-
.option("--since <time>", "Since (1h, 24h, 7d)")
|
|
290
|
-
.option("--until <time>", "Until")
|
|
291
|
-
.option("--group-by <field>", "Breakdown: level | service")
|
|
292
|
-
.action(async (opts) => {
|
|
293
|
-
const { countLogs } = await import("../lib/count.ts")
|
|
294
|
-
const result = countLogs(getDb(), {
|
|
295
|
-
project_id: resolveProject(opts.project),
|
|
296
|
-
service: opts.service,
|
|
297
|
-
level: opts.level,
|
|
298
|
-
since: opts.since,
|
|
299
|
-
until: opts.until,
|
|
300
|
-
group_by: opts.groupBy as "level" | "service" | undefined,
|
|
301
|
-
})
|
|
302
|
-
console.log(`Total: ${result.total} ${C.red}Errors: ${result.errors}${C.reset} ${C.yellow}Warns: ${result.warns}${C.reset} Fatals: ${result.fatals}`)
|
|
303
|
-
if (result.by_service) {
|
|
304
|
-
console.log(`\nBy Service:`)
|
|
305
|
-
for (const [svc, cnt] of Object.entries(result.by_service)) {
|
|
306
|
-
console.log(` ${C.cyan}${pad(svc, 20)}${C.reset} ${cnt}`)
|
|
307
|
-
}
|
|
308
|
-
} else if (opts.groupBy === "level") {
|
|
309
|
-
console.log(`\nBy Level:`)
|
|
310
|
-
for (const [lvl, cnt] of Object.entries(result.by_level)) {
|
|
311
|
-
console.log(` ${colorLevel(lvl)} ${cnt}`)
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
})
|
|
315
|
-
|
|
316
|
-
// ── logs export ───────────────────────────────────────────
|
|
317
|
-
program.command("export")
|
|
318
|
-
.description("Export logs to JSON or CSV")
|
|
319
|
-
.option("--project <name|id>", "Project name or ID")
|
|
320
|
-
.option("--since <time>", "Relative time or ISO")
|
|
321
|
-
.option("--level <level>")
|
|
322
|
-
.option("--service <name>")
|
|
323
|
-
.option("--format <fmt>", "json or csv", "json")
|
|
324
|
-
.option("--output <file>", "Output file (default: stdout)")
|
|
325
|
-
.option("--limit <n>", "Max rows", "100000")
|
|
326
|
-
.action(async (opts) => {
|
|
327
|
-
const { exportToCsv, exportToJson } = await import("../lib/export.ts")
|
|
328
|
-
const { createWriteStream } = await import("node:fs")
|
|
329
|
-
const db = getDb()
|
|
330
|
-
const options = {
|
|
331
|
-
project_id: resolveProject(opts.project),
|
|
332
|
-
since: parseRelativeTime(opts.since),
|
|
333
|
-
level: opts.level,
|
|
334
|
-
service: opts.service,
|
|
335
|
-
limit: Number(opts.limit),
|
|
336
|
-
}
|
|
337
|
-
let count = 0
|
|
338
|
-
if (opts.output) {
|
|
339
|
-
const stream = createWriteStream(opts.output)
|
|
340
|
-
const write = (s: string) => stream.write(s)
|
|
341
|
-
count = opts.format === "csv" ? exportToCsv(db, options, write) : exportToJson(db, options, write)
|
|
342
|
-
stream.end()
|
|
343
|
-
console.error(`Exported ${count} log(s) to ${opts.output}`)
|
|
344
|
-
} else {
|
|
345
|
-
const write = (s: string) => process.stdout.write(s)
|
|
346
|
-
count = opts.format === "csv" ? exportToCsv(db, options, write) : exportToJson(db, options, write)
|
|
347
|
-
process.stderr.write(`\nExported ${count} log(s)\n`)
|
|
348
|
-
}
|
|
349
|
-
})
|
|
350
|
-
|
|
351
|
-
// ── logs stats ────────────────────────────────────────────
|
|
352
|
-
program.command("stats")
|
|
353
|
-
.description("Volume overview: count, DB size, timeline, top services, error rate")
|
|
354
|
-
.option("--project <name|id>", "Scope to a project")
|
|
355
|
-
.action((opts) => {
|
|
356
|
-
const db = getDb()
|
|
357
|
-
const projectId = resolveProject(opts.project)
|
|
358
|
-
const pFilter = projectId ? `WHERE project_id = '${projectId.replace(/'/g, "''")}'` : ""
|
|
359
|
-
const pAnd = projectId ? `AND project_id = '${projectId.replace(/'/g, "''")}'` : ""
|
|
360
|
-
|
|
361
|
-
const total = (db.query(`SELECT COUNT(*) as c FROM logs ${pFilter}`).get() as { c: number }).c
|
|
362
|
-
const oldest = (db.query(`SELECT MIN(timestamp) as t FROM logs ${pFilter}`).get() as { t: string | null }).t
|
|
363
|
-
const newest = (db.query(`SELECT MAX(timestamp) as t FROM logs ${pFilter}`).get() as { t: string | null }).t
|
|
364
|
-
|
|
365
|
-
const byLevel = db.query(`SELECT level, COUNT(*) as c FROM logs ${pFilter} GROUP BY level ORDER BY c DESC`)
|
|
366
|
-
.all() as { level: string; c: number }[]
|
|
367
|
-
|
|
368
|
-
const topServices = db.query(
|
|
369
|
-
`SELECT COALESCE(service, '-') as service, COUNT(*) as c FROM logs ${pFilter} GROUP BY service ORDER BY c DESC LIMIT 5`
|
|
370
|
-
).all() as { service: string; c: number }[]
|
|
371
|
-
|
|
372
|
-
// Last 7 days histogram
|
|
373
|
-
const days = db.query(
|
|
374
|
-
`SELECT strftime('%Y-%m-%d', timestamp) as day, COUNT(*) as c FROM logs WHERE timestamp >= datetime('now', '-7 days') ${pAnd} GROUP BY day ORDER BY day`
|
|
375
|
-
).all() as { day: string; c: number }[]
|
|
376
|
-
|
|
377
|
-
const errors = byLevel.find(r => r.level === "error")?.c ?? 0
|
|
378
|
-
const fatals = byLevel.find(r => r.level === "fatal")?.c ?? 0
|
|
379
|
-
const errorRate = total > 0 ? (((errors + fatals) / total) * 100).toFixed(2) : "0.00"
|
|
380
|
-
|
|
381
|
-
console.log(`\n${C.bold}Log Volume Stats${C.reset}${projectId ? ` [${opts.project}]` : ""}`)
|
|
382
|
-
console.log(` Total: ${total.toLocaleString()}`)
|
|
383
|
-
console.log(` Oldest: ${oldest?.slice(0, 19) ?? "-"}`)
|
|
384
|
-
console.log(` Newest: ${newest?.slice(0, 19) ?? "-"}`)
|
|
385
|
-
console.log(` Error rate: ${errorRate}% (${errors} errors, ${fatals} fatals)`)
|
|
386
|
-
|
|
387
|
-
if (byLevel.length) {
|
|
388
|
-
console.log(`\n${C.bold}By Level:${C.reset}`)
|
|
389
|
-
for (const r of byLevel) console.log(` ${colorLevel(r.level)} ${r.c.toLocaleString()}`)
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
if (topServices.length) {
|
|
393
|
-
console.log(`\n${C.bold}Top Services:${C.reset}`)
|
|
394
|
-
for (const r of topServices) console.log(` ${C.cyan}${pad(r.service, 20)}${C.reset} ${r.c.toLocaleString()}`)
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
if (days.length) {
|
|
398
|
-
const maxC = Math.max(...days.map(d => d.c))
|
|
399
|
-
console.log(`\n${C.bold}Last 7 Days:${C.reset}`)
|
|
400
|
-
for (const d of days) {
|
|
401
|
-
const bar = "█".repeat(Math.max(1, Math.round((d.c / maxC) * 20)))
|
|
402
|
-
console.log(` ${d.day} ${C.cyan}${bar}${C.reset} ${d.c.toLocaleString()}`)
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
console.log("")
|
|
406
|
-
})
|
|
407
|
-
|
|
408
|
-
// ── logs health ───────────────────────────────────────────
|
|
409
|
-
program.command("health")
|
|
410
|
-
.description("Show server health and DB stats")
|
|
411
|
-
.action(async () => {
|
|
412
|
-
const { getHealth } = await import("../lib/health.ts")
|
|
413
|
-
const h = getHealth(getDb())
|
|
414
|
-
console.log(JSON.stringify(h, null, 2))
|
|
415
|
-
})
|
|
416
|
-
|
|
417
|
-
// ── logs mcp / logs serve ─────────────────────────────────
|
|
418
|
-
program.command("mcp")
|
|
419
|
-
.description("Start the MCP server")
|
|
420
|
-
.option("--claude", "Install into Claude Code")
|
|
421
|
-
.option("--codex", "Install into Codex")
|
|
422
|
-
.option("--gemini", "Install into Gemini")
|
|
423
|
-
.action(async (opts) => {
|
|
424
|
-
if (opts.claude || opts.codex || opts.gemini) {
|
|
425
|
-
const { execSync } = await import("node:child_process")
|
|
426
|
-
// Resolve the MCP binary path — works from both source and dist
|
|
427
|
-
const selfPath = process.argv[1] ?? new URL(import.meta.url).pathname
|
|
428
|
-
const mcpBin = selfPath.replace(/cli\/index\.(ts|js)$/, "mcp/index.$1")
|
|
429
|
-
const runtime = process.execPath // bun or node
|
|
430
|
-
|
|
431
|
-
if (opts.claude) {
|
|
432
|
-
const cmd = `claude mcp add --transport stdio --scope user logs -- ${runtime} ${mcpBin}`
|
|
433
|
-
console.log(`Running: ${cmd}`)
|
|
434
|
-
execSync(cmd, { stdio: "inherit" })
|
|
435
|
-
console.log("✓ Installed logs-mcp into Claude Code")
|
|
436
|
-
}
|
|
437
|
-
if (opts.codex) {
|
|
438
|
-
const config = `[mcp_servers.logs]\ncommand = "${runtime}"\nargs = ["${mcpBin}"]`
|
|
439
|
-
console.log("Add to ~/.codex/config.toml:\n\n" + config)
|
|
440
|
-
}
|
|
441
|
-
if (opts.gemini) {
|
|
442
|
-
const config = JSON.stringify({ mcpServers: { logs: { command: runtime, args: [mcpBin] } } }, null, 2)
|
|
443
|
-
console.log("Add to ~/.gemini/settings.json mcpServers:\n\n" + config)
|
|
444
|
-
}
|
|
445
|
-
return
|
|
446
|
-
}
|
|
447
|
-
await import("../mcp/index.ts")
|
|
448
|
-
})
|
|
449
|
-
|
|
450
|
-
program.command("serve")
|
|
451
|
-
.description("Start the REST API server")
|
|
452
|
-
.option("--port <n>", "Port", "3460")
|
|
453
|
-
.action(async (opts) => {
|
|
454
|
-
process.env.LOGS_PORT = opts.port
|
|
455
|
-
await import("../server/index.ts")
|
|
456
|
-
})
|
|
457
|
-
|
|
458
|
-
// ── helpers ───────────────────────────────────────────────
|
|
459
|
-
function pad(s: string, n: number) { return s.padEnd(n) }
|
|
460
|
-
|
|
461
|
-
function parseRelativeTime(val?: string): string | undefined {
|
|
462
|
-
if (!val) return undefined
|
|
463
|
-
const m = val.match(/^(\d+)(h|d|m)$/)
|
|
464
|
-
if (!m) return val
|
|
465
|
-
const [, n, unit] = m
|
|
466
|
-
const ms = Number(n) * (unit === "h" ? 3600 : unit === "d" ? 86400 : 60) * 1000
|
|
467
|
-
return new Date(Date.now() - ms).toISOString()
|
|
468
|
-
}
|
|
469
|
-
registerEventsCommands(program, { source: "logs" });
|
|
470
|
-
|
|
471
|
-
program.parse()
|
package/src/db/index.test.ts
DELETED
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test"
|
|
2
|
-
import { createTestDb } from "./index.ts"
|
|
3
|
-
|
|
4
|
-
describe("db migrations", () => {
|
|
5
|
-
it("creates all tables", () => {
|
|
6
|
-
const db = createTestDb()
|
|
7
|
-
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[]
|
|
8
|
-
const names = tables.map(t => t.name)
|
|
9
|
-
expect(names).toContain("projects")
|
|
10
|
-
expect(names).toContain("pages")
|
|
11
|
-
expect(names).toContain("logs")
|
|
12
|
-
expect(names).toContain("scan_jobs")
|
|
13
|
-
expect(names).toContain("scan_runs")
|
|
14
|
-
expect(names).toContain("performance_snapshots")
|
|
15
|
-
expect(names).toContain("logs_fts")
|
|
16
|
-
})
|
|
17
|
-
|
|
18
|
-
it("creates indexes", () => {
|
|
19
|
-
const db = createTestDb()
|
|
20
|
-
const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index'").all() as { name: string }[]
|
|
21
|
-
const names = indexes.map(i => i.name)
|
|
22
|
-
expect(names).toContain("idx_logs_project_level_ts")
|
|
23
|
-
expect(names).toContain("idx_logs_trace")
|
|
24
|
-
expect(names).toContain("idx_logs_service")
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
it("is idempotent (migrate twice)", () => {
|
|
28
|
-
const db = createTestDb()
|
|
29
|
-
expect(() => {
|
|
30
|
-
db.run("CREATE TABLE IF NOT EXISTS projects (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, github_repo TEXT, base_url TEXT, description TEXT, github_description TEXT, github_branch TEXT, github_sha TEXT, last_synced_at TEXT, created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')))")
|
|
31
|
-
}).not.toThrow()
|
|
32
|
-
})
|
|
33
|
-
})
|