@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.
Files changed (65) hide show
  1. package/dashboard/README.md +73 -0
  2. package/dashboard/bun.lock +526 -0
  3. package/dashboard/eslint.config.js +23 -0
  4. package/dashboard/index.html +13 -0
  5. package/dashboard/package.json +32 -0
  6. package/dashboard/public/favicon.svg +1 -0
  7. package/dashboard/public/icons.svg +24 -0
  8. package/dashboard/src/App.css +184 -0
  9. package/dashboard/src/App.tsx +49 -0
  10. package/dashboard/src/api.ts +33 -0
  11. package/dashboard/src/assets/hero.png +0 -0
  12. package/dashboard/src/assets/react.svg +1 -0
  13. package/dashboard/src/assets/vite.svg +1 -0
  14. package/dashboard/src/index.css +111 -0
  15. package/dashboard/src/main.tsx +10 -0
  16. package/dashboard/src/pages/Alerts.tsx +69 -0
  17. package/dashboard/src/pages/Issues.tsx +50 -0
  18. package/dashboard/src/pages/Perf.tsx +75 -0
  19. package/dashboard/src/pages/Projects.tsx +67 -0
  20. package/dashboard/src/pages/Summary.tsx +67 -0
  21. package/dashboard/src/pages/Tail.tsx +65 -0
  22. package/dashboard/tsconfig.app.json +28 -0
  23. package/dashboard/tsconfig.json +7 -0
  24. package/dashboard/tsconfig.node.json +26 -0
  25. package/dashboard/vite.config.ts +14 -0
  26. package/dist/cli/index.js +116 -12
  27. package/dist/mcp/index.js +306 -100
  28. package/dist/server/index.js +592 -7
  29. package/package.json +12 -2
  30. package/sdk/package.json +3 -2
  31. package/sdk/src/index.ts +1 -1
  32. package/sdk/src/types.ts +56 -0
  33. package/src/cli/index.ts +114 -4
  34. package/src/db/index.ts +10 -0
  35. package/src/db/migrations/001_alert_rules.ts +21 -0
  36. package/src/db/migrations/002_issues.ts +21 -0
  37. package/src/db/migrations/003_retention.ts +15 -0
  38. package/src/db/migrations/004_page_auth.ts +13 -0
  39. package/src/lib/alerts.test.ts +67 -0
  40. package/src/lib/alerts.ts +117 -0
  41. package/src/lib/compare.test.ts +52 -0
  42. package/src/lib/compare.ts +85 -0
  43. package/src/lib/diagnose.test.ts +55 -0
  44. package/src/lib/diagnose.ts +76 -0
  45. package/src/lib/export.test.ts +66 -0
  46. package/src/lib/export.ts +65 -0
  47. package/src/lib/health.test.ts +48 -0
  48. package/src/lib/health.ts +51 -0
  49. package/src/lib/ingest.ts +25 -2
  50. package/src/lib/issues.test.ts +79 -0
  51. package/src/lib/issues.ts +70 -0
  52. package/src/lib/page-auth.test.ts +54 -0
  53. package/src/lib/page-auth.ts +48 -0
  54. package/src/lib/retention.test.ts +42 -0
  55. package/src/lib/retention.ts +62 -0
  56. package/src/lib/scanner.ts +21 -2
  57. package/src/lib/scheduler.ts +6 -0
  58. package/src/lib/session-context.ts +28 -0
  59. package/src/mcp/index.ts +133 -89
  60. package/src/server/index.ts +12 -1
  61. package/src/server/routes/alerts.ts +32 -0
  62. package/src/server/routes/issues.ts +43 -0
  63. package/src/server/routes/logs.ts +21 -0
  64. package/src/server/routes/projects.ts +25 -0
  65. 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
+ }
@@ -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
- const context = await browser.newContext({
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[] = []
@@ -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 type { LogLevel } from "../types/index.ts"
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.1" })
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 to a project (project_id, url, path?, name?)",
21
- create_scan_job: "Schedule headless page scans (project_id, schedule, page_id?)",
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?, page_id?, level?, since?, until?, text?, limit?)",
24
- log_tail: "Get N most recent logs (project_id?, n?)",
25
- log_summary: "Error/warn counts by service/page (project_id?, since?)",
26
- log_context: "All logs for a trace_id",
27
- perf_snapshot: "Latest performance snapshot for a project/page (project_id, page_id?)",
28
- perf_trend: "Performance over time (project_id, page_id?, since?, limit?)",
29
- scan_status: "Last scan runs per project (project_id?)",
30
- list_projects: "List all registered projects",
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
- search_tools: "Search available tools by keyword (query)",
33
- describe_tools: "List all tools with descriptions",
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
- const text = Object.entries(TOOLS).map(([k, v]) => `${k}: ${v}`).join("\n")
44
- return { content: [{ type: "text", text }] }
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
- github_repo: z.string().optional(),
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
- url: z.string(),
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
- schedule: z.string(),
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
- page_id: z.string().optional(),
94
- level: z.string().optional(),
95
- service: z.string().optional(),
96
- since: z.string().optional(),
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
- ...args,
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
- n: z.number().optional(),
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
- since: z.string().optional(),
120
- }, ({ project_id, since }) => {
121
- const summary = summarizeLogs(db, project_id, since)
122
- return { content: [{ type: "text", text: JSON.stringify(summary) }] }
123
- })
124
-
125
- server.tool("log_context", { trace_id: z.string() }, ({ trace_id }) => {
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("perf_snapshot", {
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
- page_id: z.string().optional(),
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
- const label = snap ? scoreLabel(snap.score) : "unknown"
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
- page_id: z.string().optional(),
142
- since: z.string().optional(),
143
- limit: z.number().optional(),
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
- const jobs = listJobs(db, project_id)
153
- return { content: [{ type: "text", text: JSON.stringify(jobs) }] }
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("list_projects", {}, () => {
157
- return { content: [{ type: "text", text: JSON.stringify(listProjects(db)) }] }
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("list_pages", { project_id: z.string() }, ({ project_id }) => {
161
- return { content: [{ type: "text", text: JSON.stringify(listPages(db, project_id)) }] }
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)
@@ -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({ service: "@hasna/logs", port: PORT, status: "ok" }))
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)