@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/dist/cli/index.js +40 -7
- package/dist/mcp/index.js +245 -67
- package/dist/server/index.js +313 -3
- package/package.json +10 -2
- package/src/cli/index.ts +44 -0
- 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/mcp/index.ts +124 -90
- package/src/server/index.ts +8 -0
- 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
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
|
|
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
|
|
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
|
|
21
|
-
create_scan_job: "Schedule
|
|
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?,
|
|
24
|
-
log_tail: "
|
|
25
|
-
log_summary: "Error/warn counts by service
|
|
26
|
-
log_context: "All logs for a trace_id",
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
33
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
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
|
-
|
|
153
|
-
|
|
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("
|
|
157
|
-
|
|
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("
|
|
161
|
-
|
|
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)
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
+
}
|