@hasna/logs 0.3.25 → 0.3.27
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/README.md +33 -10
- package/dashboard/dist/assets/index-C0wZYq1m.js +53 -0
- package/dashboard/dist/assets/index-DGNrK5qb.css +1 -0
- package/dashboard/dist/index.html +14 -0
- package/dist/cli/index.js +8511 -177
- package/dist/count-bmj4r2zb.js +10 -0
- package/dist/{diagnose-e0w5rwbc.js → diagnose-3q5cy9ra.js} +2 -2
- package/dist/{export-c3eqjste.js → export-cngdb9fh.js} +1 -1
- package/dist/{http-zm3ph78w.js → http-r0xc3d2s.js} +79 -8
- package/dist/index-931pbyn5.js +141 -0
- package/dist/index-b5c72f1p.js +7 -0
- package/dist/{index-p1vgwwsz.js → index-bnr19y0h.js} +596 -37
- package/dist/{index-7w7v7hnr.js → index-by1pdzbr.js} +14 -5
- package/dist/{index-3dr7d80h.js → index-e1930v9b.js} +12 -8
- package/dist/{index-eh9bkbpa.js → index-e72k53yq.js} +10 -2
- package/dist/{index-edn08m6f.js → index-gcd14q2f.js} +9 -6
- package/dist/index-hq6kzaah.js +26 -0
- package/dist/index-j34f36wy.js +5672 -0
- package/dist/index-p4dbdzx4.js +1849 -0
- package/dist/{index-5qznfyah.js → index-q27bgpr1.js} +1086 -1646
- package/dist/index-t3x838zw.js +2583 -0
- package/dist/{index-ww5ggfv3.js → index-zkb3z95a.js} +12 -9
- package/dist/index.js +2982 -22
- package/dist/{jobs-ypmmc2ma.js → jobs-hsgyhfvm.js} +2 -1
- package/dist/mcp/index.js +1473 -4286
- package/dist/{query-7jwj05er.js → query-c5a43zx3.js} +3 -2
- package/dist/server/index.js +2944 -417
- package/dist/storage.js +50 -0
- package/package.json +27 -8
- package/biome.json +0 -13
- package/bun.lock +0 -376
- package/dashboard/README.md +0 -73
- package/dashboard/bun.lock +0 -526
- package/dashboard/eslint.config.js +0 -23
- package/dashboard/index.html +0 -13
- package/dashboard/package.json +0 -32
- package/dashboard/src/App.css +0 -184
- package/dashboard/src/App.tsx +0 -49
- package/dashboard/src/api.ts +0 -33
- package/dashboard/src/assets/hero.png +0 -0
- package/dashboard/src/assets/react.svg +0 -1
- package/dashboard/src/assets/vite.svg +0 -1
- package/dashboard/src/index.css +0 -111
- package/dashboard/src/main.tsx +0 -10
- package/dashboard/src/pages/Alerts.tsx +0 -69
- package/dashboard/src/pages/Issues.tsx +0 -50
- package/dashboard/src/pages/Perf.tsx +0 -75
- package/dashboard/src/pages/Projects.tsx +0 -67
- package/dashboard/src/pages/Summary.tsx +0 -67
- package/dashboard/src/pages/Tail.tsx +0 -65
- package/dashboard/tsconfig.app.json +0 -28
- package/dashboard/tsconfig.json +0 -7
- package/dashboard/tsconfig.node.json +0 -26
- package/dashboard/vite.config.ts +0 -14
- package/dist/count-x3n7qg3c.js +0 -9
- package/dist/index-5cj74qka.js +0 -10803
- package/dist/index-997bkzr2.js +0 -15
- package/dist/index-kezb178p.js +0 -1241
- package/dist/index-pen6t0yc.js +0 -10794
- package/sdk/package.json +0 -27
- package/sdk/src/index.ts +0 -143
- package/sdk/src/types.ts +0 -56
- package/src/cli/entrypoints.test.ts +0 -63
- package/src/cli/index.ts +0 -471
- package/src/db/index.test.ts +0 -33
- package/src/db/index.ts +0 -189
- package/src/db/migrations/001_alert_rules.ts +0 -21
- package/src/db/migrations/002_issues.ts +0 -21
- package/src/db/migrations/003_retention.ts +0 -15
- package/src/db/migrations/004_page_auth.ts +0 -13
- package/src/db/pg-migrations.ts +0 -167
- package/src/index.ts +0 -1
- package/src/lib/alerts.test.ts +0 -67
- package/src/lib/alerts.ts +0 -117
- package/src/lib/browser-script.test.ts +0 -35
- package/src/lib/browser-script.ts +0 -31
- package/src/lib/compare.test.ts +0 -52
- package/src/lib/compare.ts +0 -85
- package/src/lib/count.test.ts +0 -44
- package/src/lib/count.ts +0 -55
- package/src/lib/diagnose.test.ts +0 -55
- package/src/lib/diagnose.ts +0 -91
- package/src/lib/export.test.ts +0 -66
- package/src/lib/export.ts +0 -65
- package/src/lib/github.ts +0 -38
- package/src/lib/health.test.ts +0 -48
- package/src/lib/health.ts +0 -51
- package/src/lib/ingest.test.ts +0 -57
- package/src/lib/ingest.ts +0 -78
- package/src/lib/issues.test.ts +0 -79
- package/src/lib/issues.ts +0 -70
- package/src/lib/jobs.test.ts +0 -69
- package/src/lib/jobs.ts +0 -63
- package/src/lib/lighthouse.ts +0 -65
- package/src/lib/package-meta.test.ts +0 -43
- package/src/lib/package-meta.ts +0 -80
- package/src/lib/page-auth.test.ts +0 -54
- package/src/lib/page-auth.ts +0 -48
- package/src/lib/parse-time.test.ts +0 -37
- package/src/lib/parse-time.ts +0 -14
- package/src/lib/perf.test.ts +0 -45
- package/src/lib/perf.ts +0 -46
- package/src/lib/projects.test.ts +0 -73
- package/src/lib/projects.ts +0 -69
- package/src/lib/query.test.ts +0 -104
- package/src/lib/query.ts +0 -84
- package/src/lib/retention.test.ts +0 -42
- package/src/lib/retention.ts +0 -62
- package/src/lib/rotate.test.ts +0 -37
- package/src/lib/rotate.ts +0 -27
- package/src/lib/scanner.ts +0 -131
- package/src/lib/scheduler.ts +0 -63
- package/src/lib/session-context.ts +0 -28
- package/src/lib/summarize.test.ts +0 -38
- package/src/lib/summarize.ts +0 -23
- package/src/mcp/http.test.ts +0 -92
- package/src/mcp/http.ts +0 -135
- package/src/mcp/index.test.ts +0 -27
- package/src/mcp/index.ts +0 -444
- package/src/server/index.ts +0 -61
- package/src/server/routes/alerts.ts +0 -32
- package/src/server/routes/issues.ts +0 -43
- package/src/server/routes/jobs.ts +0 -32
- package/src/server/routes/logs.ts +0 -113
- package/src/server/routes/perf.ts +0 -23
- package/src/server/routes/projects.ts +0 -67
- package/src/server/routes/stream.ts +0 -43
- package/src/server/server.test.ts +0 -194
- package/src/types/index.ts +0 -119
- package/tsconfig.json +0 -22
- /package/dashboard/{public → dist}/favicon.svg +0 -0
- /package/dashboard/{public → dist}/icons.svg +0 -0
package/src/lib/ingest.test.ts
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test"
|
|
2
|
-
import { createTestDb } from "../db/index.ts"
|
|
3
|
-
import { ingestBatch, ingestLog } from "./ingest.ts"
|
|
4
|
-
|
|
5
|
-
describe("ingest", () => {
|
|
6
|
-
it("inserts a single log entry", () => {
|
|
7
|
-
const db = createTestDb()
|
|
8
|
-
const row = ingestLog(db, { level: "error", message: "test error", service: "api" })
|
|
9
|
-
expect(row.id).toBeTruthy()
|
|
10
|
-
expect(row.level).toBe("error")
|
|
11
|
-
expect(row.message).toBe("test error")
|
|
12
|
-
expect(row.service).toBe("api")
|
|
13
|
-
expect(row.source).toBe("sdk")
|
|
14
|
-
expect(row.timestamp).toBeTruthy()
|
|
15
|
-
})
|
|
16
|
-
|
|
17
|
-
it("inserts with all optional fields", () => {
|
|
18
|
-
const db = createTestDb()
|
|
19
|
-
const row = ingestLog(db, {
|
|
20
|
-
level: "info",
|
|
21
|
-
message: "hello",
|
|
22
|
-
source: "scanner",
|
|
23
|
-
trace_id: "trace-123",
|
|
24
|
-
session_id: "sess-456",
|
|
25
|
-
agent: "brutus",
|
|
26
|
-
url: "https://example.com",
|
|
27
|
-
stack_trace: "Error at line 1",
|
|
28
|
-
metadata: { foo: "bar" },
|
|
29
|
-
})
|
|
30
|
-
expect(row.trace_id).toBe("trace-123")
|
|
31
|
-
expect(row.agent).toBe("brutus")
|
|
32
|
-
expect(row.metadata).toBe(JSON.stringify({ foo: "bar" }))
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it("inserts a batch", () => {
|
|
36
|
-
const db = createTestDb()
|
|
37
|
-
const rows = ingestBatch(db, [
|
|
38
|
-
{ level: "warn", message: "warn 1" },
|
|
39
|
-
{ level: "error", message: "err 1" },
|
|
40
|
-
{ level: "info", message: "info 1" },
|
|
41
|
-
])
|
|
42
|
-
expect(rows).toHaveLength(3)
|
|
43
|
-
expect(rows[0]!.level).toBe("warn")
|
|
44
|
-
expect(rows[2]!.level).toBe("info")
|
|
45
|
-
})
|
|
46
|
-
|
|
47
|
-
it("batch is transactional", () => {
|
|
48
|
-
const db = createTestDb()
|
|
49
|
-
const before = (db.prepare("SELECT COUNT(*) as c FROM logs").get() as { c: number }).c
|
|
50
|
-
ingestBatch(db, [
|
|
51
|
-
{ level: "debug", message: "a" },
|
|
52
|
-
{ level: "fatal", message: "b" },
|
|
53
|
-
])
|
|
54
|
-
const after = (db.prepare("SELECT COUNT(*) as c FROM logs").get() as { c: number }).c
|
|
55
|
-
expect(after - before).toBe(2)
|
|
56
|
-
})
|
|
57
|
-
})
|
package/src/lib/ingest.ts
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import type { DbAdapter } from "@hasna/cloud"
|
|
2
|
-
import type { LogEntry, LogRow } from "../types/index.ts"
|
|
3
|
-
import { upsertIssue } from "./issues.ts"
|
|
4
|
-
import { evaluateAlerts } from "./alerts.ts"
|
|
5
|
-
|
|
6
|
-
const ERROR_LEVELS = new Set(["warn", "error", "fatal"])
|
|
7
|
-
|
|
8
|
-
export function ingestLog(db: DbAdapter, entry: LogEntry): LogRow {
|
|
9
|
-
const stmt = db.prepare(`
|
|
10
|
-
INSERT INTO logs (project_id, page_id, level, source, service, message, trace_id, session_id, agent, url, stack_trace, metadata)
|
|
11
|
-
VALUES ($project_id, $page_id, $level, $source, $service, $message, $trace_id, $session_id, $agent, $url, $stack_trace, $metadata)
|
|
12
|
-
RETURNING *
|
|
13
|
-
`)
|
|
14
|
-
const row = stmt.get({
|
|
15
|
-
$project_id: entry.project_id ?? null,
|
|
16
|
-
$page_id: entry.page_id ?? null,
|
|
17
|
-
$level: entry.level,
|
|
18
|
-
$source: entry.source ?? "sdk",
|
|
19
|
-
$service: entry.service ?? null,
|
|
20
|
-
$message: entry.message,
|
|
21
|
-
$trace_id: entry.trace_id ?? null,
|
|
22
|
-
$session_id: entry.session_id ?? null,
|
|
23
|
-
$agent: entry.agent ?? null,
|
|
24
|
-
$url: entry.url ?? null,
|
|
25
|
-
$stack_trace: entry.stack_trace ?? null,
|
|
26
|
-
$metadata: entry.metadata ? JSON.stringify(entry.metadata) : null,
|
|
27
|
-
}) as LogRow
|
|
28
|
-
|
|
29
|
-
// Side effects: issue grouping + alert evaluation (fire-and-forget)
|
|
30
|
-
if (ERROR_LEVELS.has(entry.level)) {
|
|
31
|
-
if (entry.project_id) {
|
|
32
|
-
upsertIssue(db, { project_id: entry.project_id, level: entry.level, service: entry.service, message: entry.message, stack_trace: entry.stack_trace })
|
|
33
|
-
evaluateAlerts(db, entry.project_id, entry.service ?? null, entry.level).catch(() => {})
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
return row
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function ingestBatch(db: DbAdapter, entries: LogEntry[], sharedTraceId?: string | null): LogRow[] {
|
|
41
|
-
// Apply shared trace_id to entries that don't have their own
|
|
42
|
-
if (sharedTraceId) {
|
|
43
|
-
entries = entries.map(e => e.trace_id ? e : { ...e, trace_id: sharedTraceId })
|
|
44
|
-
}
|
|
45
|
-
const insert = db.prepare(`
|
|
46
|
-
INSERT INTO logs (project_id, page_id, level, source, service, message, trace_id, session_id, agent, url, stack_trace, metadata)
|
|
47
|
-
VALUES ($project_id, $page_id, $level, $source, $service, $message, $trace_id, $session_id, $agent, $url, $stack_trace, $metadata)
|
|
48
|
-
RETURNING *
|
|
49
|
-
`)
|
|
50
|
-
// @hasna/cloud executes the callback inside the transaction immediately.
|
|
51
|
-
const rows = db.transaction(() =>
|
|
52
|
-
entries.map(entry =>
|
|
53
|
-
insert.get({
|
|
54
|
-
$project_id: entry.project_id ?? null,
|
|
55
|
-
$page_id: entry.page_id ?? null,
|
|
56
|
-
$level: entry.level,
|
|
57
|
-
$source: entry.source ?? "sdk",
|
|
58
|
-
$service: entry.service ?? null,
|
|
59
|
-
$message: entry.message,
|
|
60
|
-
$trace_id: entry.trace_id ?? null,
|
|
61
|
-
$session_id: entry.session_id ?? null,
|
|
62
|
-
$agent: entry.agent ?? null,
|
|
63
|
-
$url: entry.url ?? null,
|
|
64
|
-
$stack_trace: entry.stack_trace ?? null,
|
|
65
|
-
$metadata: entry.metadata ? JSON.stringify(entry.metadata) : null,
|
|
66
|
-
}) as LogRow
|
|
67
|
-
)
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
// Issue grouping for error-level entries (outside transaction for perf)
|
|
71
|
-
for (const entry of entries) {
|
|
72
|
-
if (ERROR_LEVELS.has(entry.level) && entry.project_id) {
|
|
73
|
-
upsertIssue(db, { project_id: entry.project_id, level: entry.level, service: entry.service, message: entry.message, stack_trace: entry.stack_trace })
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return rows
|
|
78
|
-
}
|
package/src/lib/issues.test.ts
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
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
|
-
})
|
package/src/lib/issues.ts
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
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
|
-
}
|
package/src/lib/jobs.test.ts
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "bun:test"
|
|
2
|
-
import { createTestDb } from "../db/index.ts"
|
|
3
|
-
import { createJob, createScanRun, deleteJob, finishScanRun, listJobs, listScanRuns, updateJob } from "./jobs.ts"
|
|
4
|
-
|
|
5
|
-
function seedProject(db: ReturnType<typeof createTestDb>) {
|
|
6
|
-
return db.prepare("INSERT INTO projects (name) VALUES ('test') RETURNING id").get() as { id: string }
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
describe("jobs", () => {
|
|
10
|
-
it("creates a job", () => {
|
|
11
|
-
const db = createTestDb()
|
|
12
|
-
const p = seedProject(db)
|
|
13
|
-
const job = createJob(db, { project_id: p.id, schedule: "*/5 * * * *" })
|
|
14
|
-
expect(job.id).toBeTruthy()
|
|
15
|
-
expect(job.schedule).toBe("*/5 * * * *")
|
|
16
|
-
expect(job.enabled).toBe(1)
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
it("lists jobs for a project", () => {
|
|
20
|
-
const db = createTestDb()
|
|
21
|
-
const p = seedProject(db)
|
|
22
|
-
createJob(db, { project_id: p.id, schedule: "*/5 * * * *" })
|
|
23
|
-
createJob(db, { project_id: p.id, schedule: "*/10 * * * *" })
|
|
24
|
-
expect(listJobs(db, p.id)).toHaveLength(2)
|
|
25
|
-
})
|
|
26
|
-
|
|
27
|
-
it("updates a job", () => {
|
|
28
|
-
const db = createTestDb()
|
|
29
|
-
const p = seedProject(db)
|
|
30
|
-
const job = createJob(db, { project_id: p.id, schedule: "*/5 * * * *" })
|
|
31
|
-
const updated = updateJob(db, job.id, { enabled: 0 })
|
|
32
|
-
expect(updated?.enabled).toBe(0)
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it("deletes a job", () => {
|
|
36
|
-
const db = createTestDb()
|
|
37
|
-
const p = seedProject(db)
|
|
38
|
-
const job = createJob(db, { project_id: p.id, schedule: "*/5 * * * *" })
|
|
39
|
-
deleteJob(db, job.id)
|
|
40
|
-
expect(listJobs(db, p.id)).toHaveLength(0)
|
|
41
|
-
})
|
|
42
|
-
})
|
|
43
|
-
|
|
44
|
-
describe("scan runs", () => {
|
|
45
|
-
it("creates and finishes a scan run", () => {
|
|
46
|
-
const db = createTestDb()
|
|
47
|
-
const p = seedProject(db)
|
|
48
|
-
const job = createJob(db, { project_id: p.id, schedule: "*/5 * * * *" })
|
|
49
|
-
const run = createScanRun(db, { job_id: job.id })
|
|
50
|
-
expect(run.status).toBe("running")
|
|
51
|
-
expect(run.logs_collected).toBe(0)
|
|
52
|
-
|
|
53
|
-
const finished = finishScanRun(db, run.id, { status: "completed", logs_collected: 12, errors_found: 3, perf_score: 87.5 })
|
|
54
|
-
expect(finished?.status).toBe("completed")
|
|
55
|
-
expect(finished?.logs_collected).toBe(12)
|
|
56
|
-
expect(finished?.errors_found).toBe(3)
|
|
57
|
-
expect(finished?.perf_score).toBe(87.5)
|
|
58
|
-
expect(finished?.finished_at).toBeTruthy()
|
|
59
|
-
})
|
|
60
|
-
|
|
61
|
-
it("lists scan runs for a job", () => {
|
|
62
|
-
const db = createTestDb()
|
|
63
|
-
const p = seedProject(db)
|
|
64
|
-
const job = createJob(db, { project_id: p.id, schedule: "*/5 * * * *" })
|
|
65
|
-
createScanRun(db, { job_id: job.id })
|
|
66
|
-
createScanRun(db, { job_id: job.id })
|
|
67
|
-
expect(listScanRuns(db, job.id)).toHaveLength(2)
|
|
68
|
-
})
|
|
69
|
-
})
|
package/src/lib/jobs.ts
DELETED
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
import type { Database } from "bun:sqlite"
|
|
2
|
-
import type { ScanJob, ScanRun } from "../types/index.ts"
|
|
3
|
-
|
|
4
|
-
export function createJob(db: Database, data: { project_id: string; schedule: string; page_id?: string }): ScanJob {
|
|
5
|
-
return db.prepare(`
|
|
6
|
-
INSERT INTO scan_jobs (project_id, page_id, schedule)
|
|
7
|
-
VALUES ($project_id, $page_id, $schedule)
|
|
8
|
-
RETURNING *
|
|
9
|
-
`).get({
|
|
10
|
-
$project_id: data.project_id,
|
|
11
|
-
$page_id: data.page_id ?? null,
|
|
12
|
-
$schedule: data.schedule,
|
|
13
|
-
}) as ScanJob
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export function listJobs(db: Database, projectId?: string): ScanJob[] {
|
|
17
|
-
if (projectId) {
|
|
18
|
-
return db.prepare("SELECT * FROM scan_jobs WHERE project_id = $p ORDER BY created_at DESC").all({ $p: projectId }) as ScanJob[]
|
|
19
|
-
}
|
|
20
|
-
return db.prepare("SELECT * FROM scan_jobs ORDER BY created_at DESC").all() as ScanJob[]
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function getJob(db: Database, id: string): ScanJob | null {
|
|
24
|
-
return db.prepare("SELECT * FROM scan_jobs WHERE id = $id").get({ $id: id }) as ScanJob | null
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function updateJob(db: Database, id: string, data: { enabled?: number; schedule?: string; last_run_at?: string }): ScanJob | null {
|
|
28
|
-
const fields = Object.keys(data).map(k => `${k} = $${k}`).join(", ")
|
|
29
|
-
if (!fields) return getJob(db, id)
|
|
30
|
-
const params = Object.fromEntries(Object.entries(data).map(([k, v]) => [`$${k}`, v]))
|
|
31
|
-
params.$id = id
|
|
32
|
-
return db.prepare(`UPDATE scan_jobs SET ${fields} WHERE id = $id RETURNING *`).get(params) as ScanJob | null
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function deleteJob(db: Database, id: string): void {
|
|
36
|
-
db.run("DELETE FROM scan_jobs WHERE id = $id", { $id: id })
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function createScanRun(db: Database, data: { job_id: string; page_id?: string }): ScanRun {
|
|
40
|
-
return db.prepare(`
|
|
41
|
-
INSERT INTO scan_runs (job_id, page_id) VALUES ($job_id, $page_id) RETURNING *
|
|
42
|
-
`).get({ $job_id: data.job_id, $page_id: data.page_id ?? null }) as ScanRun
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function finishScanRun(db: Database, id: string, data: { status: "completed" | "failed"; logs_collected: number; errors_found: number; perf_score?: number }): ScanRun | null {
|
|
46
|
-
return db.prepare(`
|
|
47
|
-
UPDATE scan_runs SET finished_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'),
|
|
48
|
-
status = $status, logs_collected = $logs_collected,
|
|
49
|
-
errors_found = $errors_found, perf_score = $perf_score
|
|
50
|
-
WHERE id = $id RETURNING *
|
|
51
|
-
`).get({
|
|
52
|
-
$id: id,
|
|
53
|
-
$status: data.status,
|
|
54
|
-
$logs_collected: data.logs_collected,
|
|
55
|
-
$errors_found: data.errors_found,
|
|
56
|
-
$perf_score: data.perf_score ?? null,
|
|
57
|
-
}) as ScanRun | null
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function listScanRuns(db: Database, jobId: string, limit = 20): ScanRun[] {
|
|
61
|
-
return db.prepare("SELECT * FROM scan_runs WHERE job_id = $j ORDER BY started_at DESC LIMIT $l")
|
|
62
|
-
.all({ $j: jobId, $l: limit }) as ScanRun[]
|
|
63
|
-
}
|
package/src/lib/lighthouse.ts
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import type { Database } from "bun:sqlite"
|
|
2
|
-
import { saveSnapshot } from "./perf.ts"
|
|
3
|
-
import type { PerformanceSnapshot } from "../types/index.ts"
|
|
4
|
-
|
|
5
|
-
export interface LighthouseResult {
|
|
6
|
-
lcp: number | null
|
|
7
|
-
fcp: number | null
|
|
8
|
-
cls: number | null
|
|
9
|
-
tti: number | null
|
|
10
|
-
ttfb: number | null
|
|
11
|
-
score: number | null
|
|
12
|
-
raw_audit: string
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export async function runLighthouse(url: string): Promise<LighthouseResult | null> {
|
|
16
|
-
try {
|
|
17
|
-
// Dynamic import — lighthouse is an optional peer dep
|
|
18
|
-
const { default: lighthouse } = await import("lighthouse" as string)
|
|
19
|
-
const { chromium } = await import("playwright")
|
|
20
|
-
|
|
21
|
-
const browser = await chromium.launch({ headless: true, args: ["--remote-debugging-port=9222"] })
|
|
22
|
-
try {
|
|
23
|
-
const result = await lighthouse(url, {
|
|
24
|
-
port: 9222,
|
|
25
|
-
output: "json",
|
|
26
|
-
logLevel: "silent",
|
|
27
|
-
onlyCategories: ["performance"],
|
|
28
|
-
} as Parameters<typeof lighthouse>[1])
|
|
29
|
-
|
|
30
|
-
if (!result) return null
|
|
31
|
-
const audits = result.lhr.audits
|
|
32
|
-
const score = result.lhr.categories["performance"]?.score
|
|
33
|
-
|
|
34
|
-
return {
|
|
35
|
-
lcp: (audits["largest-contentful-paint"]?.numericValue ?? null) as number | null,
|
|
36
|
-
fcp: (audits["first-contentful-paint"]?.numericValue ?? null) as number | null,
|
|
37
|
-
cls: (audits["cumulative-layout-shift"]?.numericValue ?? null) as number | null,
|
|
38
|
-
tti: (audits["interactive"]?.numericValue ?? null) as number | null,
|
|
39
|
-
ttfb: (audits["server-response-time"]?.numericValue ?? null) as number | null,
|
|
40
|
-
score: score !== undefined ? score * 100 : null,
|
|
41
|
-
raw_audit: JSON.stringify(result.lhr.audits),
|
|
42
|
-
}
|
|
43
|
-
} finally {
|
|
44
|
-
await browser.close()
|
|
45
|
-
}
|
|
46
|
-
} catch {
|
|
47
|
-
return null
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
export async function runAndSaveLighthouse(
|
|
52
|
-
db: Database,
|
|
53
|
-
url: string,
|
|
54
|
-
projectId: string,
|
|
55
|
-
pageId?: string,
|
|
56
|
-
): Promise<PerformanceSnapshot | null> {
|
|
57
|
-
const result = await runLighthouse(url)
|
|
58
|
-
if (!result) return null
|
|
59
|
-
return saveSnapshot(db, {
|
|
60
|
-
project_id: projectId,
|
|
61
|
-
page_id: pageId ?? null,
|
|
62
|
-
url,
|
|
63
|
-
...result,
|
|
64
|
-
})
|
|
65
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { afterEach, expect, test } from "bun:test"
|
|
2
|
-
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"
|
|
3
|
-
import { tmpdir } from "node:os"
|
|
4
|
-
import { join } from "node:path"
|
|
5
|
-
import { pathToFileURL } from "node:url"
|
|
6
|
-
import { readPackageVersion } from "./package-meta.ts"
|
|
7
|
-
|
|
8
|
-
const tempRoots: string[] = []
|
|
9
|
-
|
|
10
|
-
function createFixture() {
|
|
11
|
-
const root = mkdtempSync(join(tmpdir(), "logs-package-meta-"))
|
|
12
|
-
tempRoots.push(root)
|
|
13
|
-
|
|
14
|
-
mkdirSync(join(root, "src/lib"), { recursive: true })
|
|
15
|
-
mkdirSync(join(root, "dist"), { recursive: true })
|
|
16
|
-
writeFileSync(join(root, "package.json"), JSON.stringify({ version: "9.9.9" }), "utf8")
|
|
17
|
-
|
|
18
|
-
return root
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
afterEach(() => {
|
|
22
|
-
while (tempRoots.length > 0) {
|
|
23
|
-
const root = tempRoots.pop()
|
|
24
|
-
if (!root) continue
|
|
25
|
-
rmSync(root, { recursive: true, force: true })
|
|
26
|
-
}
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
test("readPackageVersion resolves package.json from source module paths", () => {
|
|
30
|
-
const root = createFixture()
|
|
31
|
-
|
|
32
|
-
const version = readPackageVersion(pathToFileURL(join(root, "src/lib/fake.js")))
|
|
33
|
-
|
|
34
|
-
expect(version).toBe("9.9.9")
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
test("readPackageVersion resolves package.json from bundled dist chunk paths", () => {
|
|
38
|
-
const root = createFixture()
|
|
39
|
-
|
|
40
|
-
const version = readPackageVersion(pathToFileURL(join(root, "dist/index-abc123.js")))
|
|
41
|
-
|
|
42
|
-
expect(version).toBe("9.9.9")
|
|
43
|
-
})
|
package/src/lib/package-meta.ts
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs"
|
|
2
|
-
|
|
3
|
-
type StandaloneCliSpec = {
|
|
4
|
-
name: string
|
|
5
|
-
description: string
|
|
6
|
-
options?: string[]
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
type PackageJson = {
|
|
10
|
-
version?: string
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const PACKAGE_JSON_CANDIDATES = [
|
|
14
|
-
"../../package.json",
|
|
15
|
-
"../package.json",
|
|
16
|
-
"./package.json",
|
|
17
|
-
]
|
|
18
|
-
|
|
19
|
-
function readPackageJson(baseUrl: string | URL = import.meta.url): PackageJson {
|
|
20
|
-
// Bundled shared chunks live under dist/, while source modules live under src/lib/.
|
|
21
|
-
for (const relativePath of PACKAGE_JSON_CANDIDATES) {
|
|
22
|
-
const candidate = new URL(relativePath, baseUrl)
|
|
23
|
-
if (!existsSync(candidate)) continue
|
|
24
|
-
|
|
25
|
-
return JSON.parse(readFileSync(candidate, "utf8")) as PackageJson
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
throw new Error(`Unable to locate package.json from ${String(baseUrl)}`)
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function readPackageVersion(baseUrl: string | URL = import.meta.url): string {
|
|
32
|
-
return readPackageJson(baseUrl).version ?? "0.0.0"
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export const PACKAGE_VERSION = readPackageVersion()
|
|
36
|
-
|
|
37
|
-
export function exitIfMetadataRequest(spec: StandaloneCliSpec, argv = process.argv.slice(2)): void {
|
|
38
|
-
if (argv.includes("--version") || argv.includes("-V")) {
|
|
39
|
-
console.log(PACKAGE_VERSION)
|
|
40
|
-
process.exit(0)
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (argv.includes("--help") || argv.includes("-h")) {
|
|
44
|
-
const options = spec.options ?? []
|
|
45
|
-
const renderedOptions = [
|
|
46
|
-
" -V, --version output the version number",
|
|
47
|
-
" -h, --help display help for command",
|
|
48
|
-
...options,
|
|
49
|
-
]
|
|
50
|
-
|
|
51
|
-
console.log(
|
|
52
|
-
[
|
|
53
|
-
`Usage: ${spec.name} [options]`,
|
|
54
|
-
"",
|
|
55
|
-
spec.description,
|
|
56
|
-
"",
|
|
57
|
-
"Options:",
|
|
58
|
-
...renderedOptions,
|
|
59
|
-
].join("\n"),
|
|
60
|
-
)
|
|
61
|
-
process.exit(0)
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function readOptionValue(names: string[], argv = process.argv.slice(2)): string | undefined {
|
|
66
|
-
for (let index = 0; index < argv.length; index += 1) {
|
|
67
|
-
const arg = argv[index]
|
|
68
|
-
if (!arg) continue
|
|
69
|
-
|
|
70
|
-
const inline = names.find((name) => arg.startsWith(`${name}=`))
|
|
71
|
-
if (inline) return arg.slice(inline.length + 1)
|
|
72
|
-
|
|
73
|
-
if (names.includes(arg)) {
|
|
74
|
-
const next = argv[index + 1]
|
|
75
|
-
if (next && !next.startsWith("-")) return next
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return undefined
|
|
80
|
-
}
|
|
@@ -1,54 +0,0 @@
|
|
|
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
|
-
})
|