page.tsx218 lines · main
1import Link from 'next/link';
2
3import { apiJson } from '../../../../../../lib/api';
4import { ExportLogsLink } from './export-link';
5import { LiveTailToggle } from './live-tail-toggle';
6
7interface FunctionLog {
8 id: string;
9 invocationId: string;
10 functionName: string;
11 status: 'ok' | 'err';
12 durationMs: string;
13 touchedTables: unknown;
14 userLogsJson: unknown;
15 errCode: string | null;
16 errMessage: string | null;
17 createdAt: string;
18}
19
20export const metadata = { title: 'logs' };
21export const dynamic = 'force-dynamic';
22
23const PAGE_SIZE = 50;
24
25export default async function LogsPage({
26 params,
27 searchParams,
28}: {
29 params: Promise<{ id: string }>;
30 searchParams: Promise<{ function?: string; status?: string; before?: string }>;
31}) {
32 const { id } = await params;
33 const sp = await searchParams;
34
35 const qs = new URLSearchParams({ limit: String(PAGE_SIZE) });
36 if (sp.function) qs.set('function', sp.function);
37 if (sp.status === 'ok' || sp.status === 'err') qs.set('status', sp.status);
38 if (sp.before) qs.set('before', sp.before);
39
40 const [{ logs }, { names }] = await Promise.all([
41 apiJson<{ logs: FunctionLog[] }>(`/v1/projects/${id}/function-logs?${qs.toString()}`),
42 apiJson<{ names: string[] }>(`/v1/projects/${id}/function-names`).catch(() => ({
43 names: [] as string[],
44 })),
45 ]);
46
47 function urlWith(overrides: Record<string, string | null>): string {
48 const next = new URLSearchParams();
49 if (sp.function) next.set('function', sp.function);
50 if (sp.status) next.set('status', sp.status);
51 if (sp.before) next.set('before', sp.before);
52 for (const [k, v] of Object.entries(overrides)) {
53 if (v === null) next.delete(k);
54 else next.set(k, v);
55 }
56 const q = next.toString();
57 return `/dashboard/projects/${id}/logs${q ? `?${q}` : ''}`;
58 }
59
60 const lastTs = logs.length > 0 ? logs[logs.length - 1]?.createdAt : null;
61
62 return (
63 <section className="flex flex-col gap-4">
64 <header className="flex flex-col items-start gap-3 sm:flex-row sm:justify-between sm:gap-4">
65 <div>
66 <h2 className="font-mono text-sm text-[var(--color-text)]">function logs</h2>
67 <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">
68 every invocation — name, duration, touched tables, user logs, errors. retention
69 depends on your tier (free: 7 days). populated by the runtime log-fanout worker.
70 </p>
71 </div>
72 <div className="flex shrink-0 items-center gap-3">
73 <LiveTailToggle />
74 <ExportLogsLink projectId={id} query={qs.toString()} />
75 </div>
76 </header>
77
78 <nav className="flex flex-wrap items-center gap-2 font-mono text-[10px]">
79 <Link
80 href={urlWith({ function: null, before: null })}
81 className={`rounded-md border px-2 py-0.5 ${
82 !sp.function
83 ? 'border-[var(--color-primary)] text-[var(--color-primary)]'
84 : 'border-[var(--color-border)] text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
85 }`}
86 >
87 all functions
88 </Link>
89 {names.map((n) => (
90 <Link
91 key={n}
92 href={urlWith({ function: n, before: null })}
93 className={`rounded-md border px-2 py-0.5 ${
94 sp.function === n
95 ? 'border-[var(--color-primary)] text-[var(--color-primary)]'
96 : 'border-[var(--color-border)] text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
97 }`}
98 >
99 {n}
100 </Link>
101 ))}
102 <span className="mx-2 text-[var(--color-text-subtle)]">·</span>
103 <Link
104 href={urlWith({ status: null, before: null })}
105 className={`rounded-md border px-2 py-0.5 ${
106 !sp.status
107 ? 'border-[var(--color-primary)] text-[var(--color-primary)]'
108 : 'border-[var(--color-border)] text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
109 }`}
110 >
111 any status
112 </Link>
113 <Link
114 href={urlWith({ status: 'ok', before: null })}
115 className={`rounded-md border px-2 py-0.5 ${
116 sp.status === 'ok'
117 ? 'border-[var(--color-primary)] text-[var(--color-primary)]'
118 : 'border-[var(--color-border)] text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
119 }`}
120 >
121 ok
122 </Link>
123 <Link
124 href={urlWith({ status: 'err', before: null })}
125 className={`rounded-md border px-2 py-0.5 ${
126 sp.status === 'err'
127 ? 'border-red-400/60 text-red-400'
128 : 'border-[var(--color-border)] text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
129 }`}
130 >
131 err
132 </Link>
133 </nav>
134
135 {logs.length === 0 ? (
136 <p className="rounded-md border border-dashed border-[var(--color-border)] p-10 text-center font-mono text-sm text-[var(--color-text-muted)]">
137 {sp.function || sp.status
138 ? 'no logs matching this filter.'
139 : 'no invocations yet — deploy a function and call it from the cli or sdk.'}
140 </p>
141 ) : (
142 <ul className="flex flex-col gap-2">
143 {logs.map((log) => (
144 <li
145 key={log.id}
146 className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-3 font-mono text-xs"
147 >
148 <div className="flex items-start justify-between gap-3">
149 <div className="min-w-0 flex-1">
150 <p className="flex items-center gap-2">
151 <span
152 className={`rounded px-1.5 py-0.5 text-[10px] ${
153 log.status === 'ok'
154 ? 'bg-[var(--color-primary-subtle)] text-[var(--color-primary)]'
155 : 'bg-red-400/15 text-red-400'
156 }`}
157 >
158 {log.status}
159 </span>
160 <span className="font-medium text-[var(--color-text)]">
161 {log.functionName}
162 </span>
163 <span className="text-[var(--color-text-subtle)]">
164 · {log.durationMs}ms
165 </span>
166 </p>
167 {log.errMessage ? (
168 <p className="mt-1 text-red-400">
169 {log.errCode ? `[${log.errCode}] ` : ''}
170 {log.errMessage}
171 </p>
172 ) : null}
173 {Array.isArray(log.userLogsJson) && log.userLogsJson.length > 0 ? (
174 <pre className="mt-1 overflow-x-auto whitespace-pre-wrap rounded-sm bg-[var(--color-bg)] p-2 text-[10px] text-[var(--color-text-muted)]">
175 {(log.userLogsJson as unknown[])
176 .map((entry) =>
177 typeof entry === 'string' ? entry : JSON.stringify(entry),
178 )
179 .join('\n')}
180 </pre>
181 ) : null}
182 {Array.isArray(log.touchedTables) && log.touchedTables.length > 0 ? (
183 <p className="mt-1 text-[10px] text-[var(--color-text-subtle)]">
184 touched:{' '}
185 {(log.touchedTables as string[]).map((t, i) => (
186 <span key={i}>
187 {i > 0 ? ', ' : ''}
188 <Link
189 href={`/dashboard/projects/${id}/studio/${encodeURIComponent(t)}`}
190 className="hover:text-[var(--color-text)]"
191 >
192 {t}
193 </Link>
194 </span>
195 ))}
196 </p>
197 ) : null}
198 </div>
199 <time className="shrink-0 text-[10px] text-[var(--color-text-subtle)]">
200 {new Date(log.createdAt).toISOString().replace('T', ' ').slice(0, 19)}
201 </time>
202 </div>
203 </li>
204 ))}
205 </ul>
206 )}
207
208 {logs.length === PAGE_SIZE && lastTs ? (
209 <Link
210 href={urlWith({ before: lastTs })}
211 className="self-end rounded-md border border-[var(--color-border)] px-3 py-1 font-mono text-[10px] text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
212 >
213 older →
214 </Link>
215 ) : null}
216 </section>
217 );
218}