@hasna/logs 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/mcp/index.ts CHANGED
@@ -9,28 +9,47 @@ 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 type { LogLevel, LogRow } from "../types/index.ts"
13
18
 
14
19
  const db = getDb()
15
- const server = new McpServer({ name: "logs", version: "0.0.1" })
20
+ const server = new McpServer({ name: "logs", version: "0.1.0" })
21
+
22
+ const BRIEF_FIELDS: (keyof LogRow)[] = ["id", "timestamp", "level", "message", "service"]
23
+
24
+ function applyBrief(rows: LogRow[], brief = true): unknown[] {
25
+ if (!brief) return rows
26
+ return rows.map(r => ({ id: r.id, timestamp: r.timestamp, level: r.level, message: r.message, service: r.service }))
27
+ }
16
28
 
17
- // Tool registry for search_tools / describe_tools pattern
18
29
  const TOOLS: Record<string, string> = {
19
30
  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?)",
31
+ register_page: "Register a page URL (project_id, url, path?, name?)",
32
+ create_scan_job: "Schedule page scans (project_id, schedule, page_id?)",
22
33
  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",
34
+ log_search: "Search logs (project_id?, level?, since?, text?, brief?=true, limit?)",
35
+ log_tail: "Recent logs (project_id?, n?, brief?=true)",
36
+ log_summary: "Error/warn counts by service (project_id?, since?)",
37
+ log_context: "All logs for a trace_id (trace_id, brief?=true)",
38
+ log_diagnose: "Full diagnosis: top errors, failing pages, perf regressions (project_id, since?)",
39
+ log_compare: "Compare two time windows for new/resolved errors and perf delta",
40
+ perf_snapshot: "Latest perf snapshot (project_id, page_id?)",
41
+ perf_trend: "Perf over time (project_id, page_id?, since?, limit?)",
42
+ scan_status: "Last scan jobs (project_id?)",
43
+ list_projects: "List all projects",
31
44
  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",
45
+ list_issues: "List grouped error issues (project_id?, status?, limit?)",
46
+ resolve_issue: "Update issue status (id, status: open|resolved|ignored)",
47
+ create_alert_rule: "Create alert rule (project_id, name, level, threshold_count, window_seconds, webhook_url?)",
48
+ list_alert_rules: "List alert rules (project_id?)",
49
+ delete_alert_rule: "Delete alert rule (id)",
50
+ get_health: "Server health + DB stats",
51
+ search_tools: "Search tools by keyword (query)",
52
+ describe_tools: "List all tools",
34
53
  }
35
54
 
36
55
  server.tool("search_tools", { query: z.string() }, ({ query }) => {
@@ -39,39 +58,21 @@ server.tool("search_tools", { query: z.string() }, ({ query }) => {
39
58
  return { content: [{ type: "text", text: matches.map(([k, v]) => `${k}: ${v}`).join("\n") || "No matches" }] }
40
59
  })
41
60
 
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
- })
61
+ server.tool("describe_tools", {}, () => ({
62
+ content: [{ type: "text", text: Object.entries(TOOLS).map(([k, v]) => `${k}: ${v}`).join("\n") }]
63
+ }))
46
64
 
47
65
  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
- })
66
+ name: z.string(), github_repo: z.string().optional(), base_url: z.string().optional(), description: z.string().optional(),
67
+ }, (args) => ({ content: [{ type: "text", text: JSON.stringify(createProject(db, args)) }] }))
56
68
 
57
69
  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
- })
70
+ project_id: z.string(), url: z.string(), path: z.string().optional(), name: z.string().optional(),
71
+ }, (args) => ({ content: [{ type: "text", text: JSON.stringify(createPage(db, args)) }] }))
66
72
 
67
73
  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
- })
74
+ project_id: z.string(), schedule: z.string(), page_id: z.string().optional(),
75
+ }, (args) => ({ content: [{ type: "text", text: JSON.stringify(createJob(db, args)) }] }))
75
76
 
76
77
  server.tool("log_push", {
77
78
  level: z.enum(["debug", "info", "warn", "error", "fatal"]),
@@ -89,77 +90,110 @@ server.tool("log_push", {
89
90
  })
90
91
 
91
92
  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(),
93
+ project_id: z.string().optional(), page_id: z.string().optional(),
94
+ level: z.string().optional(), service: z.string().optional(),
95
+ since: z.string().optional(), until: z.string().optional(),
96
+ text: z.string().optional(), trace_id: z.string().optional(),
97
+ limit: z.number().optional(), brief: z.boolean().optional(),
101
98
  }, (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) }] }
99
+ const rows = searchLogs(db, { ...args, level: args.level ? (args.level.split(",") as LogLevel[]) : undefined })
100
+ return { content: [{ type: "text", text: JSON.stringify(applyBrief(rows, args.brief !== false)) }] }
107
101
  })
108
102
 
