@hasna/logs 0.0.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/dashboard/README.md +73 -0
  2. package/dashboard/bun.lock +526 -0
  3. package/dashboard/eslint.config.js +23 -0
  4. package/dashboard/index.html +13 -0
  5. package/dashboard/package.json +32 -0
  6. package/dashboard/public/favicon.svg +1 -0
  7. package/dashboard/public/icons.svg +24 -0
  8. package/dashboard/src/App.css +184 -0
  9. package/dashboard/src/App.tsx +49 -0
  10. package/dashboard/src/api.ts +33 -0
  11. package/dashboard/src/assets/hero.png +0 -0
  12. package/dashboard/src/assets/react.svg +1 -0
  13. package/dashboard/src/assets/vite.svg +1 -0
  14. package/dashboard/src/index.css +111 -0
  15. package/dashboard/src/main.tsx +10 -0
  16. package/dashboard/src/pages/Alerts.tsx +69 -0
  17. package/dashboard/src/pages/Issues.tsx +50 -0
  18. package/dashboard/src/pages/Perf.tsx +75 -0
  19. package/dashboard/src/pages/Projects.tsx +67 -0
  20. package/dashboard/src/pages/Summary.tsx +67 -0
  21. package/dashboard/src/pages/Tail.tsx +65 -0
  22. package/dashboard/tsconfig.app.json +28 -0
  23. package/dashboard/tsconfig.json +7 -0
  24. package/dashboard/tsconfig.node.json +26 -0
  25. package/dashboard/vite.config.ts +14 -0
  26. package/dist/cli/index.js +116 -12
  27. package/dist/mcp/index.js +306 -100
  28. package/dist/server/index.js +592 -7
  29. package/package.json +12 -2
  30. package/sdk/package.json +3 -2
  31. package/sdk/src/index.ts +1 -1
  32. package/sdk/src/types.ts +56 -0
  33. package/src/cli/index.ts +114 -4
  34. package/src/db/index.ts +10 -0
  35. package/src/db/migrations/001_alert_rules.ts +21 -0
  36. package/src/db/migrations/002_issues.ts +21 -0
  37. package/src/db/migrations/003_retention.ts +15 -0
  38. package/src/db/migrations/004_page_auth.ts +13 -0
  39. package/src/lib/alerts.test.ts +67 -0
  40. package/src/lib/alerts.ts +117 -0
  41. package/src/lib/compare.test.ts +52 -0
  42. package/src/lib/compare.ts +85 -0
  43. package/src/lib/diagnose.test.ts +55 -0
  44. package/src/lib/diagnose.ts +76 -0
  45. package/src/lib/export.test.ts +66 -0
  46. package/src/lib/export.ts +65 -0
  47. package/src/lib/health.test.ts +48 -0
  48. package/src/lib/health.ts +51 -0
  49. package/src/lib/ingest.ts +25 -2
  50. package/src/lib/issues.test.ts +79 -0
  51. package/src/lib/issues.ts +70 -0
  52. package/src/lib/page-auth.test.ts +54 -0
  53. package/src/lib/page-auth.ts +48 -0
  54. package/src/lib/retention.test.ts +42 -0
  55. package/src/lib/retention.ts +62 -0
  56. package/src/lib/scanner.ts +21 -2
  57. package/src/lib/scheduler.ts +6 -0
  58. package/src/lib/session-context.ts +28 -0
  59. package/src/mcp/index.ts +133 -89
  60. package/src/server/index.ts +12 -1
  61. package/src/server/routes/alerts.ts +32 -0
  62. package/src/server/routes/issues.ts +43 -0
  63. package/src/server/routes/logs.ts +21 -0
  64. package/src/server/routes/projects.ts +25 -0
  65. package/src/server/routes/stream.ts +43 -0
