@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.
- package/dashboard/README.md +73 -0
- package/dashboard/bun.lock +526 -0
- package/dashboard/eslint.config.js +23 -0
- package/dashboard/index.html +13 -0
- package/dashboard/package.json +32 -0
- package/dashboard/public/favicon.svg +1 -0
- package/dashboard/public/icons.svg +24 -0
- package/dashboard/src/App.css +184 -0
- package/dashboard/src/App.tsx +49 -0
- package/dashboard/src/api.ts +33 -0
- package/dashboard/src/assets/hero.png +0 -0
- package/dashboard/src/assets/react.svg +1 -0
- package/dashboard/src/assets/vite.svg +1 -0
- package/dashboard/src/index.css +111 -0
- package/dashboard/src/main.tsx +10 -0
- package/dashboard/src/pages/Alerts.tsx +69 -0
- package/dashboard/src/pages/Issues.tsx +50 -0
- package/dashboard/src/pages/Perf.tsx +75 -0
- package/dashboard/src/pages/Projects.tsx +67 -0
- package/dashboard/src/pages/Summary.tsx +67 -0
- package/dashboard/src/pages/Tail.tsx +65 -0
- package/dashboard/tsconfig.app.json +28 -0
- package/dashboard/tsconfig.json +7 -0
- package/dashboard/tsconfig.node.json +26 -0
- package/dashboard/vite.config.ts +14 -0
- package/dist/cli/index.js +116 -12
- package/dist/mcp/index.js +306 -100
- package/dist/server/index.js +592 -7
- package/package.json +12 -2
- package/sdk/package.json +3 -2
- package/sdk/src/index.ts +1 -1
- package/sdk/src/types.ts +56 -0
- package/src/cli/index.ts +114 -4
- 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/lib/session-context.ts +28 -0
- package/src/mcp/index.ts +133 -89
- package/src/server/index.ts +12 -1
- 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,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,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-
|
|
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 "../
|
|
22
|
+
} from "../query-0qv7fvzt.js";
|
|
17
23
|
import {
|
|
18
24
|
__commonJS,
|
|
19
25
|
__require,
|
|
20
|
-
__toESM
|
|
21
|
-
|
|
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-
|
|
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
|
|
2246
|
-
const
|
|
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
|
|
2249
|
-
|
|
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
|
}
|