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.

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