@hasna/logs 0.0.1 → 0.2.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/dashboard/README.md +73 -0
- package/dashboard/bun.lock +526 -0
- package/dashboard/eslint.config.js +23 -0
- package/dashboard/index.html +13 -0
- package/dashboard/package.json +32 -0
- package/dashboard/public/favicon.svg +1 -0
- package/dashboard/public/icons.svg +24 -0
- package/dashboard/src/App.css +184 -0
- package/dashboard/src/App.tsx +49 -0
- package/dashboard/src/api.ts +33 -0
- package/dashboard/src/assets/hero.png +0 -0
- package/dashboard/src/assets/react.svg +1 -0
- package/dashboard/src/assets/vite.svg +1 -0
- package/dashboard/src/index.css +111 -0
- package/dashboard/src/main.tsx +10 -0
- package/dashboard/src/pages/Alerts.tsx +69 -0
- package/dashboard/src/pages/Issues.tsx +50 -0
- package/dashboard/src/pages/Perf.tsx +75 -0
- package/dashboard/src/pages/Projects.tsx +67 -0
- package/dashboard/src/pages/Summary.tsx +67 -0
- package/dashboard/src/pages/Tail.tsx +65 -0
- package/dashboard/tsconfig.app.json +28 -0
- package/dashboard/tsconfig.json +7 -0
- package/dashboard/tsconfig.node.json +26 -0
- package/dashboard/vite.config.ts +14 -0
- package/dist/cli/index.js +116 -12
- package/dist/mcp/index.js +306 -100
- package/dist/server/index.js +592 -7
- package/package.json +12 -2
- package/sdk/package.json +3 -2
- package/sdk/src/index.ts +1 -1
- package/sdk/src/types.ts +56 -0
- package/src/cli/index.ts +114 -4
- package/src/db/index.ts +10 -0
- package/src/db/migrations/001_alert_rules.ts +21 -0
- package/src/db/migrations/002_issues.ts +21 -0
- package/src/db/migrations/003_retention.ts +15 -0
- package/src/db/migrations/004_page_auth.ts +13 -0
- package/src/lib/alerts.test.ts +67 -0
- package/src/lib/alerts.ts +117 -0
- package/src/lib/compare.test.ts +52 -0
- package/src/lib/compare.ts +85 -0
- package/src/lib/diagnose.test.ts +55 -0
- package/src/lib/diagnose.ts +76 -0
- package/src/lib/export.test.ts +66 -0
- package/src/lib/export.ts +65 -0
- package/src/lib/health.test.ts +48 -0
- package/src/lib/health.ts +51 -0
- package/src/lib/ingest.ts +25 -2
- package/src/lib/issues.test.ts +79 -0
- package/src/lib/issues.ts +70 -0
- package/src/lib/page-auth.test.ts +54 -0
- package/src/lib/page-auth.ts +48 -0
- package/src/lib/retention.test.ts +42 -0
- package/src/lib/retention.ts +62 -0
- package/src/lib/scanner.ts +21 -2
- package/src/lib/scheduler.ts +6 -0
- package/src/lib/session-context.ts +28 -0
- package/src/mcp/index.ts +133 -89
- package/src/server/index.ts +12 -1
- package/src/server/routes/alerts.ts +32 -0
- package/src/server/routes/issues.ts +43 -0
- package/src/server/routes/logs.ts +21 -0
- package/src/server/routes/projects.ts +25 -0
- package/src/server/routes/stream.ts +43 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { createTestDb } from "../db/index.ts"
|
|
3
|
+
import { ingestBatch } from "./ingest.ts"
|
|
4
|
+
import { runRetentionForProject, setRetentionPolicy } from "./retention.ts"
|
|
5
|
+
|
|
6
|
+
function seedProject(db: ReturnType<typeof createTestDb>, name = "app") {
|
|
7
|
+
return db.prepare("INSERT INTO projects (name) VALUES (?) RETURNING id").get(name) as { id: string }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe("retention", () => {
|
|
11
|
+
it("does nothing when under max_rows", () => {
|
|
12
|
+
const db = createTestDb()
|
|
13
|
+
const p = seedProject(db)
|
|
14
|
+
ingestBatch(db, Array.from({ length: 5 }, () => ({ level: "info" as const, message: "x", project_id: p.id })))
|
|
15
|
+
const result = runRetentionForProject(db, p.id)
|
|
16
|
+
expect(result.deleted).toBe(0)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it("enforces max_rows", () => {
|
|
20
|
+
const db = createTestDb()
|
|
21
|
+
const p = seedProject(db)
|
|
22
|
+
setRetentionPolicy(db, p.id, { max_rows: 3 })
|
|
23
|
+
ingestBatch(db, Array.from({ length: 10 }, () => ({ level: "info" as const, message: "x", project_id: p.id })))
|
|
24
|
+
runRetentionForProject(db, p.id)
|
|
25
|
+
const count = (db.prepare("SELECT COUNT(*) as c FROM logs WHERE project_id = ?").get(p.id) as { c: number }).c
|
|
26
|
+
expect(count).toBeLessThanOrEqual(3)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("returns 0 for unknown project", () => {
|
|
30
|
+
const db = createTestDb()
|
|
31
|
+
expect(runRetentionForProject(db, "nope").deleted).toBe(0)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("setRetentionPolicy updates project config", () => {
|
|
35
|
+
const db = createTestDb()
|
|
36
|
+
const p = seedProject(db)
|
|
37
|
+
setRetentionPolicy(db, p.id, { max_rows: 500, debug_ttl_hours: 1 })
|
|
38
|
+
const proj = db.prepare("SELECT max_rows, debug_ttl_hours FROM projects WHERE id = ?").get(p.id) as { max_rows: number; debug_ttl_hours: number }
|
|
39
|
+
expect(proj.max_rows).toBe(500)
|
|
40
|
+
expect(proj.debug_ttl_hours).toBe(1)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
|
|
3
|
+
interface RetentionConfig {
|
|
4
|
+
max_rows: number
|
|
5
|
+
debug_ttl_hours: number
|
|
6
|
+
info_ttl_hours: number
|
|
7
|
+
warn_ttl_hours: number
|
|
8
|
+
error_ttl_hours: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const TTL_BY_LEVEL: Record<string, keyof RetentionConfig> = {
|
|
12
|
+
debug: "debug_ttl_hours",
|
|
13
|
+
info: "info_ttl_hours",
|
|
14
|
+
warn: "warn_ttl_hours",
|
|
15
|
+
error: "error_ttl_hours",
|
|
16
|
+
fatal: "error_ttl_hours",
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function runRetentionForProject(db: Database, projectId: string): { deleted: number } {
|
|
20
|
+
const project = db.prepare("SELECT * FROM projects WHERE id = $id").get({ $id: projectId }) as (RetentionConfig & { id: string }) | null
|
|
21
|
+
if (!project) return { deleted: 0 }
|
|
22
|
+
|
|
23
|
+
let deleted = 0
|
|
24
|
+
|
|
25
|
+
// TTL enforcement per level
|
|
26
|
+
for (const [level, configKey] of Object.entries(TTL_BY_LEVEL)) {
|
|
27
|
+
const ttlHours = project[configKey] as number
|
|
28
|
+
const cutoff = new Date(Date.now() - ttlHours * 3600 * 1000).toISOString()
|
|
29
|
+
const before = (db.prepare("SELECT COUNT(*) as c FROM logs WHERE project_id = $p AND level = $level AND timestamp < $cutoff").get({ $p: projectId, $level: level, $cutoff: cutoff }) as { c: number }).c
|
|
30
|
+
if (before > 0) {
|
|
31
|
+
db.prepare("DELETE FROM logs WHERE project_id = $p AND level = $level AND timestamp < $cutoff").run({ $p: projectId, $level: level, $cutoff: cutoff })
|
|
32
|
+
deleted += before
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// max_rows enforcement
|
|
37
|
+
const total = (db.prepare("SELECT COUNT(*) as c FROM logs WHERE project_id = $p").get({ $p: projectId }) as { c: number }).c
|
|
38
|
+
if (total > project.max_rows) {
|
|
39
|
+
const toDelete = total - project.max_rows
|
|
40
|
+
db.prepare(`DELETE FROM logs WHERE id IN (SELECT id FROM logs WHERE project_id = $p ORDER BY timestamp ASC LIMIT ${toDelete})`).run({ $p: projectId })
|
|
41
|
+
deleted += toDelete
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { deleted }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function runRetentionAll(db: Database): { deleted: number; projects: number } {
|
|
48
|
+
const projects = db.prepare("SELECT id FROM projects").all() as { id: string }[]
|
|
49
|
+
let deleted = 0
|
|
50
|
+
for (const p of projects) {
|
|
51
|
+
deleted += runRetentionForProject(db, p.id).deleted
|
|
52
|
+
}
|
|
53
|
+
return { deleted, projects: projects.length }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function setRetentionPolicy(db: Database, projectId: string, config: Partial<RetentionConfig>): void {
|
|
57
|
+
const fields = Object.keys(config).map(k => `${k} = $${k}`).join(", ")
|
|
58
|
+
if (!fields) return
|
|
59
|
+
const params = Object.fromEntries(Object.entries(config).map(([k, v]) => [`$${k}`, v]))
|
|
60
|
+
params.$id = projectId
|
|
61
|
+
db.prepare(`UPDATE projects SET ${fields} WHERE id = $id`).run(params)
|
|
62
|
+
}
|
package/src/lib/scanner.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite"
|
|
2
2
|
import { ingestBatch } from "./ingest.ts"
|
|
3
|
+
import { getPageAuth } from "./page-auth.ts"
|
|
3
4
|
import { saveSnapshot } from "./perf.ts"
|
|
4
5
|
import { getPage, touchPage } from "./projects.ts"
|
|
5
6
|
import type { LogEntry } from "../types/index.ts"
|
|
@@ -17,9 +18,27 @@ export async function scanPage(db: Database, projectId: string, pageId: string,
|
|
|
17
18
|
|
|
18
19
|
const { chromium } = await import("playwright")
|
|
19
20
|
const browser = await chromium.launch({ headless: true })
|
|
20
|
-
|
|
21
|
+
|
|
22
|
+
// Apply page auth if configured
|
|
23
|
+
const auth = getPageAuth(db, pageId)
|
|
24
|
+
const contextOptions: Parameters<typeof browser.newContext>[0] = {
|
|
21
25
|
userAgent: "Mozilla/5.0 (@hasna/logs scanner) AppleWebKit/537.36",
|
|
22
|
-
}
|
|
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
|
+
|
|
23
42
|
const browserPage = await context.newPage()
|
|
24
43
|
|
|
25
44
|
const collected: LogEntry[] = []
|
package/src/lib/scheduler.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Database } from "bun:sqlite"
|
|
|
2
2
|
import cron from "node-cron"
|
|
3
3
|
import { finishScanRun, createScanRun, listJobs, updateJob } from "./jobs.ts"
|
|
4
4
|
import { listPages } from "./projects.ts"
|
|
5
|
+
import { runRetentionAll } from "./retention.ts"
|
|
5
6
|
import { scanPage } from "./scanner.ts"
|
|
6
7
|
|
|
7
8
|
const tasks = new Map<string, cron.ScheduledTask>()
|
|
@@ -11,6 +12,11 @@ export function startScheduler(db: Database): void {
|
|
|
11
12
|
for (const job of jobs) {
|
|
12
13
|
scheduleJob(db, job.id, job.schedule, job.project_id, job.page_id ?? undefined)
|
|
13
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
|
+
})
|
|
14
20
|
console.log(`Scheduler started: ${tasks.size} job(s) active`)
|
|
15
21
|
}
|
|
16
22
|
|
|
@@ -0,0 +1,28 @@
|
|
|
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
|
+
}
|
package/src/mcp/index.ts
CHANGED
|
@@ -9,28 +9,49 @@ import { summarizeLogs } from "../lib/summarize.ts"
|
|
|
9
9
|
import { createJob, listJobs } from "../lib/jobs.ts"
|
|
10
10
|
import { createPage, createProject, listPages, listProjects } from "../lib/projects.ts"
|
|
11
11
|
import { getLatestSnapshot, getPerfTrend, scoreLabel } from "../lib/perf.ts"
|
|
12
|
-
import
|
|
12
|
+
import { createAlertRule, deleteAlertRule, listAlertRules } from "../lib/alerts.ts"
|
|
13
|
+
import { listIssues, updateIssueStatus } from "../lib/issues.ts"
|
|
14
|
+
import { diagnose } from "../lib/diagnose.ts"
|
|
15
|
+
import { compare } from "../lib/compare.ts"
|
|
16
|
+
import { getHealth } from "../lib/health.ts"
|
|
17
|
+
import { getSessionContext } from "../lib/session-context.ts"
|
|
18
|
+
import type { LogLevel, LogRow } from "../types/index.ts"
|
|
13
19
|
|
|
14
20
|
const db = getDb()
|
|
15
|
-
const server = new McpServer({ name: "logs", version: "0.0
|
|
21
|
+
const server = new McpServer({ name: "logs", version: "0.1.0" })
|
|
22
|
+
|
|
23
|
+
const BRIEF_FIELDS: (keyof LogRow)[] = ["id", "timestamp", "level", "message", "service"]
|
|
24
|
+
|
|
25
|
+
function applyBrief(rows: LogRow[], brief = true): unknown[] {
|
|
26
|
+
if (!brief) return rows
|
|
27
|
+
return rows.map(r => ({ id: r.id, timestamp: r.timestamp, level: r.level, message: r.message, service: r.service }))
|
|
28
|
+
}
|
|
16
29
|
|
|
17
|
-
// Tool registry for search_tools / describe_tools pattern
|
|
18
30
|
const TOOLS: Record<string, string> = {
|
|
19
31
|
register_project: "Register a project (name, github_repo?, base_url?, description?)",
|
|
20
|
-
register_page: "Register a page URL
|
|
21
|
-
create_scan_job: "Schedule
|
|
32
|
+
register_page: "Register a page URL (project_id, url, path?, name?)",
|
|
33
|
+
create_scan_job: "Schedule page scans (project_id, schedule, page_id?)",
|
|
22
34
|
log_push: "Push a log entry (level, message, project_id?, service?, trace_id?, metadata?)",
|
|
23
|
-
log_search: "Search logs (project_id?,
|
|
24
|
-
log_tail: "
|
|
25
|
-
log_summary: "Error/warn counts by service
|
|
26
|
-
log_context: "All logs for a trace_id",
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
35
|
+
log_search: "Search logs (project_id?, level?, since?, text?, brief?=true, limit?)",
|
|
36
|
+
log_tail: "Recent logs (project_id?, n?, brief?=true)",
|
|
37
|
+
log_summary: "Error/warn counts by service (project_id?, since?)",
|
|
38
|
+
log_context: "All logs for a trace_id (trace_id, brief?=true)",
|
|
39
|
+
log_diagnose: "Full diagnosis: top errors, failing pages, perf regressions (project_id, since?)",
|
|
40
|
+
log_compare: "Compare two time windows for new/resolved errors and perf delta",
|
|
41
|
+
perf_snapshot: "Latest perf snapshot (project_id, page_id?)",
|
|
42
|
+
perf_trend: "Perf over time (project_id, page_id?, since?, limit?)",
|
|
43
|
+
scan_status: "Last scan jobs (project_id?)",
|
|
44
|
+
list_projects: "List all projects",
|
|
31
45
|
list_pages: "List pages for a project (project_id)",
|
|
32
|
-
|
|
33
|
-
|
|
46
|
+
list_issues: "List grouped error issues (project_id?, status?, limit?)",
|
|
47
|
+
resolve_issue: "Update issue status (id, status: open|resolved|ignored)",
|
|
48
|
+
create_alert_rule: "Create alert rule (project_id, name, level, threshold_count, window_seconds, webhook_url?)",
|
|
49
|
+
list_alert_rules: "List alert rules (project_id?)",
|
|
50
|
+
delete_alert_rule: "Delete alert rule (id)",
|
|
51
|
+
log_session_context: "Logs + session metadata for a session_id (requires SESSIONS_URL env)",
|
|
52
|
+
get_health: "Server health + DB stats",
|
|
53
|
+
search_tools: "Search tools by keyword (query)",
|
|
54
|
+
describe_tools: "List all tools",
|
|
34
55
|
}
|
|
35
56
|
|
|
36
57
|
server.tool("search_tools", { query: z.string() }, ({ query }) => {
|
|
@@ -39,39 +60,21 @@ server.tool("search_tools", { query: z.string() }, ({ query }) => {
|
|
|
39
60
|
return { content: [{ type: "text", text: matches.map(([k, v]) => `${k}: ${v}`).join("\n") || "No matches" }] }
|
|
40
61
|
})
|
|
41
62
|
|
|
42
|
-
server.tool("describe_tools", {}, () => {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
})
|
|
63
|
+
server.tool("describe_tools", {}, () => ({
|
|
64
|
+
content: [{ type: "text", text: Object.entries(TOOLS).map(([k, v]) => `${k}: ${v}`).join("\n") }]
|
|
65
|
+
}))
|
|
46
66
|
|
|
47
67
|
server.tool("register_project", {
|
|
48
|
-
name: z.string(),
|
|
49
|
-
|
|
50
|
-
base_url: z.string().optional(),
|
|
51
|
-
description: z.string().optional(),
|
|
52
|
-
}, (args) => {
|
|
53
|
-
const project = createProject(db, args)
|
|
54
|
-
return { content: [{ type: "text", text: JSON.stringify(project) }] }
|
|
55
|
-
})
|
|
68
|
+
name: z.string(), github_repo: z.string().optional(), base_url: z.string().optional(), description: z.string().optional(),
|
|
69
|
+
}, (args) => ({ content: [{ type: "text", text: JSON.stringify(createProject(db, args)) }] }))
|
|
56
70
|
|
|
57
71
|
server.tool("register_page", {
|
|
58
|
-
project_id: z.string(),
|
|
59
|
-
|
|
60
|
-
path: z.string().optional(),
|
|
61
|
-
name: z.string().optional(),
|
|
62
|
-
}, (args) => {
|
|
63
|
-
const page = createPage(db, args)
|
|
64
|
-
return { content: [{ type: "text", text: JSON.stringify(page) }] }
|
|
65
|
-
})
|
|
72
|
+
project_id: z.string(), url: z.string(), path: z.string().optional(), name: z.string().optional(),
|
|
73
|
+
}, (args) => ({ content: [{ type: "text", text: JSON.stringify(createPage(db, args)) }] }))
|
|
66
74
|
|
|
67
75
|
server.tool("create_scan_job", {
|
|
68
|
-
project_id: z.string(),
|
|
69
|
-
|
|
70
|
-
page_id: z.string().optional(),
|
|
71
|
-
}, (args) => {
|
|
72
|
-
const job = createJob(db, args)
|
|
73
|
-
return { content: [{ type: "text", text: JSON.stringify(job) }] }
|
|
74
|
-
})
|
|
76
|
+
project_id: z.string(), schedule: z.string(), page_id: z.string().optional(),
|
|
77
|
+
}, (args) => ({ content: [{ type: "text", text: JSON.stringify(createJob(db, args)) }] }))
|
|
75
78
|
|
|
76
79
|
server.tool("log_push", {
|
|
77
80
|
level: z.enum(["debug", "info", "warn", "error", "fatal"]),
|
|
@@ -89,77 +92,118 @@ server.tool("log_push", {
|
|
|
89
92
|
})
|
|
90
93
|
|
|
91
94
|
server.tool("log_search", {
|
|
92
|
-
project_id: z.string().optional(),
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
until: z.string().optional(),
|
|
98
|
-
text: z.string().optional(),
|
|
99
|
-
trace_id: z.string().optional(),
|
|
100
|
-
limit: z.number().optional(),
|
|
95
|
+
project_id: z.string().optional(), page_id: z.string().optional(),
|
|
96
|
+
level: z.string().optional(), service: z.string().optional(),
|
|
97
|
+
since: z.string().optional(), until: z.string().optional(),
|
|
98
|
+
text: z.string().optional(), trace_id: z.string().optional(),
|
|
99
|
+
limit: z.number().optional(), brief: z.boolean().optional(),
|
|
101
100
|
}, (args) => {
|
|
102
|
-
const rows = searchLogs(db, {
|
|
103
|
-
|
|
104
|
-
level: args.level ? (args.level.split(",") as LogLevel[]) : undefined,
|
|
105
|
-
})
|
|
106
|
-
return { content: [{ type: "text", text: JSON.stringify(rows) }] }
|
|
101
|
+
const rows = searchLogs(db, { ...args, level: args.level ? (args.level.split(",") as LogLevel[]) : undefined })
|
|
102
|
+
return { content: [{ type: "text", text: JSON.stringify(applyBrief(rows, args.brief !== false)) }] }
|
|
107
103
|
})
|
|
108
104
|
|
|
109
105
|
server.tool("log_tail", {
|
|
110
|
-
project_id: z.string().optional(),
|
|
111
|
-
|
|
112
|
-
}, ({ project_id, n }) => {
|
|
106
|
+
project_id: z.string().optional(), n: z.number().optional(), brief: z.boolean().optional(),
|
|
107
|
+
}, ({ project_id, n, brief }) => {
|
|
113
108
|
const rows = tailLogs(db, project_id, n ?? 50)
|
|
114
|
-
return { content: [{ type: "text", text: JSON.stringify(rows) }] }
|
|
109
|
+
return { content: [{ type: "text", text: JSON.stringify(applyBrief(rows, brief !== false)) }] }
|
|
115
110
|
})
|
|
116
111
|
|
|
117
112
|
server.tool("log_summary", {
|
|
118
|
-
project_id: z.string().optional(),
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
113
|
+
project_id: z.string().optional(), since: z.string().optional(),
|
|
114
|
+
}, ({ project_id, since }) => ({
|
|
115
|
+
content: [{ type: "text", text: JSON.stringify(summarizeLogs(db, project_id, since)) }]
|
|
116
|
+
}))
|
|
117
|
+
|
|
118
|
+
server.tool("log_context", {
|
|
119
|
+
trace_id: z.string(), brief: z.boolean().optional(),
|
|
120
|
+
}, ({ trace_id, brief }) => {
|
|
126
121
|
const rows = getLogContext(db, trace_id)
|
|
127
|
-
return { content: [{ type: "text", text: JSON.stringify(rows) }] }
|
|
122
|
+
return { content: [{ type: "text", text: JSON.stringify(applyBrief(rows, brief !== false)) }] }
|
|
128
123
|
})
|
|
129
124
|
|
|
130
|
-
server.tool("
|
|
125
|
+
server.tool("log_diagnose", {
|
|
126
|
+
project_id: z.string(), since: z.string().optional(),
|
|
127
|
+
}, ({ project_id, since }) => ({
|
|
128
|
+
content: [{ type: "text", text: JSON.stringify(diagnose(db, project_id, since)) }]
|
|
129
|
+
}))
|
|
130
|
+
|
|
131
|
+
server.tool("log_compare", {
|
|
131
132
|
project_id: z.string(),
|
|
132
|
-
|
|
133
|
+
a_since: z.string(), a_until: z.string(),
|
|
134
|
+
b_since: z.string(), b_until: z.string(),
|
|
135
|
+
}, ({ project_id, a_since, a_until, b_since, b_until }) => ({
|
|
136
|
+
content: [{ type: "text", text: JSON.stringify(compare(db, project_id, a_since, a_until, b_since, b_until)) }]
|
|
137
|
+
}))
|
|
138
|
+
|
|
139
|
+
server.tool("perf_snapshot", {
|
|
140
|
+
project_id: z.string(), page_id: z.string().optional(),
|
|
133
141
|
}, ({ project_id, page_id }) => {
|
|
134
142
|
const snap = getLatestSnapshot(db, project_id, page_id)
|
|
135
|
-
|
|
136
|
-
return { content: [{ type: "text", text: JSON.stringify({ ...snap, label }) }] }
|
|
143
|
+
return { content: [{ type: "text", text: JSON.stringify(snap ? { ...snap, label: scoreLabel(snap.score) } : null) }] }
|
|
137
144
|
})
|
|
138
145
|
|
|
139
146
|
server.tool("perf_trend", {
|
|
140
|
-
project_id: z.string(),
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}, ({ project_id, page_id, since, limit }) => {
|
|
145
|
-
const trend = getPerfTrend(db, project_id, page_id, since, limit ?? 50)
|
|
146
|
-
return { content: [{ type: "text", text: JSON.stringify(trend) }] }
|
|
147
|
-
})
|
|
147
|
+
project_id: z.string(), page_id: z.string().optional(), since: z.string().optional(), limit: z.number().optional(),
|
|
148
|
+
}, ({ project_id, page_id, since, limit }) => ({
|
|
149
|
+
content: [{ type: "text", text: JSON.stringify(getPerfTrend(db, project_id, page_id, since, limit ?? 50)) }]
|
|
150
|
+
}))
|
|
148
151
|
|
|
149
152
|
server.tool("scan_status", {
|
|
150
153
|
project_id: z.string().optional(),
|
|
151
|
-
}, ({ project_id }) => {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
154
|
+
}, ({ project_id }) => ({
|
|
155
|
+
content: [{ type: "text", text: JSON.stringify(listJobs(db, project_id)) }]
|
|
156
|
+
}))
|
|
157
|
+
|
|
158
|
+
server.tool("list_projects", {}, () => ({
|
|
159
|
+
content: [{ type: "text", text: JSON.stringify(listProjects(db)) }]
|
|
160
|
+
}))
|
|
161
|
+
|
|
162
|
+
server.tool("list_pages", { project_id: z.string() }, ({ project_id }) => ({
|
|
163
|
+
content: [{ type: "text", text: JSON.stringify(listPages(db, project_id)) }]
|
|
164
|
+
}))
|
|
165
|
+
|
|
166
|
+
server.tool("list_issues", {
|
|
167
|
+
project_id: z.string().optional(), status: z.string().optional(), limit: z.number().optional(),
|
|
168
|
+
}, ({ project_id, status, limit }) => ({
|
|
169
|
+
content: [{ type: "text", text: JSON.stringify(listIssues(db, project_id, status, limit ?? 50)) }]
|
|
170
|
+
}))
|
|
171
|
+
|
|
172
|
+
server.tool("resolve_issue", {
|
|
173
|
+
id: z.string(), status: z.enum(["open", "resolved", "ignored"]),
|
|
174
|
+
}, ({ id, status }) => ({
|
|
175
|
+
content: [{ type: "text", text: JSON.stringify(updateIssueStatus(db, id, status)) }]
|
|
176
|
+
}))
|
|
177
|
+
|
|
178
|
+
server.tool("create_alert_rule", {
|
|
179
|
+
project_id: z.string(), name: z.string(),
|
|
180
|
+
level: z.string().optional(), service: z.string().optional(),
|
|
181
|
+
threshold_count: z.number().optional(), window_seconds: z.number().optional(),
|
|
182
|
+
action: z.enum(["webhook", "log"]).optional(), webhook_url: z.string().optional(),
|
|
183
|
+
}, (args) => ({ content: [{ type: "text", text: JSON.stringify(createAlertRule(db, args)) }] }))
|
|
184
|
+
|
|
185
|
+
server.tool("list_alert_rules", {
|
|
186
|
+
project_id: z.string().optional(),
|
|
187
|
+
}, ({ project_id }) => ({
|
|
188
|
+
content: [{ type: "text", text: JSON.stringify(listAlertRules(db, project_id)) }]
|
|
189
|
+
}))
|
|
155
190
|
|
|
156
|
-
server.tool("
|
|
157
|
-
|
|
191
|
+
server.tool("delete_alert_rule", { id: z.string() }, ({ id }) => {
|
|
192
|
+
deleteAlertRule(db, id)
|
|
193
|
+
return { content: [{ type: "text", text: "deleted" }] }
|
|
158
194
|
})
|
|
159
195
|
|
|
160
|
-
server.tool("
|
|
161
|
-
|
|
196
|
+
server.tool("log_session_context", {
|
|
197
|
+
session_id: z.string(),
|
|
198
|
+
brief: z.boolean().optional(),
|
|
199
|
+
}, async ({ session_id, brief }) => {
|
|
200
|
+
const ctx = await getSessionContext(db, session_id)
|
|
201
|
+
return { content: [{ type: "text", text: JSON.stringify({ ...ctx, logs: applyBrief(ctx.logs, brief !== false) }) }] }
|
|
162
202
|
})
|
|
163
203
|
|
|
204
|
+
server.tool("get_health", {}, () => ({
|
|
205
|
+
content: [{ type: "text", text: JSON.stringify(getHealth(db)) }]
|
|
206
|
+
}))
|
|
207
|
+
|
|
164
208
|
const transport = new StdioServerTransport()
|
|
165
209
|
await server.connect(transport)
|
package/src/server/index.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Hono } from "hono"
|
|
3
3
|
import { cors } from "hono/cors"
|
|
4
|
+
import { serveStatic } from "hono/bun"
|
|
4
5
|
import { getDb } from "../db/index.ts"
|
|
5
6
|
import { getBrowserScript } from "../lib/browser-script.ts"
|
|
7
|
+
import { getHealth } from "../lib/health.ts"
|
|
6
8
|
import { startScheduler } from "../lib/scheduler.ts"
|
|
9
|
+
import { alertsRoutes } from "./routes/alerts.ts"
|
|
10
|
+
import { issuesRoutes } from "./routes/issues.ts"
|
|
7
11
|
import { jobsRoutes } from "./routes/jobs.ts"
|
|
8
12
|
import { logsRoutes } from "./routes/logs.ts"
|
|
9
13
|
import { perfRoutes } from "./routes/perf.ts"
|
|
10
14
|
import { projectsRoutes } from "./routes/projects.ts"
|
|
15
|
+
import { streamRoutes } from "./routes/stream.ts"
|
|
11
16
|
|
|
12
17
|
const PORT = Number(process.env.LOGS_PORT ?? 3460)
|
|
13
18
|
const db = getDb()
|
|
@@ -25,11 +30,17 @@ app.get("/script.js", (c) => {
|
|
|
25
30
|
|
|
26
31
|
// API routes
|
|
27
32
|
app.route("/api/logs", logsRoutes(db))
|
|
33
|
+
app.route("/api/logs/stream", streamRoutes(db))
|
|
28
34
|
app.route("/api/projects", projectsRoutes(db))
|
|
29
35
|
app.route("/api/jobs", jobsRoutes(db))
|
|
36
|
+
app.route("/api/alerts", alertsRoutes(db))
|
|
37
|
+
app.route("/api/issues", issuesRoutes(db))
|
|
30
38
|
app.route("/api/perf", perfRoutes(db))
|
|
31
39
|
|
|
32
|
-
app.get("/", (c) => c.json(
|
|
40
|
+
app.get("/health", (c) => c.json(getHealth(db)))
|
|
41
|
+
app.get("/dashboard", (c) => c.redirect("/dashboard/"))
|
|
42
|
+
app.use("/dashboard/*", serveStatic({ root: "./dashboard/dist", rewriteRequestPath: (p) => p.replace(/^\/dashboard/, "") }))
|
|
43
|
+
app.get("/", (c) => c.json({ service: "@hasna/logs", port: PORT, status: "ok", dashboard: `http://localhost:${PORT}/dashboard/` }))
|
|
33
44
|
|
|
34
45
|
// Start scheduler
|
|
35
46
|
startScheduler(db)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { Hono } from "hono"
|
|
2
|
+
import type { Database } from "bun:sqlite"
|
|
3
|
+
import { createAlertRule, deleteAlertRule, listAlertRules, updateAlertRule } from "../../lib/alerts.ts"
|
|
4
|
+
|
|
5
|
+
export function alertsRoutes(db: Database) {
|
|
6
|
+
const app = new Hono()
|
|
7
|
+
|
|
8
|
+
app.post("/", async (c) => {
|
|
9
|
+
const body = await c.req.json()
|
|
10
|
+
if (!body.project_id || !body.name) return c.json({ error: "project_id and name required" }, 422)
|
|
11
|
+
return c.json(createAlertRule(db, body), 201)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
app.get("/", (c) => {
|
|
15
|
+
const { project_id } = c.req.query()
|
|
16
|
+
return c.json(listAlertRules(db, project_id || undefined))
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
app.put("/:id", async (c) => {
|
|
20
|
+
const body = await c.req.json()
|
|
21
|
+
const updated = updateAlertRule(db, c.req.param("id"), body)
|
|
22
|
+
if (!updated) return c.json({ error: "not found" }, 404)
|
|
23
|
+
return c.json(updated)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
app.delete("/:id", (c) => {
|
|
27
|
+
deleteAlertRule(db, c.req.param("id"))
|
|
28
|
+
return c.json({ deleted: true })
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
return app
|
|
32
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Hono } from "hono"
|
|
2
|
+
import type { Database } from "bun:sqlite"
|
|
3
|
+
import { getIssue, listIssues, updateIssueStatus } from "../../lib/issues.ts"
|
|
4
|
+
import { searchLogs } from "../../lib/query.ts"
|
|
5
|
+
|
|
6
|
+
export function issuesRoutes(db: Database) {
|
|
7
|
+
const app = new Hono()
|
|
8
|
+
|
|
9
|
+
app.get("/", (c) => {
|
|
10
|
+
const { project_id, status, limit } = c.req.query()
|
|
11
|
+
return c.json(listIssues(db, project_id || undefined, status || undefined, limit ? Number(limit) : 50))
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
app.get("/:id", (c) => {
|
|
15
|
+
const issue = getIssue(db, c.req.param("id"))
|
|
16
|
+
if (!issue) return c.json({ error: "not found" }, 404)
|
|
17
|
+
return c.json(issue)
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
app.get("/:id/logs", (c) => {
|
|
21
|
+
const issue = getIssue(db, c.req.param("id"))
|
|
22
|
+
if (!issue) return c.json({ error: "not found" }, 404)
|
|
23
|
+
// Search logs matching this issue's fingerprint via service+level
|
|
24
|
+
const rows = searchLogs(db, {
|
|
25
|
+
project_id: issue.project_id ?? undefined,
|
|
26
|
+
level: issue.level as "error",
|
|
27
|
+
service: issue.service ?? undefined,
|
|
28
|
+
text: issue.message_template.slice(0, 50),
|
|
29
|
+
limit: 50,
|
|
30
|
+
})
|
|
31
|
+
return c.json(rows)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
app.put("/:id", async (c) => {
|
|
35
|
+
const { status } = await c.req.json() as { status: "open" | "resolved" | "ignored" }
|
|
36
|
+
if (!["open", "resolved", "ignored"].includes(status)) return c.json({ error: "invalid status" }, 422)
|
|
37
|
+
const updated = updateIssueStatus(db, c.req.param("id"), status)
|
|
38
|
+
if (!updated) return c.json({ error: "not found" }, 404)
|
|
39
|
+
return c.json(updated)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
return app
|
|
43
|
+
}
|
|
@@ -3,6 +3,7 @@ import type { Database } from "bun:sqlite"
|
|
|
3
3
|
import { ingestBatch, ingestLog } from "../../lib/ingest.ts"
|
|
4
4
|
import { getLogContext, searchLogs, tailLogs } from "../../lib/query.ts"
|
|
5
5
|
import { summarizeLogs } from "../../lib/summarize.ts"
|
|
6
|
+
import { exportToCsv, exportToJson } from "../../lib/export.ts"
|
|
6
7
|
import type { LogEntry, LogLevel } from "../../types/index.ts"
|
|
7
8
|
|
|
8
9
|
export function logsRoutes(db: Database) {
|
|
@@ -61,5 +62,25 @@ export function logsRoutes(db: Database) {
|
|
|
61
62
|
return c.json(rows)
|
|
62
63
|
})
|
|
63
64
|
|
|
65
|
+
// GET /api/logs/export?format=json|csv&project_id=&since=&level=
|
|
66
|
+
app.get("/export", (c) => {
|
|
67
|
+
const { project_id, since, until, level, service, format, limit } = c.req.query()
|
|
68
|
+
const opts = { project_id: project_id || undefined, since: since || undefined, until: until || undefined, level: level || undefined, service: service || undefined, limit: limit ? Number(limit) : undefined }
|
|
69
|
+
|
|
70
|
+
if (format === "csv") {
|
|
71
|
+
c.header("Content-Type", "text/csv")
|
|
72
|
+
c.header("Content-Disposition", "attachment; filename=logs.csv")
|
|
73
|
+
const chunks: string[] = []
|
|
74
|
+
exportToCsv(db, opts, s => chunks.push(s))
|
|
75
|
+
return c.text(chunks.join(""))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
c.header("Content-Type", "application/json")
|
|
79
|
+
c.header("Content-Disposition", "attachment; filename=logs.json")
|
|
80
|
+
const chunks: string[] = []
|
|
81
|
+
exportToJson(db, opts, s => chunks.push(s))
|
|
82
|
+
return c.text(chunks.join("\n"))
|
|
83
|
+
})
|
|
84
|
+
|
|
64
85
|
return app
|
|
65
86
|
}
|
|
@@ -2,6 +2,8 @@ import { Hono } from "hono"
|
|
|
2
2
|
import type { Database } from "bun:sqlite"
|
|
3
3
|
import { createPage, createProject, getProject, listPages, listProjects } from "../../lib/projects.ts"
|
|
4
4
|
import { syncGithubRepo } from "../../lib/github.ts"
|
|
5
|
+
import { runRetentionForProject, setRetentionPolicy } from "../../lib/retention.ts"
|
|
6
|
+
import { deletePageAuth, setPageAuth } from "../../lib/page-auth.ts"
|
|
5
7
|
|
|
6
8
|
export function projectsRoutes(db: Database) {
|
|
7
9
|
const app = new Hono()
|
|
@@ -30,6 +32,29 @@ export function projectsRoutes(db: Database) {
|
|
|
30
32
|
|
|
31
33
|
app.get("/:id/pages", (c) => c.json(listPages(db, c.req.param("id"))))
|
|
32
34
|
|
|
35
|
+
app.put("/:id/retention", async (c) => {
|
|
36
|
+
const body = await c.req.json()
|
|
37
|
+
setRetentionPolicy(db, c.req.param("id"), body)
|
|
38
|
+
return c.json({ updated: true })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
app.post("/:id/retention/run", (c) => {
|
|
42
|
+
const result = runRetentionForProject(db, c.req.param("id"))
|
|
43
|
+
return c.json(result)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
app.post("/:id/pages/:page_id/auth", async (c) => {
|
|
47
|
+
const { type, credentials } = await c.req.json()
|
|
48
|
+
if (!type || !credentials) return c.json({ error: "type and credentials required" }, 422)
|
|
49
|
+
const result = setPageAuth(db, c.req.param("page_id"), type, credentials)
|
|
50
|
+
return c.json({ id: result.id, type: result.type, created_at: result.created_at }, 201)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
app.delete("/:id/pages/:page_id/auth", (c) => {
|
|
54
|
+
deletePageAuth(db, c.req.param("page_id"))
|
|
55
|
+
return c.json({ deleted: true })
|
|
56
|
+
})
|
|
57
|
+
|
|
33
58
|
app.post("/:id/sync-repo", async (c) => {
|
|
34
59
|
const project = getProject(db, c.req.param("id"))
|
|
35
60
|
if (!project) return c.json({ error: "not found" }, 404)
|