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.

200 linesCodeBlameRaw
1import { CircleDot, ExternalLink } from "lucide-react";
2import { Form, Link, redirect } from "react-router";
3
4import type { ErrorGroup } from "@g1t/contracts";
5
6import type { Route } from "./+types/observability-errors";
7import { FilterBar, ObservabilityHeader, useLoadingView } from "../../components/observability";
8import { EmptyState, ErrorText, SubmitButton, TimeAgo } from "../../components/ui";
9import { Badge } from "../../components/ui/badge";
10import { Card } from "../../components/ui/card";
11import { NavTab, NavTabs } from "../../components/ui/nav-tabs";
12import { refusal } from "../../lib/access.server";
13import { cn } from "../../lib/cn";
14import { page } from "../../lib/meta";
15import { count, errorIssue, viewHref } from "../../lib/observability";
16import { observed, soft } from "../../lib/observability.server";
17import { work } from "../../lib/services.server";
18import { assertSameOrigin, requireUser } from "../../lib/session.server";
19
20const STATES: ErrorGroup["state"][] = ["open", "resolved", "ignored"];
21
22export function meta({ params, ...args }: Route.MetaArgs) {
23 return page(args, { title: `Errors · ${params.owner}/${params.repo} · g1t` });
24}
25
26export async function loader({ params, context, request }: Route.LoaderArgs) {
27 const { projectId, service, view, filter, access } = await observed(context, params, request);
28 const stateParam = new URL(request.url).searchParams.get("state");
29 const state = STATES.find((s) => s === stateParam) ?? "open";
30 const [groups, facets] = await Promise.all([
31 soft(service.errors({ project_id: projectId, ...filter, deployment_id: null, state }), "errors"),
32 soft(service.facets({ project_id: projectId }), "facets"),
33 ]);
34 return { view, groups, facets, state, can: { triage: access.can.triage, issue: access.can.participate } };
35}
36
37export async function action({ request, params, context }: Route.ActionArgs) {
38 assertSameOrigin(request);
39 const user = requireUser(context, request);
40 const { projectId, service, repo } = await observed(context, params, request);
41 const form = await request.formData();
42 const intent = String(form.get("intent") ?? "");
43 const fingerprint = String(form.get("fingerprint") ?? "");
44 if (intent === "resolved" || intent === "ignored" || intent === "open") {
45 const refused = await refusal(context, params, "triage");
46 if (refused) return { error: refused, fingerprint };
47 await service.setError({ project_id: projectId, fingerprint, state: intent });
48 return { notice: intent === "open" ? "Open again." : intent === "resolved" ? "Resolved. It opens again if it happens again." : "Ignored." };
49 }
50 if (intent === "issue") {
51 const refused = await refusal(context, params, "participate");
52 if (refused) return { error: refused, fingerprint };
53 const group = (await service.errors({ project_id: projectId })).find((g) => g.fingerprint === fingerprint);
54 if (!group) return { error: "That error is no longer kept.", fingerprint };
55 const url = new URL(request.url);
56 const { title, body } = errorIssue(group, `${url.origin}/${params.owner}/${params.repo}/observability`);
57 const path = { namespace: repo.namespace, name: repo.name };
58 const opened = await work.openIssue(user, path, { title, body, labels: ["bug"] });
59 if (!opened.ok) return { error: opened.error.message, fingerprint };
60 await service.setError({ project_id: projectId, fingerprint, issue_number: opened.value.number });
61 throw redirect(`/${path.namespace}/${path.name}/issues/${opened.value.number}`);
62 }
63 return { error: "Unknown request.", fingerprint };
64}
65
66export default function Errors({ loaderData, actionData, params }: Route.ComponentProps) {
67 const { view, groups, facets, state, can } = loaderData;
68 const base = `/${params.owner}/${params.repo}`;
69 const path = `${base}/observability/errors`;
70 const loading = useLoadingView();
71 const failedOn = actionData && "fingerprint" in actionData ? actionData.fingerprint : null;
72
73 return (
74 <div className="mx-auto max-w-6xl">
75 <ObservabilityHeader
76 title="Errors"
77 about="Exceptions, grouped: the same error thrown a thousand times is one row. Each can become an issue, carrying the stack and the request it came from."
78 />
79 <FilterBar path={path} view={view} environments={facets?.environments ?? []} services={facets?.services ?? []}>
80 <NavTabs label="State" variant="pill">
81 {STATES.map((s) => (
82 <NavTab
83 key={s}
84 to={viewHref(path, view, { state: s === "open" ? null : s })}
85 preventScrollReset
86 current={s === state}
87 variant="pill"
88 className="capitalize"
89 >
90 {s}
91 </NavTab>
92 ))}
93 </NavTabs>
94 </FilterBar>
95 <div className="mt-3 min-h-5">
96 {actionData && "notice" in actionData && <p className="text-sm text-success">{actionData.notice}</p>}
97 </div>
98
99 <div className={cn("mt-2 transition-opacity", loading && "opacity-60")}>
100 {!groups ? (
101 <Card className="p-6 text-sm text-muted">Errors could not be loaded just now. Reload the page to try again.</Card>
102 ) : groups.length === 0 ? (
103 <EmptyState title={state === "open" ? "No open errors in this period" : `No ${state} errors in this period`}>
104 Exceptions thrown by apps on g1t.page show here on their own; others arrive as OpenTelemetry exception events and log records.
105 </EmptyState>
106 ) : (
107 <ul className="space-y-3">
108 {groups.map((g) => (
109 <li key={g.fingerprint} id={g.fingerprint}>
110 <Card className="min-w-0 p-4">
111 <div className="flex flex-wrap items-start justify-between gap-3">
112 <div className="min-w-0 flex-1">
113 <p className="flex flex-wrap items-center gap-2">
114 <span className="font-mono text-sm text-danger">{g.name}</span>
115 <Badge tone="neutral" className="font-mono">
116 {g.service || "app"}
117 </Badge>
118 {g.environment && <Badge tone="neutral">{g.environment}</Badge>}
119 {g.issue_number && (
120 <Link to={`${base}/issues/${g.issue_number}`} className="inline-flex items-center gap-1 text-xs text-accent hover:underline">
121 <CircleDot size={12} />#{g.issue_number}
122 </Link>
123 )}
124 </p>
125 <p className="mt-1 text-sm wrap-break-word">{g.message}</p>
126 <p className="mt-1.5 text-xs text-faint tabular-nums">
127 <span className="text-fg">{count(g.count)}</span> time{g.count === 1 ? "" : "s"} · first <TimeAgo at={g.first_us / 1000} /> · last{" "}
128 <TimeAgo at={g.last_us / 1000} />
129 {g.first_deployment_id && (
130 <>
131 {" "}
132 · since{" "}
133 <Link to={`${base}/deployments/${g.first_deployment_id}`} className="font-mono hover:text-fg">
134 {g.first_deployment_id.slice(0, 12)}
135 </Link>
136 </>
137 )}
138 </p>
139 </div>
140 <div className="flex shrink-0 flex-wrap gap-2">
141 {g.last_request_id && (
142 <Link to={`${base}/observability/requests/${g.last_request_id}`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
143 Latest request <ExternalLink size={11} />
144 </Link>
145 )}
146 {g.last_trace_id && (
147 <Link to={`${base}/observability/traces/${g.last_trace_id}`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
148 Trace <ExternalLink size={11} />
149 </Link>
150 )}
151 </div>
152 </div>
153 {g.stack && (
154 <details className="mt-3">
155 <summary className="cursor-pointer text-xs text-muted hover:text-fg">Stack</summary>
156 <pre className="mt-2 max-h-72 overflow-auto rounded-md border border-line bg-bg p-3 font-mono text-xs whitespace-pre">{g.stack}</pre>
157 </details>
158 )}
159 {(can.triage || (can.issue && !g.issue_number)) && (
160 <div className="mt-3 flex flex-wrap items-center gap-2 border-t border-line pt-3">
161 {can.issue && !g.issue_number && (
162 <Form method="post" className="flex flex-wrap items-center gap-2">
163 <input type="hidden" name="fingerprint" value={g.fingerprint} />
164 <SubmitButton name="intent" value="issue" size="sm" variant="outline">
165 Make an issue
166 </SubmitButton>
167 </Form>
168 )}
169 {can.triage && (
170 <Form method="post" className="ml-auto flex gap-2">
171 <input type="hidden" name="fingerprint" value={g.fingerprint} />
172 {g.state !== "resolved" && (
173 <SubmitButton name="intent" value="resolved" size="sm" variant="ghost">
174 Resolve
175 </SubmitButton>
176 )}
177 {g.state !== "ignored" && (
178 <SubmitButton name="intent" value="ignored" size="sm" variant="ghost">
179 Ignore
180 </SubmitButton>
181 )}
182 {g.state !== "open" && (
183 <SubmitButton name="intent" value="open" size="sm" variant="ghost">
184 Open again
185 </SubmitButton>
186 )}
187 </Form>
188 )}
189 </div>
190 )}
191 {failedOn === g.fingerprint && <ErrorText>{actionData && "error" in actionData ? actionData.error : null}</ErrorText>}
192 </Card>
193 </li>
194 ))}
195 </ul>
196 )}
197 </div>
198 </div>
199 );
200}