@@ -0,0 +1,50 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { get, put, type Issue } from '../api'
3
+
4
+ const STATUS_COLOR: Record<string, string> = { open: '#f87171', resolved: '#4ade80', ignored: '#64748b' }
5
+
6
+ export function Issues() {
7
+ const [issues, setIssues] = useState<Issue[]>([])
8
+ const [status, setStatus] = useState('open')
9
+
10
+ const load = () => get<Issue[]>(`/issues?status=${status}&limit=100`).then(setIssues).catch(() => {})
11
+ useEffect(() => { load() }, [status])
12
+
13
+ const updateStatus = async (id: string, s: string) => {
14
+ await put(`/issues/${id}`, { status: s })
15
+ load()
16
+ }
17
+
18
+ return (
19
+ <div>
20
+ <div style={{ display: 'flex', gap: 12, marginBottom: 16, alignItems: 'center' }}>
21
+ <h2 style={{ margin: 0, color: '#38bdf8' }}>Issues</h2>
22
+ {['open', 'resolved', 'ignored'].map(s => (
23
+ <button key={s} onClick={() => setStatus(s)} style={{ background: status === s ? '#1e40af' : '#1e293b', color: '#e2e8f0', border: 'none', padding: '4px 12px', borderRadius: 4, cursor: 'pointer', textTransform: 'capitalize' }}>{s}</button>
24
+ ))}
25
+ <span style={{ color: '#64748b', fontSize: 12 }}>{issues.length} issue(s)</span>
26
+ </div>
27
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
28
+ {issues.length === 0 && <div style={{ color: '#64748b', padding: 20, textAlign: 'center' }}>No {status} issues 🎉</div>}
29
+ {issues.map(issue => (
30
+ <div key={issue.id} style={{ background: '#1e293b', borderRadius: 8, padding: '12px 16px', display: 'flex', gap: 16, alignItems: 'flex-start' }}>
31
+ <div style={{ flex: 1 }}>
32
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 4 }}>
33
+ <span style={{ color: STATUS_COLOR[issue.status] ?? '#e2e8f0', fontWeight: 700, fontSize: 12 }}>{issue.level.toUpperCase()}</span>
34
+ {issue.service && <span style={{ color: '#7dd3fc', fontSize: 12 }}>{issue.service}</span>}
35
+ <span style={{ color: '#64748b', fontSize: 11 }}>×{issue.count}</span>
36
+ <span style={{ color: '#475569', fontSize: 11 }}>last: {issue.last_seen.slice(0, 16).replace('T', ' ')}</span>
37
+ </div>
38
+ <div style={{ color: '#e2e8f0', fontSize: 13 }}>{issue.message_template}</div>
39
+ </div>
40
+ <div style={{ display: 'flex', gap: 6 }}>
41
+ {issue.status !== 'resolved' && <button onClick={() => updateStatus(issue.id, 'resolved')} style={{ background: '#166534', color: '#4ade80', border: 'none', padding: '3px 8px', borderRadius: 4, cursor: 'pointer', fontSize: 12 }}>Resolve</button>}
42
+ {issue.status !== 'ignored' && <button onClick={() => updateStatus(issue.id, 'ignored')} style={{ background: '#1e293b', color: '#64748b', border: '1px solid #334155', padding: '3px 8px', borderRadius: 4, cursor: 'pointer', fontSize: 12 }}>Ignore</button>}
43
+ {issue.status !== 'open' && <button onClick={() => updateStatus(issue.id, 'open')} style={{ background: '#7f1d1d', color: '#f87171', border: 'none', padding: '3px 8px', borderRadius: 4, cursor: 'pointer', fontSize: 12 }}>Reopen</button>}
44
+ </div>
45
+ </div>
46
+ ))}
47
+ </div>
48
+ </div>
49
+ )
50
+ }
@@ -0,0 +1,75 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts'
3
+ import { get, type Project, type PerfSnapshot } from '../api'
4
+
5
+ function ScoreBadge({ score }: { score: number | null }) {
6
+ const color = score === null ? '#64748b' : score >= 90 ? '#4ade80' : score >= 50 ? '#fbbf24' : '#f87171'
7
+ return <span style={{ color, fontWeight: 700, fontSize: 18 }}>{score !== null ? Math.round(score) : '—'}</span>
8
+ }
9
+
10
+ export function Perf() {
11
+ const [projects, setProjects] = useState<Project[]>([])
12
+ const [selected, setSelected] = useState<string>('')
13
+ const [trend, setTrend] = useState<PerfSnapshot[]>([])
14
+
15
+ useEffect(() => {
16
+ get<Project[]>('/projects').then(p => { setProjects(p); if (p[0]) setSelected(p[0].id) }).catch(() => {})
17
+ }, [])
18
+
19
+ useEffect(() => {
20
+ if (!selected) return
21
+ get<PerfSnapshot[]>(`/perf/trend?project_id=${selected}&limit=30`).then(setTrend).catch(() => {})
22
+ }, [selected])
23
+
24
+ const chartData = [...trend].reverse().map(s => ({
25
+ time: s.timestamp.slice(5, 16).replace('T', ' '),
26
+ score: s.score !== null ? Math.round(s.score) : null,
27
+ lcp: s.lcp !== null ? Math.round(s.lcp) : null,
28
+ fcp: s.fcp !== null ? Math.round(s.fcp) : null,
29
+ }))
30
+
31
+ return (
32
+ <div>
33
+ <div style={{ display: 'flex', gap: 12, marginBottom: 16, alignItems: 'center' }}>
34
+ <h2 style={{ margin: 0, color: '#38bdf8' }}>Performance</h2>
35
+ <select value={selected} onChange={e => setSelected(e.target.value)} style={{ background: '#1e293b', border: '1px solid #334155', color: '#e2e8f0', padding: '4px 8px', borderRadius: 4 }}>
36
+ {projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
37
+ </select>
38
+ </div>
39
+
40
+ {trend.length > 0 && (
41
+ <div style={{ display: 'flex', gap: 16, marginBottom: 20, flexWrap: 'wrap' }}>
42
+ {[
43
+ { label: 'Score', value: trend[0]?.score ?? null },
44
+ { label: 'LCP (ms)', value: trend[0]?.lcp !== null && trend[0]?.lcp !== undefined ? Math.round(trend[0].lcp) : null },
45
+ { label: 'FCP (ms)', value: trend[0]?.fcp !== null && trend[0]?.fcp !== undefined ? Math.round(trend[0].fcp) : null },
46
+ { label: 'CLS', value: trend[0]?.cls !== null && trend[0]?.cls !== undefined ? trend[0].cls.toFixed(3) : null },
47
+ ].map(stat => (
48
+ <div key={stat.label} style={{ background: '#1e293b', borderRadius: 8, padding: '12px 20px', textAlign: 'center', minWidth: 100 }}>
49
+ <ScoreBadge score={Number(stat.value)} />
50
+ <div style={{ color: '#64748b', fontSize: 12, marginTop: 4 }}>{stat.label}</div>
51
+ </div>
52
+ ))}
53
+ </div>
54
+ )}
55
+
56
+ {chartData.length > 1 ? (
57
+ <div style={{ background: '#1e293b', borderRadius: 8, padding: 20 }}>
58
+ <div style={{ color: '#94a3b8', marginBottom: 12, fontSize: 13 }}>Performance Score Over Time</div>
59
+ <ResponsiveContainer width="100%" height={250}>
60
+ <LineChart data={chartData}>
61
+ <XAxis dataKey="time" stroke="#64748b" tick={{ fill: '#94a3b8', fontSize: 11 }} />
62
+ <YAxis domain={[0, 100]} stroke="#64748b" tick={{ fill: '#94a3b8', fontSize: 12 }} />
63
+ <Tooltip contentStyle={{ background: '#0f172a', border: '1px solid #334155', color: '#e2e8f0' }} />
64
+ <Line type="monotone" dataKey="score" stroke="#38bdf8" strokeWidth={2} dot={false} />
65
+ </LineChart>
66
+ </ResponsiveContainer>
67
+ </div>
68
+ ) : (
69
+ <div style={{ color: '#64748b', padding: 40, textAlign: 'center', background: '#1e293b', borderRadius: 8 }}>
70
+ {projects.length === 0 ? 'No projects yet. Register one via CLI or MCP.' : 'No performance data yet. Run a scan job to collect metrics.'}
71
+ </div>
72
+ )}
73
+ </div>
74
+ )
75
+ }
@@ -0,0 +1,67 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { get, post, type Project } from '../api'
3
+
4
+ export function Projects() {
5
+ const [projects, setProjects] = useState<Project[]>([])
6
+ const [form, setForm] = useState({ name: '', github_repo: '', base_url: '' })
7
+ const [pageForm, setPageForm] = useState({ project_id: '', url: '', name: '' })
8
+
9
+ const load = () => get<Project[]>('/projects').then(setProjects).catch(() => {})
10
+ useEffect(() => { load() }, [])
11
+
12
+ const create = async () => {
13
+ if (!form.name) return
14
+ await post('/projects', form)
15
+ setForm({ name: '', github_repo: '', base_url: '' })
16
+ load()
17
+ }
18
+
19
+ const addPage = async () => {
20
+ if (!pageForm.project_id || !pageForm.url) return
21
+ await post(`/projects/${pageForm.project_id}/pages`, { url: pageForm.url, name: pageForm.name })
22
+ setPageForm(f => ({ ...f, url: '', name: '' }))
23
+ }
24
+
25
+ return (
26
+ <div>
27
+ <h2 style={{ color: '#38bdf8', marginBottom: 16 }}>Projects</h2>
28
+
29
+ <div style={{ background: '#1e293b', borderRadius: 8, padding: 16, marginBottom: 16 }}>
30
+ <div style={{ color: '#94a3b8', fontSize: 13, marginBottom: 10 }}>Register Project</div>
31
+ <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
32
+ <input placeholder="Name *" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} style={{ background: '#0f172a', border: '1px solid #334155', color: '#e2e8f0', padding: '4px 8px', borderRadius: 4, fontFamily: 'monospace' }} />
33
+ <input placeholder="GitHub repo" value={form.github_repo} onChange={e => setForm(f => ({ ...f, github_repo: e.target.value }))} style={{ background: '#0f172a', border: '1px solid #334155', color: '#e2e8f0', padding: '4px 8px', borderRadius: 4, width: 220, fontFamily: 'monospace' }} />
34
+ <input placeholder="Base URL" value={form.base_url} onChange={e => setForm(f => ({ ...f, base_url: e.target.value }))} style={{ background: '#0f172a', border: '1px solid #334155', color: '#e2e8f0', padding: '4px 8px', borderRadius: 4, width: 200, fontFamily: 'monospace' }} />
35
+ <button onClick={create} style={{ background: '#1e40af', color: '#e2e8f0', border: 'none', padding: '4px 16px', borderRadius: 4, cursor: 'pointer' }}>+ Create</button>
36
+ </div>
37
+ </div>
38
+
39
+ <div style={{ background: '#1e293b', borderRadius: 8, padding: 16, marginBottom: 20 }}>
40
+ <div style={{ color: '#94a3b8', fontSize: 13, marginBottom: 10 }}>Register Page</div>
41
+ <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
42
+ <select value={pageForm.project_id} onChange={e => setPageForm(f => ({ ...f, project_id: e.target.value }))} style={{ background: '#0f172a', border: '1px solid #334155', color: '#e2e8f0', padding: '4px 8px', borderRadius: 4 }}>
43
+ <option value="">Select project</option>
44
+ {projects.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
45
+ </select>
46
+ <input placeholder="URL *" value={pageForm.url} onChange={e => setPageForm(f => ({ ...f, url: e.target.value }))} style={{ background: '#0f172a', border: '1px solid #334155', color: '#e2e8f0', padding: '4px 8px', borderRadius: 4, width: 250, fontFamily: 'monospace' }} />
47
+ <input placeholder="Name" value={pageForm.name} onChange={e => setPageForm(f => ({ ...f, name: e.target.value }))} style={{ background: '#0f172a', border: '1px solid #334155', color: '#e2e8f0', padding: '4px 8px', borderRadius: 4, fontFamily: 'monospace' }} />
48
+ <button onClick={addPage} style={{ background: '#1e40af', color: '#e2e8f0', border: 'none', padding: '4px 16px', borderRadius: 4, cursor: 'pointer' }}>+ Add Page</button>
49
+ </div>
50
+ </div>
51
+
52
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
53
+ {projects.length === 0 && <div style={{ color: '#64748b', padding: 20, textAlign: 'center' }}>No projects yet.</div>}
54
+ {projects.map(p => (
55
+ <div key={p.id} style={{ background: '#1e293b', borderRadius: 8, padding: '12px 16px' }}>
56
+ <div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
57
+ <span style={{ color: '#e2e8f0', fontWeight: 600 }}>{p.name}</span>
58
+ <span style={{ color: '#475569', fontSize: 11 }}>{p.id}</span>
59
+ {p.base_url && <a href={p.base_url} target="_blank" rel="noreferrer" style={{ color: '#38bdf8', fontSize: 12 }}>{p.base_url}</a>}
60
+ {p.github_repo && <span style={{ color: '#7dd3fc', fontSize: 12 }}>⎋ {p.github_repo}</span>}
61
+ </div>
62
+ </div>
63
+ ))}
64
+ </div>
65
+ </div>
66
+ )
67
+ }
@@ -0,0 +1,67 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, Legend } from 'recharts'
3
+ import { get, type LogSummary, type Health } from '../api'
4
+
5
+ export function Summary() {
6
+ const [summary, setSummary] = useState<LogSummary[]>([])
7
+ const [health, setHealth] = useState<Health | null>(null)
8
+
9
+ useEffect(() => {
10
+ get<LogSummary[]>('/logs/summary?since=' + new Date(Date.now() - 24 * 3600 * 1000).toISOString()).then(setSummary).catch(() => {})
11
+ fetch('/health').then(r => r.json()).then(setHealth).catch(() => {})
12
+ }, [])
13
+
14
+ // Group by service for chart
15
+ const byService = summary.reduce<Record<string, { service: string; error: number; warn: number; fatal: number }>>((acc, s) => {
16
+ const svc = s.service ?? 'unknown'
17
+ if (!acc[svc]) acc[svc] = { service: svc, error: 0, warn: 0, fatal: 0 }
18
+ if (s.level === 'error') acc[svc]!.error += s.count
19
+ if (s.level === 'warn') acc[svc]!.warn += s.count
20
+ if (s.level === 'fatal') acc[svc]!.fatal += s.count
21
+ return acc
22
+ }, {})
23
+ const chartData = Object.values(byService).sort((a, b) => (b.error + b.fatal) - (a.error + a.fatal))
24
+
25
+ return (
26
+ <div>
27
+ <h2 style={{ color: '#38bdf8', marginBottom: 16 }}>Summary (last 24h)</h2>
28
+
29
+ {health && (
30
+ <div style={{ display: 'flex', gap: 16, marginBottom: 24, flexWrap: 'wrap' }}>
31
+ {[
32
+ { label: 'Total Logs', value: health.total_logs.toLocaleString(), color: '#38bdf8' },
33
+ { label: 'Projects', value: health.projects, color: '#7dd3fc' },
34
+ { label: 'Open Issues', value: health.open_issues, color: health.open_issues > 0 ? '#f87171' : '#4ade80' },
35
+ { label: 'Errors', value: (health.logs_by_level['error'] ?? 0) + (health.logs_by_level['fatal'] ?? 0), color: '#f87171' },
36
+ { label: 'Warnings', value: health.logs_by_level['warn'] ?? 0, color: '#fbbf24' },
37
+ { label: 'Uptime', value: `${Math.floor(health.uptime_seconds / 60)}m`, color: '#4ade80' },
38
+ ].map(stat => (
39
+ <div key={stat.label} style={{ background: '#1e293b', borderRadius: 8, padding: '12px 20px', minWidth: 120, textAlign: 'center' }}>
40
+ <div style={{ color: stat.color, fontSize: 24, fontWeight: 700 }}>{stat.value}</div>
41
+ <div style={{ color: '#64748b', fontSize: 12, marginTop: 4 }}>{stat.label}</div>
42
+ </div>
43
+ ))}
44
+ </div>
45
+ )}
46
+
47
+ {chartData.length > 0 ? (
48
+ <div style={{ background: '#1e293b', borderRadius: 8, padding: 20 }}>
49
+ <div style={{ color: '#94a3b8', marginBottom: 12, fontSize: 13 }}>Errors & Warnings by Service</div>
50
+ <ResponsiveContainer width="100%" height={300}>
51
+ <BarChart data={chartData}>
52
+ <XAxis dataKey="service" stroke="#64748b" tick={{ fill: '#94a3b8', fontSize: 12 }} />
53
+ <YAxis stroke="#64748b" tick={{ fill: '#94a3b8', fontSize: 12 }} />
54
+ <Tooltip contentStyle={{ background: '#0f172a', border: '1px solid #334155', color: '#e2e8f0' }} />
55
+ <Legend wrapperStyle={{ color: '#94a3b8' }} />
56
+ <Bar dataKey="fatal" fill="#c084fc" stackId="a" />
57
+ <Bar dataKey="error" fill="#f87171" stackId="a" />
58
+ <Bar dataKey="warn" fill="#fbbf24" stackId="a" />
59
+ </BarChart>
60
+ </ResponsiveContainer>
61
+ </div>
62
+ ) : (
63
+ <div style={{ color: '#64748b', padding: 40, textAlign: 'center', background: '#1e293b', borderRadius: 8 }}>No errors or warnings in the last 24h 🎉</div>
64
+ )}
65
+ </div>
66
+ )
67
+ }
@@ -0,0 +1,65 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import type { LogRow } from '../api'
3
+
4
+ const LEVEL_COLOR: Record<string, string> = {
5
+ debug: '#64748b', info: '#22d3ee', warn: '#fbbf24', error: '#f87171', fatal: '#c084fc'
6
+ }
7
+
8
+ export function Tail() {
9
+ const [logs, setLogs] = useState<LogRow[]>([])
10
+ const [paused, setPaused] = useState(false)
11
+ const [filter, setFilter] = useState('')
12
+ const bottomRef = useRef<HTMLDivElement>(null)
13
+ const esRef = useRef<EventSource | null>(null)
14
+
15
+ useEffect(() => {
16
+ const es = new EventSource('/api/logs/stream')
17
+ esRef.current = es
18
+ es.onmessage = (e) => {
19
+ if (paused) return
20
+ try {
21
+ const log = JSON.parse(e.data) as LogRow
22
+ setLogs(prev => [...prev.slice(-499), log])
23
+ } catch {}
24
+ }
25
+ return () => es.close()
26
+ }, [paused])
27
+
28
+ useEffect(() => {
29
+ if (!paused) bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
30
+ }, [logs, paused])
31
+
32
+ const filtered = filter
33
+ ? logs.filter(l => l.message.toLowerCase().includes(filter.toLowerCase()) || (l.service ?? '').includes(filter))
34
+ : logs
35
+
36
+ return (
37
+ <div>
38
+ <div style={{ display: 'flex', gap: 12, marginBottom: 12, alignItems: 'center' }}>
39
+ <h2 style={{ margin: 0, color: '#38bdf8' }}>Live Tail</h2>
40
+ <input
41
+ placeholder="Filter..."
42
+ value={filter}
43
+ onChange={e => setFilter(e.target.value)}
44
+ style={{ background: '#1e293b', border: '1px solid #334155', color: '#e2e8f0', padding: '4px 8px', borderRadius: 4, fontFamily: 'monospace' }}
45
+ />
46
+ <button onClick={() => setPaused(p => !p)} style={{ background: paused ? '#22d3ee' : '#334155', color: paused ? '#0f172a' : '#e2e8f0', border: 'none', padding: '4px 12px', borderRadius: 4, cursor: 'pointer' }}>
47
+ {paused ? '▶ Resume' : '⏸ Pause'}
48
+ </button>
49
+ <button onClick={() => setLogs([])} style={{ background: '#334155', color: '#e2e8f0', border: 'none', padding: '4px 12px', borderRadius: 4, cursor: 'pointer' }}>Clear</button>
50
+ <span style={{ color: '#64748b', fontSize: 12 }}>{filtered.length} logs</span>
51
+ </div>
52
+ <div style={{ background: '#020617', borderRadius: 8, padding: 12, height: 'calc(100vh - 160px)', overflowY: 'auto', fontSize: 13 }}>
53
+ {filtered.map(log => (
54
+ <div key={log.id} style={{ display: 'flex', gap: 12, marginBottom: 2, lineHeight: 1.5 }}>
55
+ <span style={{ color: '#475569', minWidth: 200 }}>{log.timestamp.slice(0, 19).replace('T', ' ')}</span>
56
+ <span style={{ color: LEVEL_COLOR[log.level] ?? '#e2e8f0', minWidth: 50, fontWeight: 700 }}>{log.level.toUpperCase()}</span>
57
+ <span style={{ color: '#7dd3fc', minWidth: 100 }}>{log.service ?? '-'}</span>
58
+ <span style={{ color: '#e2e8f0', wordBreak: 'break-all' }}>{log.message}</span>
59
+ </div>
60
+ ))}
61
+ <div ref={bottomRef} />
62
+ </div>
63
+ </div>
64
+ )
65
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4
+ "target": "ES2023",
5
+ "useDefineForClassFields": true,
6
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "types": ["vite/client"],
9
+ "skipLibCheck": true,
10
+
11
+ /* Bundler mode */
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "moduleDetection": "force",
16
+ "noEmit": true,
17
+ "jsx": "react-jsx",
18
+
19
+ /* Linting */
20
+ "strict": true,
21
+ "noUnusedLocals": true,
22
+ "noUnusedParameters": true,
23
+ "erasableSyntaxOnly": true,
24
+ "noFallthroughCasesInSwitch": true,
25
+ "noUncheckedSideEffectImports": true
26
+ },
27
+ "include": ["src"]
28
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
+ "target": "ES2023",
5
+ "lib": ["ES2023"],
6
+ "module": "ESNext",
7
+ "types": ["node"],
8
+ "skipLibCheck": true,
9
+
10
+ /* Bundler mode */
11
+ "moduleResolution": "bundler",
12
+ "allowImportingTsExtensions": true,
13
+ "verbatimModuleSyntax": true,
14
+ "moduleDetection": "force",
15
+ "noEmit": true,
16
+
17
+ /* Linting */
18
+ "strict": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "erasableSyntaxOnly": true,
22
+ "noFallthroughCasesInSwitch": true,
23
+ "noUncheckedSideEffectImports": true
24
+ },
25
+ "include": ["vite.config.ts"]
26
+ }
@@ -0,0 +1,14 @@
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ base: '/dashboard/',
7
+ build: { outDir: 'dist' },
8
+ server: {
9
+ proxy: {
10
+ '/api': 'http://localhost:3460',
11
+ '/health': 'http://localhost:3460',
12
+ }
13
+ }
14
+ })
package/dist/cli/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // @bun
3
3
  import {
4
4
  runJob
5
- } from "../index-dh02dp7n.js";
5
+ } from "../index-2p6ynjet.js";
6
6
  import {
7
7
  createPage,
8
8
  createProject,
@@ -10,17 +10,21 @@ import {
10
10
  ingestLog,
11
11
  listPages,
12
12
  listProjects,
13
+ summarizeLogs
14
+ } from "../index-77ss2sf4.js";
15
+ import {
16
+ createJob,
17
+ listJobs
18
+ } from "../jobs-124e878j.js";
19
+ import {
13
20
  searchLogs,
14
- summarizeLogs,
15
21
  tailLogs
16
- } from "../index-zj6ymcv7.js";
22
+ } from "../query-0qv7fvzt.js";
17
23
  import {
18
24
  __commonJS,
19
25
  __require,
20
- __toESM,
21
- createJob,
22
- listJobs
23
- } from "../index-4mnved04.js";
26
+ __toESM
27
+ } from "../index-g8dczzvv.js";
24
28
 