109
103
  server.tool("log_tail", {
110
- project_id: z.string().optional(),
111
- n: z.number().optional(),
112
- }, ({ project_id, n }) => {
104
+ project_id: z.string().optional(), n: z.number().optional(), brief: z.boolean().optional(),
105
+ }, ({ project_id, n, brief }) => {
113
106
  const rows = tailLogs(db, project_id, n ?? 50)
114
- return { content: [{ type: "text", text: JSON.stringify(rows) }] }
107
+ return { content: [{ type: "text", text: JSON.stringify(applyBrief(rows, brief !== false)) }] }
115
108
  })
116
109
 
117
110
  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 }) => {
111
+ project_id: z.string().optional(), since: z.string().optional(),
112
+ }, ({ project_id, since }) => ({
113
+ content: [{ type: "text", text: JSON.stringify(summarizeLogs(db, project_id, since)) }]
114
+ }))
115
+
116
+ server.tool("log_context", {
117
+ trace_id: z.string(), brief: z.boolean().optional(),
118
+ }, ({ trace_id, brief }) => {
126
119
  const rows = getLogContext(db, trace_id)
127
- return { content: [{ type: "text", text: JSON.stringify(rows) }] }
120
+ return { content: [{ type: "text", text: JSON.stringify(applyBrief(rows, brief !== false)) }] }
128
121
  })
129
122
 
130
- server.tool("perf_snapshot", {
123
+ server.tool("log_diagnose", {
124
+ project_id: z.string(), since: z.string().optional(),
125
+ }, ({ project_id, since }) => ({
126
+ content: [{ type: "text", text: JSON.stringify(diagnose(db, project_id, since)) }]
127
+ }))
128
+
129
+ server.tool("log_compare", {
131
130
  project_id: z.string(),
132
- page_id: z.string().optional(),
131
+ a_since: z.string(), a_until: z.string(),
132
+ b_since: z.string(), b_until: z.string(),
133
+ }, ({ project_id, a_since, a_until, b_since, b_until }) => ({
134
+ content: [{ type: "text", text: JSON.stringify(compare(db, project_id, a_since, a_until, b_since, b_until)) }]
135
+ }))
136
+
137
+ server.tool("perf_snapshot", {
138
+ project_id: z.string(), page_id: z.string().optional(),
133
139
  }, ({ project_id, page_id }) => {
134
140
  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 }) }] }
141
+ return { content: [{ type: "text", text: JSON.stringify(snap ? { ...snap, label: scoreLabel(snap.score) } : null) }] }
137
142
  })
138
143
 
139
144
  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
- })
145
+ project_id: z.string(), page_id: z.string().optional(), since: z.string().optional(), limit: z.number().optional(),
146
+ }, ({ project_id, page_id, since, limit }) => ({
147
+ content: [{ type: "text", text: JSON.stringify(getPerfTrend(db, project_id, page_id, since, limit ?? 50)) }]
148
+ }))
148
149
 
149
150
  server.tool("scan_status", {
150
151
  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
- })
152
+ }, ({ project_id }) => ({
153
+ content: [{ type: "text", text: JSON.stringify(listJobs(db, project_id)) }]
154
+ }))
155
+
156
+ server.tool("list_projects", {}, () => ({
157
+ content: [{ type: "text", text: JSON.stringify(listProjects(db)) }]
158
+ }))
159
+
160
+ server.tool("list_pages", { project_id: z.string() }, ({ project_id }) => ({
161
+ content: [{ type: "text", text: JSON.stringify(listPages(db, project_id)) }]
162
+ }))
163
+
164
+ server.tool("list_issues", {
165
+ project_id: z.string().optional(), status: z.string().optional(), limit: z.number().optional(),
166
+ }, ({ project_id, status, limit }) => ({
167
+ content: [{ type: "text", text: JSON.stringify(listIssues(db, project_id, status, limit ?? 50)) }]
168
+ }))
169
+
170
+ server.tool("resolve_issue", {
171
+ id: z.string(), status: z.enum(["open", "resolved", "ignored"]),
172
+ }, ({ id, status }) => ({
173
+ content: [{ type: "text", text: JSON.stringify(updateIssueStatus(db, id, status)) }]
174
+ }))
175
+
176
+ server.tool("create_alert_rule", {
177
+ project_id: z.string(), name: z.string(),
178
+ level: z.string().optional(), service: z.string().optional(),
179
+ threshold_count: z.number().optional(), window_seconds: z.number().optional(),
180
+ action: z.enum(["webhook", "log"]).optional(), webhook_url: z.string().optional(),
181
+ }, (args) => ({ content: [{ type: "text", text: JSON.stringify(createAlertRule(db, args)) }] }))
182
+
183
+ server.tool("list_alert_rules", {
184
+ project_id: z.string().optional(),
185
+ }, ({ project_id }) => ({
186
+ content: [{ type: "text", text: JSON.stringify(listAlertRules(db, project_id)) }]
187
+ }))
155
188
 
