@hasna/logs 0.3.25 → 0.3.27
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-p1vgwwsz.js → index-bnr19y0h.js} +596 -37
- 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-p4dbdzx4.js +1849 -0
- package/dist/{index-5qznfyah.js → index-q27bgpr1.js} +1086 -1646
- package/dist/index-t3x838zw.js +2583 -0
- package/dist/{index-ww5ggfv3.js → index-zkb3z95a.js} +12 -9
- package/dist/index.js +2982 -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-5cj74qka.js +0 -10803
- package/dist/index-997bkzr2.js +0 -15
- package/dist/index-kezb178p.js +0 -1241
- 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/lib/rotate.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import type { Database } from "bun:sqlite"
|
|
2
|
-
|
|
3
|
-
const DEFAULT_MAX_ROWS = 100_000
|
|
4
|
-
|
|
5
|
-
export function rotateLogs(db: Database, maxRows = DEFAULT_MAX_ROWS): number {
|
|
6
|
-
const total = (db.prepare("SELECT COUNT(*) as c FROM logs").get() as { c: number }).c
|
|
7
|
-
if (total <= maxRows) return 0
|
|
8
|
-
const toDelete = total - maxRows
|
|
9
|
-
db.prepare(`
|
|
10
|
-
DELETE FROM logs WHERE id IN (
|
|
11
|
-
SELECT id FROM logs ORDER BY timestamp ASC LIMIT ${toDelete}
|
|
12
|
-
)
|
|
13
|
-
`).run()
|
|
14
|
-
return toDelete
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function rotateByProject(db: Database, projectId: string, maxRows = DEFAULT_MAX_ROWS): number {
|
|
18
|
-
const total = (db.prepare("SELECT COUNT(*) as c FROM logs WHERE project_id = $p").get({ $p: projectId }) as { c: number }).c
|
|
19
|
-
if (total <= maxRows) return 0
|
|
20
|
-
const toDelete = total - maxRows
|
|
21
|
-
db.prepare(`
|
|
22
|
-
DELETE FROM logs WHERE id IN (
|
|
23
|
-
SELECT id FROM logs WHERE project_id = $p ORDER BY timestamp ASC LIMIT ${toDelete}
|
|
24
|
-
)
|
|
25
|
-
`).run({ $p: projectId })
|
|
26
|
-
return toDelete
|
|
27
|
-
}
|
package/src/lib/scanner.ts
DELETED
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
import type { Database } from "bun:sqlite"
|
|
2
|
-
import { ingestBatch } from "./ingest.ts"
|
|
3
|
-
import { getPageAuth } from "./page-auth.ts"
|
|
4
|
-
import { saveSnapshot } from "./perf.ts"
|
|
5
|
-
import { getPage, touchPage } from "./projects.ts"
|
|
6
|
-
import type { LogEntry } from "../types/index.ts"
|
|
7
|
-
|
|
8
|
-
export interface ScanResult {
|
|
9
|
-
logsCollected: number
|
|
10
|
-
errorsFound: number
|
|
11
|
-
perfScore: number | null
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export async function scanPage(db: Database, projectId: string, pageId: string, urlOverride?: string): Promise<ScanResult> {
|
|
15
|
-
const page = getPage(db, pageId)
|
|
16
|
-
const url = urlOverride || page?.url
|
|
17
|
-
if (!url) throw new Error(`No URL for page ${pageId}`)
|
|
18
|
-
|
|
19
|
-
const { chromium } = await import("playwright")
|
|
20
|
-
const browser = await chromium.launch({ headless: true })
|
|
21
|
-
|
|
22
|
-
// Apply page auth if configured
|
|
23
|
-
const auth = getPageAuth(db, pageId)
|
|
24
|
-
const contextOptions: Parameters<typeof browser.newContext>[0] = {
|
|
25
|
-
userAgent: "Mozilla/5.0 (@hasna/logs scanner) AppleWebKit/537.36",
|
|
26
|
-
}
|
|
27
|
-
if (auth?.type === "cookie") {
|
|
28
|
-
try { contextOptions.storageState = JSON.parse(auth.credentials) } catch { /* invalid */ }
|
|
29
|
-
} else if (auth?.type === "basic") {
|
|
30
|
-
const [username, password] = auth.credentials.split(":")
|
|
31
|
-
contextOptions.httpCredentials = { username: username ?? "", password: password ?? "" }
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const context = await browser.newContext(contextOptions)
|
|
35
|
-
|
|
36
|
-
if (auth?.type === "bearer") {
|
|
37
|
-
await context.route("**/*", (route) => {
|
|
38
|
-
route.continue({ headers: { ...route.request().headers(), Authorization: `Bearer ${auth.credentials}` } })
|
|
39
|
-
})
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const browserPage = await context.newPage()
|
|
43
|
-
|
|
44
|
-
const collected: LogEntry[] = []
|
|
45
|
-
let errorsFound = 0
|
|
46
|
-
|
|
47
|
-
// Capture console output
|
|
48
|
-
browserPage.on("console", (msg) => {
|
|
49
|
-
const level = msg.type() === "error" ? "error" : msg.type() === "warning" ? "warn" : msg.type() === "info" ? "info" : "debug"
|
|
50
|
-
if (level === "error") errorsFound++
|
|
51
|
-
collected.push({
|
|
52
|
-
project_id: projectId,
|
|
53
|
-
page_id: pageId,
|
|
54
|
-
level: level as LogEntry["level"],
|
|
55
|
-
source: "scanner",
|
|
56
|
-
message: msg.text(),
|
|
57
|
-
url,
|
|
58
|
-
})
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
// Capture page errors (uncaught JS exceptions)
|
|
62
|
-
browserPage.on("pageerror", (err) => {
|
|
63
|
-
errorsFound++
|
|
64
|
-
collected.push({
|
|
65
|
-
project_id: projectId,
|
|
66
|
-
page_id: pageId,
|
|
67
|
-
level: "error",
|
|
68
|
-
source: "scanner",
|
|
69
|
-
message: err.message,
|
|
70
|
-
stack_trace: err.stack,
|
|
71
|
-
url,
|
|
72
|
-
})
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
// Capture network failures
|
|
76
|
-
browserPage.on("requestfailed", (req) => {
|
|
77
|
-
collected.push({
|
|
78
|
-
project_id: projectId,
|
|
79
|
-
page_id: pageId,
|
|
80
|
-
level: "warn",
|
|
81
|
-
source: "scanner",
|
|
82
|
-
message: `Network request failed: ${req.url()} — ${req.failure()?.errorText ?? "unknown"}`,
|
|
83
|
-
url,
|
|
84
|
-
})
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
let perfScore: number | null = null
|
|
88
|
-
|
|
89
|
-
try {
|
|
90
|
-
await browserPage.goto(url, { waitUntil: "networkidle", timeout: 30_000 })
|
|
91
|
-
|
|
92
|
-
// Try basic perf metrics via CDP
|
|
93
|
-
try {
|
|
94
|
-
const metrics = await browserPage.evaluate(() => {
|
|
95
|
-
const nav = performance.getEntriesByType("navigation")[0] as PerformanceNavigationTiming | undefined
|
|
96
|
-
const paint = performance.getEntriesByName("first-contentful-paint")[0]
|
|
97
|
-
return {
|
|
98
|
-
ttfb: nav ? nav.responseStart - nav.requestStart : null,
|
|
99
|
-
fcp: paint?.startTime ?? null,
|
|
100
|
-
domLoad: nav ? nav.domContentLoadedEventEnd - nav.startTime : null,
|
|
101
|
-
}
|
|
102
|
-
})
|
|
103
|
-
// Store what we can without full Lighthouse
|
|
104
|
-
if (metrics.fcp !== null || metrics.ttfb !== null) {
|
|
105
|
-
saveSnapshot(db, {
|
|
106
|
-
project_id: projectId,
|
|
107
|
-
page_id: pageId,
|
|
108
|
-
url,
|
|
109
|
-
fcp: metrics.fcp,
|
|
110
|
-
ttfb: metrics.ttfb,
|
|
111
|
-
lcp: null,
|
|
112
|
-
cls: null,
|
|
113
|
-
tti: metrics.domLoad,
|
|
114
|
-
score: null,
|
|
115
|
-
raw_audit: JSON.stringify(metrics),
|
|
116
|
-
})
|
|
117
|
-
}
|
|
118
|
-
} catch {
|
|
119
|
-
// perf metrics optional
|
|
120
|
-
}
|
|
121
|
-
} finally {
|
|
122
|
-
await browser.close()
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
if (collected.length > 0) {
|
|
126
|
-
ingestBatch(db, collected)
|
|
127
|
-
if (page) touchPage(db, pageId)
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
return { logsCollected: collected.length, errorsFound, perfScore }
|
|
131
|
-
}
|
package/src/lib/scheduler.ts
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import type { Database } from "bun:sqlite"
|
|
2
|
-
import cron from "node-cron"
|
|
3
|
-
import { finishScanRun, createScanRun, listJobs, updateJob } from "./jobs.ts"
|
|
4
|
-
import { listPages } from "./projects.ts"
|
|
5
|
-
import { runRetentionAll } from "./retention.ts"
|
|
6
|
-
import { scanPage } from "./scanner.ts"
|
|
7
|
-
|
|
8
|
-
const tasks = new Map<string, cron.ScheduledTask>()
|
|
9
|
-
|
|
10
|
-
export function startScheduler(db: Database): void {
|
|
11
|
-
const jobs = listJobs(db).filter(j => j.enabled)
|
|
12
|
-
for (const job of jobs) {
|
|
13
|
-
scheduleJob(db, job.id, job.schedule, job.project_id, job.page_id ?? undefined)
|
|
14
|
-
}
|
|
15
|
-
// Hourly retention runner
|
|
16
|
-
cron.schedule("0 * * * *", () => {
|
|
17
|
-
const result = runRetentionAll(db)
|
|
18
|
-
if (result.deleted > 0) console.log(`Retention: deleted ${result.deleted} logs across ${result.projects} project(s)`)
|
|
19
|
-
})
|
|
20
|
-
console.log(`Scheduler started: ${tasks.size} job(s) active`)
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function scheduleJob(db: Database, jobId: string, schedule: string, projectId: string, pageId?: string): void {
|
|
24
|
-
if (tasks.has(jobId)) tasks.get(jobId)!.stop()
|
|
25
|
-
const task = cron.schedule(schedule, async () => {
|
|
26
|
-
await runJob(db, jobId, projectId, pageId)
|
|
27
|
-
})
|
|
28
|
-
tasks.set(jobId, task)
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function unscheduleJob(jobId: string): void {
|
|
32
|
-
tasks.get(jobId)?.stop()
|
|
33
|
-
tasks.delete(jobId)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export async function runJob(db: Database, jobId: string, projectId: string, pageId?: string): Promise<void> {
|
|
37
|
-
const pages = pageId
|
|
38
|
-
? [{ id: pageId, url: "" }] // will resolve url in scan
|
|
39
|
-
: listPages(db, projectId)
|
|
40
|
-
|
|
41
|
-
await Promise.all(pages.map(async (page) => {
|
|
42
|
-
const run = createScanRun(db, { job_id: jobId, page_id: page.id })
|
|
43
|
-
try {
|
|
44
|
-
const result = await scanPage(db, projectId, page.id, page.url)
|
|
45
|
-
finishScanRun(db, run.id, {
|
|
46
|
-
status: "completed",
|
|
47
|
-
logs_collected: result.logsCollected,
|
|
48
|
-
errors_found: result.errorsFound,
|
|
49
|
-
perf_score: result.perfScore ?? undefined,
|
|
50
|
-
})
|
|
51
|
-
} catch (err) {
|
|
52
|
-
finishScanRun(db, run.id, { status: "failed", logs_collected: 0, errors_found: 0 })
|
|
53
|
-
console.error(`Scan failed for page ${page.id}:`, err)
|
|
54
|
-
}
|
|
55
|
-
}))
|
|
56
|
-
|
|
57
|
-
updateJob(db, jobId, { last_run_at: new Date().toISOString() })
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function stopScheduler(): void {
|
|
61
|
-
for (const task of tasks.values()) task.stop()
|
|
62
|
-
tasks.clear()
|
|
63
|
-
}
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import type { Database } from "bun:sqlite"
|
|
2
|
-
import type { LogRow } from "../types/index.ts"
|
|
3
|
-
|
|
4
|
-
export interface SessionContext {
|
|
5
|
-
session_id: string
|
|
6
|
-
logs: LogRow[]
|
|
7
|
-
session?: Record<string, unknown>
|
|
8
|
-
error?: string
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export async function getSessionContext(db: Database, sessionId: string): Promise<SessionContext> {
|
|
12
|
-
const logs = db.prepare("SELECT * FROM logs WHERE session_id = $s ORDER BY timestamp ASC")
|
|
13
|
-
.all({ $s: sessionId }) as LogRow[]
|
|
14
|
-
|
|
15
|
-
const sessionsUrl = process.env.SESSIONS_URL
|
|
16
|
-
if (!sessionsUrl) {
|
|
17
|
-
return { session_id: sessionId, logs }
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
try {
|
|
21
|
-
const res = await fetch(`${sessionsUrl.replace(/\/$/, "")}/api/sessions/${sessionId}`)
|
|
22
|
-
if (!res.ok) return { session_id: sessionId, logs }
|
|
23
|
-
const session = await res.json() as Record<string, unknown>
|
|
24
|
-
return { session_id: sessionId, logs, session }
|
|
25
|
-
} catch (err) {
|
|
26
|
-
return { session_id: sessionId, logs, error: String(err) }
|
|
27
|
-
}
|
|
28
|
-
}
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test"
|
|
2
|
-
import { createTestDb } from "../db/index.ts"
|
|
3
|
-
import { ingestBatch } from "./ingest.ts"
|
|
4
|
-
import { summarizeLogs } from "./summarize.ts"
|
|
5
|
-
|
|
6
|
-
describe("summarizeLogs", () => {
|
|
7
|
-
it("returns warn/error/fatal counts grouped by service", () => {
|
|
8
|
-
const db = createTestDb()
|
|
9
|
-
ingestBatch(db, [
|
|
10
|
-
{ level: "error", message: "e1", service: "api" },
|
|
11
|
-
{ level: "error", message: "e2", service: "api" },
|
|
12
|
-
{ level: "warn", message: "w1", service: "db" },
|
|
13
|
-
{ level: "info", message: "i1", service: "api" }, // excluded
|
|
14
|
-
{ level: "debug", message: "d1", service: "api" }, // excluded
|
|
15
|
-
])
|
|
16
|
-
const summary = summarizeLogs(db)
|
|
17
|
-
expect(summary.length).toBe(2)
|
|
18
|
-
const api = summary.find(s => s.service === "api" && s.level === "error")
|
|
19
|
-
expect(api?.count).toBe(2)
|
|
20
|
-
const db2 = summary.find(s => s.service === "db")
|
|
21
|
-
expect(db2?.count).toBe(1)
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
it("excludes info/debug from summary", () => {
|
|
25
|
-
const db = createTestDb()
|
|
26
|
-
ingestBatch(db, [
|
|
27
|
-
{ level: "info", message: "ok" },
|
|
28
|
-
{ level: "debug", message: "trace" },
|
|
29
|
-
])
|
|
30
|
-
const summary = summarizeLogs(db)
|
|
31
|
-
expect(summary).toHaveLength(0)
|
|
32
|
-
})
|
|
33
|
-
|
|
34
|
-
it("returns empty for no logs", () => {
|
|
35
|
-
const db = createTestDb()
|
|
36
|
-
expect(summarizeLogs(db)).toHaveLength(0)
|
|
37
|
-
})
|
|
38
|
-
})
|
package/src/lib/summarize.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import type { Database } from "bun:sqlite"
|
|
2
|
-
import type { LogSummary } from "../types/index.ts"
|
|
3
|
-
import { parseTime } from "./parse-time.ts"
|
|
4
|
-
|
|
5
|
-
export function summarizeLogs(db: Database, projectId?: string, since?: string, until?: string): LogSummary[] {
|
|
6
|
-
const conditions: string[] = ["level IN ('warn','error','fatal')"]
|
|
7
|
-
const params: Record<string, unknown> = {}
|
|
8
|
-
|
|
9
|
-
if (projectId) { conditions.push("project_id = $project_id"); params.$project_id = projectId }
|
|
10
|
-
if (since) { conditions.push("timestamp >= $since"); params.$since = parseTime(since) ?? since }
|
|
11
|
-
if (until) { conditions.push("timestamp <= $until"); params.$until = parseTime(until) ?? until }
|
|
12
|
-
|
|
13
|
-
const where = `WHERE ${conditions.join(" AND ")}`
|
|
14
|
-
const sql = `
|
|
15
|
-
SELECT project_id, service, page_id, level,
|
|
16
|
-
COUNT(*) as count,
|
|
17
|
-
MAX(timestamp) as latest
|
|
18
|
-
FROM logs ${where}
|
|
19
|
-
GROUP BY project_id, service, page_id, level
|
|
20
|
-
ORDER BY count DESC
|
|
21
|
-
`
|
|
22
|
-
return db.prepare(sql).all(params) as LogSummary[]
|
|
23
|
-
}
|
package/src/mcp/http.test.ts
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
-
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
-
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
|
-
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
5
|
-
import { buildServer } from "./index.ts";
|
|
6
|
-
import {
|
|
7
|
-
DEFAULT_MCP_HTTP_PORT,
|
|
8
|
-
isHttpMode,
|
|
9
|
-
resolveMcpHttpPort,
|
|
10
|
-
startMcpHttpServer,
|
|
11
|
-
} from "./http.ts";
|
|
12
|
-
|
|
13
|
-
describe("logs MCP HTTP transport", () => {
|
|
14
|
-
test("defaults port to 8864", () => {
|
|
15
|
-
expect(DEFAULT_MCP_HTTP_PORT).toBe(8864);
|
|
16
|
-
expect(resolveMcpHttpPort(["node"], {})).toBe(8864);
|
|
17
|
-
expect(resolveMcpHttpPort(["node", "--port", "9001"], {})).toBe(9001);
|
|
18
|
-
expect(resolveMcpHttpPort(["node"], { MCP_HTTP_PORT: "9002" })).toBe(9002);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
test("isHttpMode detects flag and env", () => {
|
|
22
|
-
expect(isHttpMode(["node"], {})).toBe(false);
|
|
23
|
-
expect(isHttpMode(["node", "--http"], {})).toBe(true);
|
|
24
|
-
expect(isHttpMode(["node"], { MCP_HTTP: "1" })).toBe(true);
|
|
25
|
-
});
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
describe("logs buildServer stdio registration", () => {
|
|
29
|
-
test("registers tools over in-memory transport", async () => {
|
|
30
|
-
const server = buildServer();
|
|
31
|
-
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
32
|
-
await server.connect(serverTransport);
|
|
33
|
-
|
|
34
|
-
const client = new Client({ name: "test", version: "0.0.0" });
|
|
35
|
-
await client.connect(clientTransport);
|
|
36
|
-
|
|
37
|
-
const tools = await client.listTools();
|
|
38
|
-
expect(tools.tools.some((tool) => tool.name === "get_health")).toBe(true);
|
|
39
|
-
|
|
40
|
-
await client.close();
|
|
41
|
-
await server.close();
|
|
42
|
-
});
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
describe("logs streamable HTTP server", () => {
|
|
46
|
-
let handle: Awaited<ReturnType<typeof startMcpHttpServer>>;
|
|
47
|
-
|
|
48
|
-
beforeAll(async () => {
|
|
49
|
-
handle = await startMcpHttpServer(buildServer, { port: 0 });
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
afterAll(async () => {
|
|
53
|
-
await handle.close();
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("GET /health returns ok", async () => {
|
|
57
|
-
const res = await fetch(`http://${handle.host}:${handle.port}/health`);
|
|
58
|
-
expect(res.status).toBe(200);
|
|
59
|
-
expect(await res.json()).toEqual({ status: "ok", name: "logs" });
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test("initialize and call get_health over streamable HTTP", async () => {
|
|
63
|
-
const transport = new StreamableHTTPClientTransport(
|
|
64
|
-
new URL(`http://${handle.host}:${handle.port}/mcp`),
|
|
65
|
-
);
|
|
66
|
-
const client = new Client({ name: "test", version: "0.0.0" });
|
|
67
|
-
await client.connect(transport);
|
|
68
|
-
|
|
69
|
-
const result = await client.callTool({ name: "get_health", arguments: {} });
|
|
70
|
-
expect(result.content).toBeDefined();
|
|
71
|
-
expect(Array.isArray(result.content)).toBe(true);
|
|
72
|
-
|
|
73
|
-
await client.close();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test("serves three concurrent clients from one process", async () => {
|
|
77
|
-
const clients = await Promise.all(
|
|
78
|
-
Array.from({ length: 3 }, async () => {
|
|
79
|
-
const transport = new StreamableHTTPClientTransport(
|
|
80
|
-
new URL(`http://${handle.host}:${handle.port}/mcp`),
|
|
81
|
-
);
|
|
82
|
-
const client = new Client({ name: "test", version: "0.0.0" });
|
|
83
|
-
await client.connect(transport);
|
|
84
|
-
const tools = await client.listTools();
|
|
85
|
-
return { client, count: tools.tools.length };
|
|
86
|
-
}),
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
expect(clients.every((entry) => entry.count > 0)).toBe(true);
|
|
90
|
-
await Promise.all(clients.map((entry) => entry.client.close()));
|
|
91
|
-
});
|
|
92
|
-
});
|
package/src/mcp/http.ts
DELETED
|
@@ -1,135 +0,0 @@
|
|
|
1
|
-
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
-
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
3
|
-
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
-
|
|
5
|
-
export const MCP_HTTP_SERVICE_NAME = "logs";
|
|
6
|
-
export const DEFAULT_MCP_HTTP_PORT = 8864;
|
|
7
|
-
|
|
8
|
-
export function isHttpMode(
|
|
9
|
-
argv: string[] = process.argv,
|
|
10
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
11
|
-
): boolean {
|
|
12
|
-
return argv.includes("--http") || env.MCP_HTTP === "1";
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function isStdioMode(
|
|
16
|
-
argv: string[] = process.argv,
|
|
17
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
18
|
-
): boolean {
|
|
19
|
-
return argv.includes("--stdio") || env.MCP_STDIO === "1";
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function resolveMcpHttpPort(
|
|
23
|
-
argv: string[] = process.argv,
|
|
24
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
25
|
-
): number {
|
|
26
|
-
const portIdx = argv.indexOf("--port");
|
|
27
|
-
if (portIdx !== -1 && argv[portIdx + 1]) {
|
|
28
|
-
return parsePort(argv[portIdx + 1]!, "--port");
|
|
29
|
-
}
|
|
30
|
-
if (env.MCP_HTTP_PORT) {
|
|
31
|
-
return parsePort(env.MCP_HTTP_PORT, "MCP_HTTP_PORT");
|
|
32
|
-
}
|
|
33
|
-
return DEFAULT_MCP_HTTP_PORT;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function parsePort(raw: string, source: string): number {
|
|
37
|
-
const parsed = Number(raw);
|
|
38
|
-
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) {
|
|
39
|
-
throw new Error(`Invalid ${source} value "${raw}". Expected 0-65535.`);
|
|
40
|
-
}
|
|
41
|
-
return parsed;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
async function readJsonBody(req: IncomingMessage): Promise<unknown> {
|
|
45
|
-
const chunks: Buffer[] = [];
|
|
46
|
-
for await (const chunk of req) {
|
|
47
|
-
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
48
|
-
}
|
|
49
|
-
const text = Buffer.concat(chunks).toString("utf8");
|
|
50
|
-
if (!text) return undefined;
|
|
51
|
-
return JSON.parse(text) as unknown;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export type McpHttpServerHandle = {
|
|
55
|
-
port: number;
|
|
56
|
-
host: string;
|
|
57
|
-
close: () => Promise<void>;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
export async function startMcpHttpServer(
|
|
61
|
-
buildServer: () => McpServer,
|
|
62
|
-
options?: { port?: number; host?: string; serviceName?: string },
|
|
63
|
-
): Promise<McpHttpServerHandle> {
|
|
64
|
-
const host = options?.host ?? "127.0.0.1";
|
|
65
|
-
const requestedPort = options?.port ?? resolveMcpHttpPort();
|
|
66
|
-
const serviceName = options?.serviceName ?? MCP_HTTP_SERVICE_NAME;
|
|
67
|
-
|
|
68
|
-
const httpServer = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
69
|
-
try {
|
|
70
|
-
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
71
|
-
|
|
72
|
-
if (req.method === "GET" && url.pathname === "/health") {
|
|
73
|
-
res.writeHead(200, { "Content-Type": "application/json" });
|
|
74
|
-
res.end(JSON.stringify({ status: "ok", name: serviceName }));
|
|
75
|
-
return;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
if (url.pathname !== "/mcp") {
|
|
79
|
-
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
80
|
-
res.end("Not Found");
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
const server = buildServer();
|
|
85
|
-
const transport = new StreamableHTTPServerTransport({
|
|
86
|
-
sessionIdGenerator: undefined,
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
await server.connect(transport);
|
|
90
|
-
|
|
91
|
-
let parsedBody: unknown;
|
|
92
|
-
if (req.method === "POST") {
|
|
93
|
-
parsedBody = await readJsonBody(req);
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
await transport.handleRequest(req, res, parsedBody);
|
|
97
|
-
|
|
98
|
-
res.on("close", () => {
|
|
99
|
-
void transport.close();
|
|
100
|
-
void server.close();
|
|
101
|
-
});
|
|
102
|
-
} catch (error) {
|
|
103
|
-
console.error(`[${serviceName}-mcp] HTTP error:`, error);
|
|
104
|
-
if (!res.headersSent) {
|
|
105
|
-
res.writeHead(500, { "Content-Type": "application/json" });
|
|
106
|
-
res.end(
|
|
107
|
-
JSON.stringify({
|
|
108
|
-
jsonrpc: "2.0",
|
|
109
|
-
error: { code: -32603, message: "Internal server error" },
|
|
110
|
-
id: null,
|
|
111
|
-
}),
|
|
112
|
-
);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
await new Promise<void>((resolve, reject) => {
|
|
118
|
-
httpServer.once("error", reject);
|
|
119
|
-
httpServer.listen(requestedPort, host, () => resolve());
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
const addr = httpServer.address();
|
|
123
|
-
const port = typeof addr === "object" && addr ? addr.port : requestedPort;
|
|
124
|
-
|
|
125
|
-
console.error(`[${serviceName}-mcp] Streamable HTTP listening on http://${host}:${port}/mcp`);
|
|
126
|
-
|
|
127
|
-
return {
|
|
128
|
-
port,
|
|
129
|
-
host,
|
|
130
|
-
close: () =>
|
|
131
|
-
new Promise<void>((resolve, reject) => {
|
|
132
|
-
httpServer.close((err) => (err ? reject(err) : resolve()));
|
|
133
|
-
}),
|
|
134
|
-
};
|
|
135
|
-
}
|
package/src/mcp/index.test.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { test, expect } from "bun:test"
|
|
2
|
-
import { fileURLToPath } from "url"
|
|
3
|
-
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
|
4
|
-
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
5
|
-
|
|
6
|
-
test("logs MCP lists tools over stdio", async () => {
|
|
7
|
-
const entry = fileURLToPath(new URL("./index.ts", import.meta.url))
|
|
8
|
-
const transport = new StdioClientTransport({
|
|
9
|
-
command: "bun",
|
|
10
|
-
args: ["run", entry],
|
|
11
|
-
})
|
|
12
|
-
const client = new Client({ name: "logs-mcp-test", version: "0.0.0" }, { capabilities: {} })
|
|
13
|
-
|
|
14
|
-
try {
|
|
15
|
-
await client.connect(transport)
|
|
16
|
-
const result = await client.listTools()
|
|
17
|
-
const toolNames = result.tools.map((tool) => tool.name)
|
|
18
|
-
|
|
19
|
-
expect(toolNames.length).toBeGreaterThan(0)
|
|
20
|
-
expect(toolNames).toContain("get_health")
|
|
21
|
-
expect(toolNames).toContain("log_export")
|
|
22
|
-
expect(toolNames).toContain("log_search")
|
|
23
|
-
expect(toolNames).toContain("log_stats")
|
|
24
|
-
} finally {
|
|
25
|
-
await client.close().catch(() => {})
|
|
26
|
-
}
|
|
27
|
-
})
|