25
29
  // node_modules/commander/lib/error.js
26
30
  var require_error = __commonJS((exports) => {
@@ -2231,7 +2235,7 @@ program2.command("scan").description("Run an immediate scan for a job").option("
2231
2235
  process.exit(1);
2232
2236
  }
2233
2237
  const db = getDb();
2234
- const job = (await import("../jobs-zv49jh0y.js")).getJob(db, opts.job);
2238
+ const job = (await import("../jobs-124e878j.js")).getJob(db, opts.job);
2235
2239
  if (!job) {
2236
2240
  console.error("Job not found");
2237
2241
  process.exit(1);
@@ -2240,13 +2244,113 @@ program2.command("scan").description("Run an immediate scan for a job").option("
2240
2244
  await runJob(db, job.id, job.project_id, job.page_id ?? undefined);
2241
2245
  console.log("Scan complete.");
2242
2246
  });
2247
+ program2.command("watch").description("Stream new logs in real time with color coding").option("--project <id>").option("--level <levels>", "Comma-separated levels").option("--service <name>").action(async (opts) => {
2248
+ const db = getDb();
2249
+ const { searchLogs: searchLogs2 } = await import("../query-0qv7fvzt.js");
2250
+ const COLORS = {
2251
+ debug: "\x1B[90m",
2252
+ info: "\x1B[36m",
2253
+ warn: "\x1B[33m",
2254
+ error: "\x1B[31m",
2255
+ fatal: "\x1B[35m"
2256
+ };
2257
+ const RESET = "\x1B[0m";
2258
+ const BOLD = "\x1B[1m";
2259
+ let lastTimestamp = new Date().toISOString();
2260
+ let errorCount = 0;
2261
+ let warnCount = 0;
2262
+ process.stdout.write(`\x1B[2J\x1B[H`);
2263
+ console.log(`${BOLD}@hasna/logs watch${RESET} \u2014 Ctrl+C to exit
2264
+ `);
2265
+ const poll = () => {
2266
+ const rows = searchLogs2(db, {
2267
+ project_id: opts.project,
2268
+ level: opts.level ? opts.level.split(",") : undefined,
2269
+ service: opts.service,
2270
+ since: lastTimestamp,
2271
+ limit: 100
2272
+ }).reverse();
2273
+ for (const row of rows) {
2274
+ if (row.timestamp <= lastTimestamp)
2275
+ continue;
2276
+ lastTimestamp = row.timestamp;
2277
+ if (row.level === "error" || row.level === "fatal")
2278
+ errorCount++;
2279
+ if (row.level === "warn")
2280
+ warnCount++;
2281
+ const color = COLORS[row.level] ?? "";
2282
+ const ts = row.timestamp.slice(11, 19);
2283
+ const svc = (row.service ?? "-").padEnd(12);
2284
+ const lvl = row.level.toUpperCase().padEnd(5);
2285
+ console.log(`${color}${ts} ${BOLD}${lvl}${RESET}${color} ${svc} ${row.message}${RESET}`);
2286
+ }
2287
+ process.stdout.write(`\x1B]2;logs: ${errorCount}E ${warnCount}W\x07`);
2288
+ };
2289
+ const interval = setInterval(poll, 500);
2290
+ process.on("SIGINT", () => {
2291
+ clearInterval(interval);
2292
+ console.log(`
2293
+
2294
+ Errors: ${errorCount} Warnings: ${warnCount}`);
2295
+ process.exit(0);
2296
+ });
2297
+ });
2298
+ program2.command("export").description("Export logs to JSON or CSV").option("--project <id>").option("--since <time>", "Relative time or ISO").option("--level <level>").option("--service <name>").option("--format <fmt>", "json or csv", "json").option("--output <file>", "Output file (default: stdout)").option("--limit <n>", "Max rows", "100000").action(async (opts) => {
2299
+ const { exportToCsv, exportToJson } = await import("../export-yjaw2sr3.js");
2300
+ const { createWriteStream } = await import("fs");
2301
+ const db = getDb();
2302
+ const options = {
2303
+ project_id: opts.project,
2304
+ since: parseRelativeTime(opts.since),
2305
+ level: opts.level,
2306
+ service: opts.service,
2307
+ limit: Number(opts.limit)
2308
+ };
2309
+ let count = 0;
2310
+ if (opts.output) {
2311
+ const stream = createWriteStream(opts.output);
2312
+ const write = (s) => stream.write(s);
2313
+ count = opts.format === "csv" ? exportToCsv(db, options, write) : exportToJson(db, options, write);
2314
+ stream.end();
2315
+ console.error(`Exported ${count} log(s) to ${opts.output}`);
2316
+ } else {
2317
+ const write = (s) => process.stdout.write(s);
2318
+ count = opts.format === "csv" ? exportToCsv(db, options, write) : exportToJson(db, options, write);
2319
+ process.stderr.write(`
2320
+ Exported ${count} log(s)
2321
+ `);
2322
+ }
2323
+ });
2324
+ program2.command("health").description("Show server health and DB stats").action(async () => {
2325
+ const { getHealth } = await import("../health-f2qrebqc.js");
2326
+ const h = getHealth(getDb());
2327
+ console.log(JSON.stringify(h, null, 2));
2328
+ });
2243
2329
  program2.command("mcp").description("Start the MCP server").option("--claude", "Install into Claude Code").option("--codex", "Install into Codex").option("--gemini", "Install into Gemini").action(async (opts) => {
2244
2330
  if (opts.claude || opts.codex || opts.gemini) {
2245
- const bin = process.execPath;
2246
- const script = new URL(import.meta.url).pathname;
2331
+ const { execSync } = await import("child_process");
2332
+ const selfPath = process.argv[1] ?? new URL(import.meta.url).pathname;
2333
+ const mcpBin = selfPath.replace(/cli\/index\.(ts|js)$/, "mcp/index.$1");
2334
+ const runtime = process.execPath;
2247
2335
  if (opts.claude) {
2248
- const { execSync } = await import("child_process");
2249
- execSync(`claude mcp add --transport stdio --scope user logs -- ${bin} ${script} mcp`, { stdio: "inherit" });
2336
+ const cmd = `claude mcp add --transport stdio --scope user logs -- ${runtime} ${mcpBin}`;
2337
+ console.log(`Running: ${cmd}`);
2338
+ execSync(cmd, { stdio: "inherit" });
2339
+ console.log("\u2713 Installed logs-mcp into Claude Code");
2340
+ }
2341
+ if (opts.codex) {
2342
+ const config = `[mcp_servers.logs]
2343
+ command = "${runtime}"
2344
+ args = ["${mcpBin}"]`;
2345
+ console.log(`Add to ~/.codex/config.toml:
2346
+
2347
+ ` + config);
2348
+ }
2349
+ if (opts.gemini) {
2350
+ const config = JSON.stringify({ mcpServers: { logs: { command: runtime, args: [mcpBin] } } }, null, 2);
2351
+ console.log(`Add to ~/.gemini/settings.json mcpServers:
2352
+
2353
+ ` + config);
2250
2354
  }
2251
2355
  return;
2252
2356
  }