@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
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { createTestDb } from "../db/index.ts"
|
|
3
|
+
import { computeFingerprint, getIssue, listIssues, updateIssueStatus, upsertIssue } from "./issues.ts"
|
|
4
|
+
|
|
5
|
+
function seedProject(db: ReturnType<typeof createTestDb>) {
|
|
6
|
+
return db.prepare("INSERT INTO projects (name) VALUES ('app') RETURNING id").get() as { id: string }
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
describe("computeFingerprint", () => {
|
|
10
|
+
it("returns consistent hash for same input", () => {
|
|
11
|
+
const a = computeFingerprint("error", "api", "DB connection failed")
|
|
12
|
+
const b = computeFingerprint("error", "api", "DB connection failed")
|
|
13
|
+
expect(a).toBe(b)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it("returns different hash for different messages", () => {
|
|
17
|
+
const a = computeFingerprint("error", "api", "timeout")
|
|
18
|
+
const b = computeFingerprint("error", "api", "DB error")
|
|
19
|
+
expect(a).not.toBe(b)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("normalizes hex IDs in messages", () => {
|
|
23
|
+
const a = computeFingerprint("error", "api", "Error for id abc123def456")
|
|
24
|
+
const b = computeFingerprint("error", "api", "Error for id 000fffaabbcc")
|
|
25
|
+
expect(a).toBe(b)
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
describe("upsertIssue", () => {
|
|
30
|
+
it("creates a new issue", () => {
|
|
31
|
+
const db = createTestDb()
|
|
32
|
+
const p = seedProject(db)
|
|
33
|
+
const issue = upsertIssue(db, { project_id: p.id, level: "error", service: "api", message: "DB timeout" })
|
|
34
|
+
expect(issue.id).toBeTruthy()
|
|
35
|
+
expect(issue.count).toBe(1)
|
|
36
|
+
expect(issue.status).toBe("open")
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("increments count on duplicate", () => {
|
|
40
|
+
const db = createTestDb()
|
|
41
|
+
const p = seedProject(db)
|
|
42
|
+
upsertIssue(db, { project_id: p.id, level: "error", message: "Same error" })
|
|
43
|
+
upsertIssue(db, { project_id: p.id, level: "error", message: "Same error" })
|
|
44
|
+
const issue = upsertIssue(db, { project_id: p.id, level: "error", message: "Same error" })
|
|
45
|
+
expect(issue.count).toBe(3)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it("reopens resolved issues", () => {
|
|
49
|
+
const db = createTestDb()
|
|
50
|
+
const p = seedProject(db)
|
|
51
|
+
const issue = upsertIssue(db, { project_id: p.id, level: "error", message: "err" })
|
|
52
|
+
updateIssueStatus(db, issue.id, "resolved")
|
|
53
|
+
const reopened = upsertIssue(db, { project_id: p.id, level: "error", message: "err" })
|
|
54
|
+
expect(reopened.status).toBe("open")
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe("listIssues", () => {
|
|
59
|
+
it("filters by project and status", () => {
|
|
60
|
+
const db = createTestDb()
|
|
61
|
+
const p = seedProject(db)
|
|
62
|
+
const issue = upsertIssue(db, { project_id: p.id, level: "error", message: "database connection timed out" })
|
|
63
|
+
updateIssueStatus(db, issue.id, "resolved")
|
|
64
|
+
upsertIssue(db, { project_id: p.id, level: "error", message: "authentication service unavailable" })
|
|
65
|
+
expect(listIssues(db, p.id, "open")).toHaveLength(1)
|
|
66
|
+
expect(listIssues(db, p.id, "resolved")).toHaveLength(1)
|
|
67
|
+
expect(listIssues(db, p.id)).toHaveLength(2)
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe("updateIssueStatus", () => {
|
|
72
|
+
it("updates status", () => {
|
|
73
|
+
const db = createTestDb()
|
|
74
|
+
const p = seedProject(db)
|
|
75
|
+
const issue = upsertIssue(db, { project_id: p.id, level: "error", message: "x" })
|
|
76
|
+
const updated = updateIssueStatus(db, issue.id, "ignored")
|
|
77
|
+
expect(updated?.status).toBe("ignored")
|
|
78
|
+
})
|
|
79
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
import { createHash } from "node:crypto"
|
|
3
|
+
|
|
4
|
+
export interface Issue {
|
|
5
|
+
id: string
|
|
6
|
+
project_id: string | null
|
|
7
|
+
fingerprint: string
|
|
8
|
+
level: string
|
|
9
|
+
service: string | null
|
|
10
|
+
message_template: string
|
|
11
|
+
first_seen: string
|
|
12
|
+
last_seen: string
|
|
13
|
+
count: number
|
|
14
|
+
status: "open" | "resolved" | "ignored"
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function computeFingerprint(level: string, service: string | null, message: string, stackTrace?: string | null): string {
|
|
18
|
+
// Normalize message: strip hex IDs, numbers, timestamps
|
|
19
|
+
const normalized = message
|
|
20
|
+
.replace(/[0-9a-f]{8,}/gi, "<id>")
|
|
21
|
+
.replace(/\d+/g, "<n>")
|
|
22
|
+
.replace(/https?:\/\/[^\s]+/g, "<url>")
|
|
23
|
+
.trim()
|
|
24
|
+
const stackFrame = stackTrace ? stackTrace.split("\n").slice(0, 3).join("|") : ""
|
|
25
|
+
const raw = `${level}|${service ?? ""}|${normalized}|${stackFrame}`
|
|
26
|
+
return createHash("sha256").update(raw).digest("hex").slice(0, 16)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function upsertIssue(db: Database, data: {
|
|
30
|
+
project_id?: string
|
|
31
|
+
level: string
|
|
32
|
+
service?: string | null
|
|
33
|
+
message: string
|
|
34
|
+
stack_trace?: string | null
|
|
35
|
+
}): Issue {
|
|
36
|
+
const fingerprint = computeFingerprint(data.level, data.service ?? null, data.message, data.stack_trace)
|
|
37
|
+
return db.prepare(`
|
|
38
|
+
INSERT INTO issues (project_id, fingerprint, level, service, message_template)
|
|
39
|
+
VALUES ($project_id, $fingerprint, $level, $service, $message_template)
|
|
40
|
+
ON CONFLICT(project_id, fingerprint) DO UPDATE SET
|
|
41
|
+
count = count + 1,
|
|
42
|
+
last_seen = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
|
|
43
|
+
status = CASE WHEN status = 'resolved' THEN 'open' ELSE status END
|
|
44
|
+
RETURNING *
|
|
45
|
+
`).get({
|
|
46
|
+
$project_id: data.project_id ?? null,
|
|
47
|
+
$fingerprint: fingerprint,
|
|
48
|
+
$level: data.level,
|
|
49
|
+
$service: data.service ?? null,
|
|
50
|
+
$message_template: data.message.slice(0, 500),
|
|
51
|
+
}) as Issue
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function listIssues(db: Database, projectId?: string, status?: string, limit = 50): Issue[] {
|
|
55
|
+
const conditions: string[] = []
|
|
56
|
+
const params: Record<string, unknown> = { $limit: limit }
|
|
57
|
+
if (projectId) { conditions.push("project_id = $p"); params.$p = projectId }
|
|
58
|
+
if (status) { conditions.push("status = $status"); params.$status = status }
|
|
59
|
+
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : ""
|
|
60
|
+
return db.prepare(`SELECT * FROM issues ${where} ORDER BY last_seen DESC LIMIT $limit`).all(params) as Issue[]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function getIssue(db: Database, id: string): Issue | null {
|
|
64
|
+
return db.prepare("SELECT * FROM issues WHERE id = $id").get({ $id: id }) as Issue | null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function updateIssueStatus(db: Database, id: string, status: "open" | "resolved" | "ignored"): Issue | null {
|
|
68
|
+
return db.prepare("UPDATE issues SET status = $status WHERE id = $id RETURNING *")
|
|
69
|
+
.get({ $id: id, $status: status }) as Issue | null
|
|
70
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { createTestDb } from "../db/index.ts"
|
|
3
|
+
import { deletePageAuth, getPageAuth, setPageAuth } from "./page-auth.ts"
|
|
4
|
+
|
|
5
|
+
function seedPage(db: ReturnType<typeof createTestDb>) {
|
|
6
|
+
const p = db.prepare("INSERT INTO projects (name) VALUES ('app') RETURNING id").get() as { id: string }
|
|
7
|
+
const page = db.prepare("INSERT INTO pages (project_id, url) VALUES (?, 'https://app.com') RETURNING id").get(p.id) as { id: string }
|
|
8
|
+
return { projectId: p.id, pageId: page.id }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe("page auth", () => {
|
|
12
|
+
it("sets and retrieves bearer auth", () => {
|
|
13
|
+
const db = createTestDb()
|
|
14
|
+
const { pageId } = seedPage(db)
|
|
15
|
+
setPageAuth(db, pageId, "bearer", "my-token-123")
|
|
16
|
+
const auth = getPageAuth(db, pageId)
|
|
17
|
+
expect(auth?.type).toBe("bearer")
|
|
18
|
+
expect(auth?.credentials).toBe("my-token-123")
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("credentials are encrypted at rest", () => {
|
|
22
|
+
const db = createTestDb()
|
|
23
|
+
const { pageId } = seedPage(db)
|
|
24
|
+
setPageAuth(db, pageId, "bearer", "secret-token")
|
|
25
|
+
const raw = db.prepare("SELECT credentials FROM page_auth WHERE page_id = ?").get(pageId) as { credentials: string }
|
|
26
|
+
// Raw value should NOT be the plaintext token
|
|
27
|
+
expect(raw.credentials).not.toBe("secret-token")
|
|
28
|
+
expect(raw.credentials).toContain(":") // IV:encrypted format
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("upserts on duplicate page_id", () => {
|
|
32
|
+
const db = createTestDb()
|
|
33
|
+
const { pageId } = seedPage(db)
|
|
34
|
+
setPageAuth(db, pageId, "bearer", "token-v1")
|
|
35
|
+
setPageAuth(db, pageId, "bearer", "token-v2")
|
|
36
|
+
const auth = getPageAuth(db, pageId)
|
|
37
|
+
expect(auth?.credentials).toBe("token-v2")
|
|
38
|
+
const { c } = db.prepare("SELECT COUNT(*) as c FROM page_auth WHERE page_id = ?").get(pageId) as { c: number }
|
|
39
|
+
expect(c).toBe(1)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("returns null for unknown page", () => {
|
|
43
|
+
const db = createTestDb()
|
|
44
|
+
expect(getPageAuth(db, "nope")).toBeNull()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("deletes auth", () => {
|
|
48
|
+
const db = createTestDb()
|
|
49
|
+
const { pageId } = seedPage(db)
|
|
50
|
+
setPageAuth(db, pageId, "basic", "user:pass")
|
|
51
|
+
deletePageAuth(db, pageId)
|
|
52
|
+
expect(getPageAuth(db, pageId)).toBeNull()
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"
|
|
3
|
+
|
|
4
|
+
const SECRET_KEY = Buffer.from((process.env.LOGS_SECRET_KEY ?? "open-logs-default-key-32bytesXXX").padEnd(32).slice(0, 32))
|
|
5
|
+
|
|
6
|
+
export interface PageAuth {
|
|
7
|
+
id: string
|
|
8
|
+
page_id: string
|
|
9
|
+
type: "cookie" | "bearer" | "basic"
|
|
10
|
+
credentials: string
|
|
11
|
+
created_at: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function encrypt(text: string): string {
|
|
15
|
+
const iv = randomBytes(16)
|
|
16
|
+
const cipher = createCipheriv("aes-256-cbc", SECRET_KEY, iv)
|
|
17
|
+
const encrypted = Buffer.concat([cipher.update(text, "utf8"), cipher.final()])
|
|
18
|
+
return iv.toString("hex") + ":" + encrypted.toString("hex")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function decrypt(text: string): string {
|
|
22
|
+
const [ivHex, encHex] = text.split(":")
|
|
23
|
+
if (!ivHex || !encHex) return text
|
|
24
|
+
const iv = Buffer.from(ivHex, "hex")
|
|
25
|
+
const enc = Buffer.from(encHex, "hex")
|
|
26
|
+
const decipher = createDecipheriv("aes-256-cbc", SECRET_KEY, iv)
|
|
27
|
+
return Buffer.concat([decipher.update(enc), decipher.final()]).toString("utf8")
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function setPageAuth(db: Database, pageId: string, type: PageAuth["type"], credentials: string): PageAuth {
|
|
31
|
+
const encrypted = encrypt(credentials)
|
|
32
|
+
return db.prepare(`
|
|
33
|
+
INSERT INTO page_auth (page_id, type, credentials)
|
|
34
|
+
VALUES ($page_id, $type, $credentials)
|
|
35
|
+
ON CONFLICT(page_id) DO UPDATE SET type = excluded.type, credentials = excluded.credentials
|
|
36
|
+
RETURNING *
|
|
37
|
+
`).get({ $page_id: pageId, $type: type, $credentials: encrypted }) as PageAuth
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getPageAuth(db: Database, pageId: string): { type: PageAuth["type"]; credentials: string } | null {
|
|
41
|
+
const row = db.prepare("SELECT * FROM page_auth WHERE page_id = $id").get({ $id: pageId }) as PageAuth | null
|
|
42
|
+
if (!row) return null
|
|
43
|
+
return { type: row.type, credentials: decrypt(row.credentials) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function deletePageAuth(db: Database, pageId: string): void {
|
|
47
|
+
db.run("DELETE FROM page_auth WHERE page_id = $id", { $id: pageId })
|
|
48
|
+
}
|
|
@@ -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
|
|