156
- server.tool("list_projects", {}, () => {
157
- return { content: [{ type: "text", text: JSON.stringify(listProjects(db)) }] }
189
+ server.tool("delete_alert_rule", { id: z.string() }, ({ id }) => {
190
+ deleteAlertRule(db, id)
191
+ return { content: [{ type: "text", text: "deleted" }] }
158
192
  })
159
193
 
160
- server.tool("list_pages", { project_id: z.string() }, ({ project_id }) => {
161
- return { content: [{ type: "text", text: JSON.stringify(listPages(db, project_id)) }] }
162
- })
194
+ server.tool("get_health", {}, () => ({
195
+ content: [{ type: "text", text: JSON.stringify(getHealth(db)) }]
196
+ }))
163
197
 
164
198
  const transport = new StdioServerTransport()
165
199
  await server.connect(transport)
@@ -3,11 +3,15 @@ import { Hono } from "hono"
3
3
  import { cors } from "hono/cors"
4
4
  import { getDb } from "../db/index.ts"
5
5
  import { getBrowserScript } from "../lib/browser-script.ts"
6
+ import { getHealth } from "../lib/health.ts"
6
7
  import { startScheduler } from "../lib/scheduler.ts"
8
+ import { alertsRoutes } from "./routes/alerts.ts"
9
+ import { issuesRoutes } from "./routes/issues.ts"
7
10
  import { jobsRoutes } from "./routes/jobs.ts"
8
11
  import { logsRoutes } from "./routes/logs.ts"
9
12
  import { perfRoutes } from "./routes/perf.ts"
10
13
  import { projectsRoutes } from "./routes/projects.ts"
14
+ import { streamRoutes } from "./routes/stream.ts"
11
15
 
12
16
  const PORT = Number(process.env.LOGS_PORT ?? 3460)
13
17
  const db = getDb()
@@ -25,10 +29,14 @@ app.get("/script.js", (c) => {
25
29
 
26
30
  // API routes
27
31
  app.route("/api/logs", logsRoutes(db))
32
+ app.route("/api/logs/stream", streamRoutes(db))
28
33
  app.route("/api/projects", projectsRoutes(db))
29
34
  app.route("/api/jobs", jobsRoutes(db))
35
+ app.route("/api/alerts", alertsRoutes(db))
36
+ app.route("/api/issues", issuesRoutes(db))
30
37
  app.route("/api/perf", perfRoutes(db))
31
38
 
39
+ app.get("/health", (c) => c.json(getHealth(db)))
32
40
  app.get("/", (c) => c.json({ service: "@hasna/logs", port: PORT, status: "ok" }))
33
41
 
34
42
  // Start scheduler
@@ -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)
@@ -0,0 +1,43 @@
1
+ import { Hono } from "hono"
2
+ import { streamSSE } from "hono/streaming"
3
+ import type { Database } from "bun:sqlite"
4
+ import type { LogLevel, LogRow } from "../../types/index.ts"
5
+
6
+ export function streamRoutes(db: Database) {
7
+ const app = new Hono()
8
+
9
+ // GET /api/logs/stream?project_id=&level=&service=
10
+ app.get("/", (c) => {
11
+ const { project_id, level, service } = c.req.query()
12
+
13
+ return streamSSE(c, async (stream) => {
14
+ let lastId: string | null = null
15
+
16
+ // Seed lastId with the most recent log so we only stream new ones
17
+ const latest = db.prepare("SELECT id FROM logs ORDER BY timestamp DESC LIMIT 1").get() as { id: string } | null
18
+ lastId = latest?.id ?? null
19
+
20
+ while (true) {
21
+ const conditions: string[] = []
22
+ const params: Record<string, unknown> = {}
23
+
24
+ if (lastId) { conditions.push("rowid > (SELECT rowid FROM logs WHERE id = $lastId)"); params.$lastId = lastId }
25
+ if (project_id) { conditions.push("project_id = $project_id"); params.$project_id = project_id }
26
+ if (level) { conditions.push("level IN (" + level.split(",").map((l, i) => `$l${i}`).join(",") + ")"); level.split(",").forEach((l, i) => { params[`$l${i}`] = l }) }
27
+ if (service) { conditions.push("service = $service"); params.$service = service }
28
+
29
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""
30
+ const rows = db.prepare(`SELECT * FROM logs ${where} ORDER BY timestamp ASC LIMIT 50`).all(params) as LogRow[]
31
+
32
+ for (const row of rows) {
33
+ await stream.writeSSE({ data: JSON.stringify(row), id: row.id, event: row.level })
34
+ lastId = row.id
35
+ }
36
+
37
+ await stream.sleep(500)
38
+ }
39
+ })
40
+ })
41
+
42
+ return app
43
+ }