Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

157 linesCodeBlameRaw
1import { Pause, Play } from "lucide-react";
2import { useEffect, useMemo, useState } from "react";
3import { Link } from "react-router";
4
5import { LOG_SEVERITIES, type LogSeverity, type TelemetryLog } from "@g1t/contracts/telemetry";
6
7import type { Route } from "./+types/observability-logs";
8import { FilterBar, LiveDot, ObservabilityHeader, useLoadingView } from "../../components/observability";
9import { LogLines } from "../../components/observability-logs";
10import { ButtonLink, EmptyState } from "../../components/ui";
11import { Button } from "../../components/ui/button";
12import { Card } from "../../components/ui/card";
13import { cn } from "../../lib/cn";
14import { page } from "../../lib/meta";
15import { matchesLog, viewHref } from "../../lib/observability";
16import { useTelemetryLive } from "../../lib/observability-live";
17import { observed, soft } from "../../lib/observability.server";
18
19/** The levels offered as toggles: trace and debug go with debug, fatal with error. */
20const LEVELS: LogSeverity[] = ["debug", "info", "warn", "error"];
21const WITH: Partial<Record<LogSeverity, LogSeverity[]>> = { debug: ["trace", "debug"], error: ["error", "fatal"] };
22/** A live page keeps this many lines; older ones are a page away. */
23const KEEP = 1000;
24
25export function meta({ params, ...args }: Route.MetaArgs) {
26 return page(args, { title: `Logs · ${params.owner}/${params.repo} · g1t` });
27}
28
29export async function loader({ params, context, request }: Route.LoaderArgs) {
30 const { projectId, service, view, filter } = await observed(context, params, request);
31 const search = new URL(request.url).searchParams;
32 const levels = search.getAll("level").filter((l): l is LogSeverity => (LOG_SEVERITIES as readonly string[]).includes(l));
33 const cursor = search.get("cursor");
34 const [list, facets] = await Promise.all([
35 soft(
36 service.logs({
37 project_id: projectId,
38 ...filter,
39 text: view.q || null,
40 severities: levels.flatMap((l) => WITH[l] ?? [l]),
41 cursor,
42 limit: 300,
43 }),
44 "logs",
45 ),
46 soft(service.facets({ project_id: projectId }), "facets"),
47 ]);
48 return { view, list, facets, levels, cursor };
49}
50
51export default function Logs({ loaderData, params }: Route.ComponentProps) {
52 const { view, list, facets, levels, cursor } = loaderData;
53 const base = `/${params.owner}/${params.repo}`;
54 const path = `${base}/observability/logs`;
55 const loading = useLoadingView();
56 const [live, setLive] = useState(false);
57 const [arrived, setArrived] = useState<TelemetryLog[]>([]);
58 const [dropped, setDropped] = useState(0);
59 // A new view starts the page over.
60 useEffect(() => {
61 setArrived([]);
62 setDropped(0);
63 }, [list]);
64 const wanted = levels.flatMap((l) => WITH[l] ?? [l]);
65 const state = useTelemetryLive(`${base}/observability/live`, live && !cursor, (items, lost) => {
66 const fresh = items.flatMap((i) => (i.type === "log" && matchesLog(i.log, view, wanted) ? [i.log] : []));
67 if (fresh.length) setArrived((before) => [...fresh.sort((a, b) => b.time_us - a.time_us), ...before].slice(0, KEEP));
68 if (lost) setDropped((n) => n + lost);
69 });
70 const shown = useMemo(() => {
71 const seen = new Set<string>();
72 return [...arrived, ...(list?.items ?? [])].filter((l) => (seen.has(l.id) ? false : (seen.add(l.id), true))).slice(0, KEEP);
73 }, [arrived, list]);
74 const fresh = useMemo(() => new Set(arrived.map((l) => l.id)), [arrived]);
75
76 const levelHref = (level: LogSeverity) => {
77 const next = levels.includes(level) ? levels.filter((l) => l !== level) : [...levels, level];
78 const url = viewHref(path, view);
79 return `${url}${next.length ? `${url.includes("?") ? "&" : "?"}${next.map((l) => `level=${l}`).join("&")}` : ""}`;
80 };
81 const pageHref = (c: string | null) => {
82 const url = viewHref(path, view, { cursor: c });
83 return `${url}${levels.length ? `${url.includes("?") ? "&" : "?"}${levels.map((l) => `level=${l}`).join("&")}` : ""}`;
84 };
85
86 return (
87 <div className="mx-auto max-w-6xl">
88 <ObservabilityHeader title="Logs" about="Every line the project's software wrote, newest first. Search words, a level, or any attribute; follow it live.">
89 {!cursor && (
90 <div className="flex items-center gap-3">
91 <LiveDot on={state === "live"} />
92 <Button variant={live ? "secondary" : "outline"} size="sm" onClick={() => setLive(!live)} aria-pressed={live}>
93 {live ? <Pause size={14} /> : <Play size={14} />}
94 {live ? "Pause" : "Follow live"}
95 </Button>
96 </div>
97 )}
98 </ObservabilityHeader>
99 <FilterBar
100 path={path}
101 view={view}
102 environments={facets?.environments ?? []}
103 services={facets?.services ?? []}
104 search={{ placeholder: 'Search: timeout level:error user.id=42 "exact phrase"', hint: "Words to find, level:error, or attribute=value" }}
105 >
106 <nav className="flex rounded-md border border-line p-0.5 text-sm" aria-label="Levels">
107 {LEVELS.map((level) => (
108 <Link
109 key={level}
110 to={levelHref(level)}
111 preventScrollReset
112 aria-pressed={levels.includes(level)}
113 className={cn(
114 "rounded px-2 py-0.5 text-xs capitalize",
115 levels.includes(level) ? "bg-raised text-fg" : "text-muted hover:text-fg",
116 )}
117 >
118 {level}
119 </Link>
120 ))}
121 </nav>
122 </FilterBar>
123 {state === "dropped" && <p className="mt-3 text-xs text-warn">The live connection dropped; reconnecting.</p>}
124 {dropped > 0 && <p className="mt-3 text-xs text-faint">{dropped.toLocaleString("en-US")} lines arrived faster than the page shows them; search to find them.</p>}
125
126 <div className={cn("mt-4 transition-opacity", loading && "opacity-60")}>
127 {!list ? (
128 <Card className="p-6 text-sm text-muted">Logs could not be loaded just now. Reload the page to try again.</Card>
129 ) : shown.length === 0 ? (
130 <EmptyState title={view.q || levels.length ? "No lines match" : "No logs in this period"}>
131 {live ? "Following: new lines show here as they are written." : "Apps on g1t.page send what they write with console.log; anything else sends OpenTelemetry logs."}
132 </EmptyState>
133 ) : (
134 <Card className="overflow-hidden">
135 <LogLines logs={shown} base={base} fresh={fresh} />
136 </Card>
137 )}
138 {(list?.next || cursor) && (
139 <div className="mt-4 flex justify-between">
140 {cursor ? (
141 <ButtonLink to={pageHref(null)} variant="outline" size="sm">
142 Newest
143 </ButtonLink>
144 ) : (
145 <span />
146 )}
147 {list?.next && (
148 <ButtonLink to={pageHref(list.next)} variant="outline" size="sm">
149 Older
150 </ButtonLink>
151 )}
152 </div>
153 )}
154 </div>
155 </div>
156 );
157}