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.

Commit

A workspace's pages fail one part at a time, and never say something false because a read failed. The frame: a visitor's sidebar projects do without instead of taking every page down. Settings: data residency (a failure could save "anywhere" over "eu"), two-factor and Delete (what deleting would take) show Unavailable instead of their forms or button. Billing: spend limit, allowance and alerts, credit, limit requests and Billing on Stripe (its stream no longer rejects) each in place. AI: Ask's setting shows Unavailable instead of a form that could save over the real choice. Security: a failed overview no longer shows the activate-the-plan prompt. Members: outside collaborators, each member's teams; personal access tokens: members' tokens; Actions: managed workflows; packages: deleted packages; repositories: recently deleted; projects: production links; integrations: repositories to add and each alert source's recent deliveries; the visitors' overview: counts and open pull requests; code access, team repositories to add, members on the overview, installed apps, artifact spaces' teams (a space's form is withheld rather than lose its team kind). Artifact spaces and repository files no longer turn a failure into a 404. Home's streamed sections are each in a Boundary; usage has an errorElement. Kept quiet on purpose: the price book and this month's usage on billing (defaults and "—" stand in), EU availability, the delete list's app count, the workspace name (its slug stands in), descriptions, pins, the bypass badge. Checked: apps/web tsc -b; npm test (987).

G1t-Session:
ses_01m4zzpc96ect8d41mffgr1a45
syntaqxcommitted Parentb8afae4Browse files
25 files+456−1660/25 viewed
+10−3
2424 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../../components/ui/dialog";
2525 import { RadioGroup, RadioOption } from "../../components/ui/radio-group";
2626 import { SelectField } from "../../components/ui/select";
27+import { Unavailable } from "../../components/boundary";
2728 import { page } from "../../lib/meta";
29+import { isFailed, part, type Failed } from "../../lib/partial";
2830 import { actions } from "../../lib/services.server";
2931 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
3032
4042 const workspace = params.owner.toLowerCase();
4143 const settings = unwrap(await actions.workspaceActionsSettings(workspace, viewer));
4244 // The workflows it manages, and their sources, are its owners' to see.
43− const managed = role === "owner" ? unwrap(await actions.workspaceManagedWorkflows(workspace, viewer)) : [];
45+ // They are a section beside the policy: when Actions does not answer for
46+ // them, that section says so and the policy's form stays.
47+ const listed = role === "owner" ? await part(actions.workspaceManagedWorkflows(workspace, viewer), "Managed workflows") : null;
48+ const managed = listed === null ? [] : isFailed(listed) ? listed : unwrap(listed);
4449 return { settings, managed, owner: role === "owner" };
4550 }
4651
271276 );
272277 }
273278
274−function ManagedWorkflows({ workspace, managed }: { workspace: string; managed: WorkspaceManagedWorkflow[] }) {
279+function ManagedWorkflows({ workspace, managed }: { workspace: string; managed: WorkspaceManagedWorkflow[] | Failed }) {
275280 return (
276281 <section className="max-w-4xl space-y-4 border-t border-line pt-8">
277282 <div className="flex flex-wrap items-start justify-between gap-4">
288293 </div>
289294 <ManagedWorkflowDialog workspace={workspace} />
290295 </div>
291− {managed.length === 0 ? (
296+ {isFailed(managed) ? (
297+ <Unavailable what={managed.what} />
298+ ) : managed.length === 0 ? (
292299 <EmptyState title="No managed workflows">
293300 Add a workflow file from one of the workspace's repositories to run it in all of them, or in those you choose.
294301 </EmptyState>
+17−3
1717 import { autoOffered, defaultModel as startingModel, modelLabel, modelOptions } from "../../lib/ask-model";
1818 import { ai } from "../../lib/services.server";
1919 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
20+import { Unavailable } from "../../components/boundary";
21+import { isFailed, part } from "../../lib/partial";
2022
2123 export function meta({ params, ...args }: Route.MetaArgs) {
2224 return page(args, { title: `AI · ${params.owner} · g1t` });
3335 const role = roleIn(viewer, params.owner);
3436 if (!role) throw new Response(null, { status: 404 });
3537 const slug = params.owner.toLowerCase();
36− const [connections, ask] = await Promise.all([ai.list(slug, viewer), ai.askSettings(slug, viewer)]);
38+ // The providers are what the page is about. Ask's setting is a section
39+ // beside them: when it does not answer, that section says so instead of
40+ // offering a form that would save over a choice it could not read.
41+ const [connections, ask] = await Promise.all([ai.list(slug, viewer), part(ai.askSettings(slug, viewer), "Ask g1t's provider")]);
3742 const adding = new URL(request.url).searchParams.get("add");
3843 return {
3944 slug,
4045 owner: role === "owner",
4146 connections: unwrap(connections),
4247 adding: adding && isAiProvider(adding) ? adding : null,
43− ask: ask.ok ? ask.value : null,
48+ ask: isFailed(ask) ? ask : ask.ok ? ask.value : null,
4449 };
4550 }
4651
133138 {justAdded && <Added connection={justAdded} />}
134139 <ErrorText>{askError || addError ? null : error}</ErrorText>
135140
136− <AskSetting connections={connections} setup={ask} owner={owner} saved={asked} error={askError} />
141+ {isFailed(ask) ? (
142+ <section aria-labelledby="ask-heading" className="mb-8">
143+ <h2 id="ask-heading" className="mb-3 text-sm font-medium">
144+ Ask g1t
145+ </h2>
146+ <Unavailable what={ask.what} />
147+ </section>
148+ ) : (
149+ <AskSetting connections={connections} setup={ask} owner={owner} saved={asked} error={askError} />
150+ )}
137151
138152 <section aria-labelledby="providers-heading">
139153 <h2 id="providers-heading" className="mb-3 text-sm font-medium">
+11−9
1313 import type { Route } from "./+types/apps";
1414 import { AppTile, NoApps, useAppPins } from "../../components/apps";
1515 import { ButtonLink } from "../../components/ui";
16−import { DOCK_COOKIE, type InstalledAppData, appData, appPinFromForm, dockCookie, installedApps, pinsToShow, withPin, writeDock } from "../../lib/apps";
16+import { DOCK_COOKIE, appData, appPinFromForm, dockCookie, installedApps, pinsToShow, withPin, writeDock } from "../../lib/apps";
1717 import { connectedStates } from "../../lib/connected.server";
1818 import { savePins, savedPins } from "../../lib/dock.server";
1919 import { page } from "../../lib/meta";
2020 import { readCookie } from "../../lib/mission";
2121 import { assertSameOrigin, getViewer, requireUser } from "../../lib/session.server";
2222 import { Card } from "../../components/ui/card";
23+import { Unavailable } from "../../components/boundary";
24+import { isFailed, part } from "../../lib/partial";
2325
2426 export function meta({ params, ...args }: Route.MetaArgs) {
2527 return page(args, { title: `Apps · ${params.owner} · g1t` });
3032 const slug = params.owner.toLowerCase();
3133 const membership = viewer?.workspaces?.find((m) => m.slug === slug);
3234 if (!viewer || !membership) throw data(null, { status: 404 });
33− const [connected, saved] = await Promise.all([connectedStates(slug, viewer).catch(() => null), savedPins(viewer, slug)]);
35+ const [connected, saved] = await Promise.all([part(connectedStates(slug, viewer), "What is installed"), savedPins(viewer, slug)]);
3436 return {
3537 slug,
3638 name: membership.name?.trim() || slug,
37− /** Null when what is installed could not be read. */
38− apps: connected ? installedApps(connected).map((app) => appData(app, slug)) : (null as InstalledAppData[] | null),
39+ /** Failed when what is installed could not be read. */
40+ apps: isFailed(connected) ? connected : installedApps(connected).map((app) => appData(app, slug)),
3941 pins: pinsToShow(saved, readCookie(request.headers.get("cookie"), DOCK_COOKIE), slug),
4042 };
4143 }
6163 export default function Apps({ loaderData }: Route.ComponentProps) {
6264 const { slug, name, apps } = loaderData;
6365 const { pins, toggle } = useAppPins(slug, loaderData.pins);
64− const usable = (apps ?? []).filter((app) => app.usable);
66+ const usable = (isFailed(apps) ? [] : apps).filter((app) => app.usable);
6567 const pinned = pins.map((key) => usable.find((app) => app.key === key)).filter((app) => app != null);
6668 return (
6769 <main className="mx-auto max-w-5xl px-4 py-8 sm:px-8 sm:py-10">
7880 </ButtonLink>
7981 </div>
8082
81− {apps == null ? (
82− <Card asChild className="mt-8 px-4 py-6 text-center text-sm text-muted">
83− <p>What is installed can't be read right now. Reload in a minute.</p>
84− </Card>
83+ {isFailed(apps) ? (
84+ <div className="mt-8">
85+ <Unavailable what={apps.what} />
86+ </div>
8587 ) : apps.length === 0 ? (
8688 <div className="mt-8">
8789 <NoApps slug={slug} />
+62−19
3434 import { assertSameOrigin, getViewer, managesBilling, requireUser, roleIn, unwrap } from "../../lib/session.server";
3535 import { Card } from "../../components/ui/card";
3636 import { SubmitButton } from "../../components/ui";
37+import { Unavailable } from "../../components/boundary";
38+import { isFailed, orElse, part } from "../../lib/partial";
3739
3840 /** The trial and pools as published, when the price book cannot be read. */
3941 const DEFAULT_TRIAL_MICROS = 5_000_000;
100102 const manages = managesBilling(viewer, params.owner);
101103 // Stripe's own (the saved card, the upcoming invoice, whether Stripe Tax
102104 // needs an address) is streamed: the page does not wait on Stripe, and
103− // its sections hold their place.
104− const details = billing
105− .billingDetails(slug, viewer)
106− .then((result) => (result.ok ? result.value : null))
107− .catch(() => null);
108− const [account, statement, features, limit, entitlements, requests, book, credits, report] = await Promise.all([
105+ // its sections hold their place. When Stripe's side does not answer, the
106+ // card that shows it says so; the plan's summary does without it.
107+ const details = part(
108+ billing.billingDetails(slug, viewer).then((result) => (result.ok ? result.value : null)),
109+ "Billing on Stripe",
110+ );
111+ const [account, statement, features, limit, entitled, requests, book, credits, report] = await Promise.all([
109112 billing.account(slug, viewer),
110113 billing.statement(slug, viewer, url.searchParams.get("month"), group),
111114 billing.features(slug, viewer),
112− billing.limit(slug, viewer).catch(() => null),
113− billing.entitlements(slug).catch(() => null),
114− billing.limitRequests(slug, viewer).catch(() => null),
115+ // The account, its statement and the plan are what the page is about.
116+ // Each of these is a card beside them, which says so in its place when
117+ // billing does not answer for it.
118+ part(billing.limit(slug, viewer), "Your spend limit"),
119+ part(billing.entitlements(slug), "Your allowance and alerts"),
120+ part(billing.limitRequests(slug, viewer), "Your limit requests"),
121+ // The price book only fills in published defaults, which stand in for it.
115122 billing.prices().catch(() => null),
116− billing.credits(slug, viewer).catch(() => null),
123+ part(billing.credits(slug, viewer), "Credit from g1t"),
124+ // This month's usage so far: the plan's summary shows "—" without it.
117125 billing.usageReport(slug, viewer, { from: monthStart, until: today }).catch(() => null),
118126 ]);
127+ const entitlements = orElse(entitled, null);
119128 const featureStates = unwrap(features);
120129 const trialMicros = book?.free?.trialWorkspaceMicros ?? DEFAULT_TRIAL_MICROS;
121130 const done = url.searchParams.get("done");
122131 const plan = featureStates.find((state) => state.plan.feature === "plan") ?? null;
123132 const securityPlan = featureStates.find((state) => state.plan.feature === "security") ?? null;
124133 // Credit from g1t for everything; grants scoped to models belong to usage no longer offered.
125− const general = credits?.ok ? credits.value.grants.filter((grant) => grant.scope !== "models") : [];
134+ const general = !isFailed(credits) && credits.ok ? credits.value.grants.filter((grant) => grant.scope !== "models") : [];
126135 return {
127136 slug,
128137 role,
134143 group,
135144 plan,
136145 securityPlan,
137− limit: limit?.ok ? limit.value : null,
146+ limit: isFailed(limit) ? limit : limit.ok ? limit.value : null,
138147 entitlements,
139− requests: requests?.ok ? requests.value : [],
140− credits: general.length > 0 && credits?.ok ? { ...credits.value, grants: general } : null,
148+ /** Set when the allowance, alerts and trial could not be read: said once, at the top. */
149+ entitlementsFailed: isFailed(entitled) ? entitled : null,
150+ requests: isFailed(requests) ? requests : requests.ok ? requests.value : [],
151+ credits: isFailed(credits) ? credits : general.length > 0 && credits.ok ? { ...credits.value, grants: general } : null,
141152 report: report?.ok ? report.value : null,
142153 details,
143154 trialMicros,
251262 }
252263
253264 export default function WorkspaceBilling({ loaderData, actionData }: Route.ComponentProps) {
254− const { slug, role, staff, account, statement, group, plan, securityPlan, limit, entitlements, requests, credits, report, details, trialMicros, firstMonthMicros, notice, problem } =
265+ const { slug, role, staff, account, statement, group, plan, securityPlan, entitlements, entitlementsFailed, credits, report, details, trialMicros, firstMonthMicros, notice, problem } =
255266 loaderData;
267+ const limit = orElse(loaderData.limit, null);
268+ const requests = orElse(loaderData.requests, []);
256269 const { status } = account;
257270 const owner = role === "owner" || loaderData.managesBilling;
258271 const error = actionData as SectionError;
271284 slug={slug}
272285 state={plan}
273286 status={standing}
274− details={loaded}
287+ details={orElse(loaded, null)}
275288 report={report}
276289 owner={owner}
277290 enabled={status.enabled}
300313 <Suspense fallback={null}>
301314 <Await resolve={details}>
302315 {(loaded) =>
303− loaded?.taxAddressNeededAt ? (
316+ !isFailed(loaded) && loaded?.taxAddressNeededAt ? (
304317 <div role="alert" className="mb-6 flex flex-wrap items-start gap-3 rounded-xl border border-warn/40 bg-warn/5 p-4 text-sm">
305318 <div className="min-w-0 flex-1 basis-64">
306319 <p className="font-medium">Add your billing address in Stripe</p>
322335 }
323336 </Await>
324337 </Suspense>
338+ {entitlementsFailed && (
339+ <div className="mb-6">
340+ <Unavailable what={entitlementsFailed.what} />
341+ </div>
342+ )}
325343 {entitlements && <SpikeBanner slug={slug} entitlements={entitlements} owner={owner} />}
326344 {err("spike") && <p className="mb-6 text-sm text-danger">{err("spike")}</p>}
327345 <Alerts alerts={entitlements?.alerts ?? []} />
338356 <TrialCard entitlements={entitlements} trialMicros={trialMicros} owner={owner} enabled={status.enabled} error={err("trial")} />
339357 )}
340358
359+ {paying && isFailed(loaderData.limit) && (
360+ <div className="mb-6">
361+ <Unavailable what={loaderData.limit.what} />
362+ </div>
363+ )}
341364 {paying && limit && selfServe && (
342365 <>
343366 <SpendLimitCard limit={limit} owner={owner} error={err("limit")} />
347370 </>
348371 )}
349372
350− {credits && <CreditsCard credits={credits} />}
373+ {isFailed(credits) ? (
374+ <div className="mb-6">
375+ <Unavailable what={credits.what} />
376+ </div>
377+ ) : (
378+ credits && <CreditsCard credits={credits} />
379+ )}
351380
381+ {/* Past requests are listed on the limit's cards: without them, say so rather than show none. */}
382+ {isFailed(loaderData.requests) && (paying || (limit && selfServe)) && (
383+ <div className="mb-6">
384+ <Unavailable what={loaderData.requests.what} compact />
385+ </div>
386+ )}
352387 {paying && <OverageCard requests={requests} owner={owner} error={err("overage")} />}
353388
354389 <AddOns plan={plan} security={securityPlan} error={err("security")} />
356391 {status.enabled && owner && (
357392 <Suspense fallback={<StripeSkeleton />}>
358393 <Await resolve={details}>
359− {(loaded) => <BillingOnStripeCard details={loaded} enabled={status.enabled} live={status.live} staff={staff} error={err("stripe")} />}
394+ {(loaded) =>
395+ isFailed(loaded) ? (
396+ <div className="mb-6">
397+ <Unavailable what={loaded.what} />
398+ </div>
399+ ) : (
400+ <BillingOnStripeCard details={loaded} enabled={status.enabled} live={status.live} staff={staff} error={err("stripe")} />
401+ )
402+ }
360403 </Await>
361404 </Suspense>
362405 )}
+10−3
55 import { page } from "../../lib/meta";
66 import { identity } from "../../lib/services.server";
77 import { requireUser, roleIn } from "../../lib/session.server";
8+import { Unavailable } from "../../components/boundary";
9+import { isFailed, part } from "../../lib/partial";
810
911 export function meta({ params, ...args }: Route.MetaArgs) {
1012 return page(args, { title: `Code access · ${params.owner} · g1t` });
1719 export async function loader({ params, context, request }: Route.LoaderArgs) {
1820 const viewer = requireUser(context, request);
1921 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
20− const members = await identity.listMembers(params.owner, viewer).catch(() => null);
21− const owners = members?.ok ? members.value.filter((m) => m.role === "owner").slice(0, 4).map((m) => m.username) : [];
22+ // Who to ask is beside what the page says: without it, the page says that much.
23+ const members = await part(identity.listMembers(params.owner, viewer), "The workspace's owners");
24+ const owners = isFailed(members) ? members : members.ok ? members.value.filter((m) => m.role === "owner").slice(0, 4).map((m) => m.username) : [];
2225 const from = new URL(request.url).searchParams.get("from");
2326 // Only a path on this site, shown as text.
2427 return { owners, from: from && /^\/[^/]/.test(from) ? from.split("?")[0] : null };
4346 )}{" "}
4447 Your membership includes Chat, Artifacts and Notifications. An owner can turn on Code access for you.
4548 </p>
46− {owners.length > 0 && (
49+ {isFailed(owners) ? (
50+ <div className="mt-4 flex justify-center">
51+ <Unavailable what={owners.what} compact />
52+ </div>
53+ ) : owners.length > 0 && (
4754 <p className="mt-4 text-sm text-muted">
4855 Owners:{" "}
4956 {owners.map((owner, index) => (
+4−2
2323 const viewer = requireUser(context, request);
2424 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
2525 const repo = `${params.repoOwner}/${params.repoName}`;
26− const found = await docs.repoPage(params.owner.toLowerCase(), viewer, repo, params["*"] ?? "").catch(() => null);
27− if (!found?.ok) throw data(null, { status: 404 });
26+ // The file is what the page is about: when it cannot be read, the page
27+ // says so rather than that there is no such file.
28+ const found = await docs.repoPage(params.owner.toLowerCase(), viewer, repo, params["*"] ?? "");
29+ if (!found.ok) throw data(null, { status: 404 });
2830 return { found: found.value };
2931 }
3032
+12−3
77 import { page } from "../../../lib/meta";
88 import { identity } from "../../../lib/services.server";
99 import { requireUser, roleIn } from "../../../lib/session.server";
10+import { Unavailable } from "../../../components/boundary";
11+import { isFailed, orElse, part } from "../../../lib/partial";
1012
1113 export function meta({ params, ...args }: Route.MetaArgs) {
1214 return page(args, { title: `New space · Artifacts · ${params.owner} · g1t` });
1517 export async function loader({ params, context, request }: Route.LoaderArgs) {
1618 const viewer = requireUser(context, request);
1719 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
18− const teams = await identity.listTeams(viewer, params.owner.toLowerCase()).catch(() => null);
19− return { teams: teams?.ok ? teams.value.map((t) => ({ slug: t.slug, name: t.name })) : [] };
20+ // The teams a space can be for: without them, a space for everyone or
21+ // for its members can still be made, and the page says why no team is offered.
22+ const teams = await part(identity.listTeams(viewer, params.owner.toLowerCase()), "Teams");
23+ return { teams: isFailed(teams) ? teams : teams.ok ? teams.value.map((t) => ({ slug: t.slug, name: t.name })) : [] };
2024 }
2125
2226 /** A new space: for everyone, a team, or only its members. Whoever makes it has full access. */
2832 <div className="mx-auto max-w-2xl">
2933 <h1 className="text-xl font-semibold tracking-tight">New space</h1>
3034 <p className="mt-1 mb-8 text-sm text-muted">A space holds artifacts for a team, a project or a topic. What goes in it follows its access.</p>
35+ {isFailed(loaderData.teams) && (
36+ <div className="mb-4">
37+ <Unavailable what={loaderData.teams.what} compact />
38+ </div>
39+ )}
3140 <SpaceForm
3241 initial={initialSpace()}
33− teams={loaderData.teams}
42+ teams={orElse(loaderData.teams, [])}
3443 submitLabel="Create space"
3544 busy={busy}
3645 error={error}
+25−16
1818 import { page } from "../../../lib/meta";
1919 import { docs, identity } from "../../../lib/services.server";
2020 import { requireUser, roleIn } from "../../../lib/session.server";
21+import { Unavailable } from "../../../components/boundary";
22+import { isFailed, orElse, part, type Failed } from "../../../lib/partial";
2123
2224 export function meta({ loaderData: loaded, params, ...args }: Route.MetaArgs) {
2325 return page(args, { title: `Settings · ${loaded?.space.name ?? "Space"} · Artifacts · ${params.owner} · g1t` });
2426 }
2527
26−export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ space: DocSpace; members: DocSpaceMember[]; teams: { slug: string; name: string }[] }> {
28+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ space: DocSpace; members: DocSpaceMember[]; teams: { slug: string; name: string }[] | Failed }> {
2729 const viewer = requireUser(context, request);
2830 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
2931 const slug = params.owner.toLowerCase();
30− const [found, teams] = await Promise.all([docs.space(slug, params.space, viewer), identity.listTeams(viewer, slug).catch(() => null)]);
32+ // The teams it can be for are beside the space: without them its form is
33+ // not offered (a space for a team would read as one for nobody), while
34+ // sharing and members still are.
35+ const [found, teams] = await Promise.all([docs.space(slug, params.space, viewer), part(identity.listTeams(viewer, slug), "This space's settings")]);
3136 if (!found.ok) throw data(null, { status: 404 });
3237 if (found.value.space.viewer_role !== "manage") throw data(null, { status: 403 });
33− return { space: found.value.space, members: found.value.members, teams: teams?.ok ? teams.value.map((t) => ({ slug: t.slug, name: t.name })) : [] };
38+ return { space: found.value.space, members: found.value.members, teams: isFailed(teams) ? teams : teams.ok ? teams.value.map((t) => ({ slug: t.slug, name: t.name })) : [] };
3439 }
3540
3641 const ROLE_OPTIONS = DOC_ROLES.map((r) => ({ value: r, label: DOC_ROLE_LABELS[r], description: DOC_ROLE_SUMMARIES[r] }));
5358 const listed = new Set(members.map((m) => m.key));
5459 const candidates = [
5560 ...(layout?.mentionables ?? []).map((m) => ({ value: `username:${m.name}`, label: `${m.display_name} (@${m.name})` })),
56− ...teams.map((t) => ({ value: `team:${t.slug}`, label: `${t.name} (team)` })),
61+ ...orElse(teams, []).map((t) => ({ value: `team:${t.slug}`, label: `${t.name} (team)` })),
5762 ].filter((c) => !listed.has(c.value));
5863 return (
5964 <div className="mx-auto max-w-3xl">
6570 </p>
6671 <h1 className="mt-1 text-xl font-semibold tracking-tight">Space settings</h1>
6772 <div className="mt-8">
68− <SpaceForm
69− initial={initialSpace(space)}
70− teams={teams}
71− isDefault={space.is_default}
72− submitLabel="Save"
73− busy={settings.busy}
74− error={settings.error}
75− onSubmit={async (value) => {
76− const saved = await settings.send<DocSpace>("update_space", { space_id: space.id, space_change: spaceInput(value) });
77− if (saved.ok && saved.value.slug !== space.slug) navigate(`${spacePath(slug, saved.value.slug)}/settings`, { replace: true });
78− }}
79− />
73+ {isFailed(teams) ? (
74+ <Unavailable what={teams.what} />
75+ ) : (
76+ <SpaceForm
77+ initial={initialSpace(space)}
78+ teams={teams}
79+ isDefault={space.is_default}
80+ submitLabel="Save"
81+ busy={settings.busy}
82+ error={settings.error}
83+ onSubmit={async (value) => {
84+ const saved = await settings.send<DocSpace>("update_space", { space_id: space.id, space_change: spaceInput(value) });
85+ if (saved.ok && saved.value.slug !== space.slug) navigate(`${spacePath(slug, saved.value.slug)}/settings`, { replace: true });
86+ }}
87+ />
88+ )}
8089 </div>
8190
8291 <section className="mt-12">
+4−2
2323 const viewer = requireUser(context, request);
2424 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
2525 const slug = params.owner.toLowerCase();
26− const found = await docs.space(slug, params.space, viewer).catch(() => null);
27− if (!found?.ok || found.value.space.archived_at) throw data(null, { status: 404 });
26+ // The space is what the page is about: when Artifacts does not answer,
27+ // the page says so rather than that there is no such space.
28+ const found = await docs.space(slug, params.space, viewer);
29+ if (!found.ok || found.value.space.archived_at) throw data(null, { status: 404 });
2830 const list = await folios
2931 .list(slug, viewer, { tab: "all", space_id: found.value.space.id, limit: 100 })
3032 .then((r) => (r.ok ? r.value : null))
+48−14
7373 import { inbox } from "../../lib/services.server";
7474 import { requireUser, roleIn } from "../../lib/session.server";
7575 import { homePagePath } from "../../lib/workspace-nav";
76+import { Boundary } from "../../components/boundary";
7677
7778 export function meta({ params, ...args }: Route.MetaArgs) {
7879 return page(args, { title: `Home · ${params.owner} · g1t` });
312313 link={{ label: "Notifications", to: "/notifications" }}
313314 >
314315 <Suspense fallback={<RowsSkeleton rows={4} />}>
315− <Await resolve={waiting}>{(att) => <NeedsList attention={att} now={now} />}</Await>
316+ {/* Each section's data says what did not answer; a section that fails to draw keeps that to itself. */}
317+ <Await resolve={waiting}>
318+ {(att) => (
319+ <Boundary what="Where you're needed">
320+ <NeedsList attention={att} now={now} />
321+ </Boundary>
322+ )}
323+ </Await>
316324 </Suspense>
317325 </Card>
318326 </div>
321329 <aside aria-label="Now" className="contents lg:sticky lg:top-4 lg:col-start-2 lg:row-span-2 lg:row-start-1 lg:block lg:space-y-5">
322330 <Card title="Running now" className="order-2">
323331 <Suspense fallback={<RowsSkeleton rows={2} />}>
324− <Await resolve={runningNow}>{(value) => <RunningList rows={value.rows} missing={value.missing} now={now} />}</Await>
332+ <Await resolve={runningNow}>
333+ {(value) => (
334+ <Boundary what="Running now">
335+ <RunningList rows={value.rows} missing={value.missing} now={now} />
336+ </Boundary>
337+ )}
338+ </Await>
325339 </Suspense>
326340 </Card>
327341 <div className="order-4">
332346 </Card>
333347 }
334348 >
335− <Await resolve={spend}>{(value) => <SpendCard spend={value} slug={slug} span={span} />}</Await>
349+ <Await resolve={spend}>
350+ {(value) => (
351+ <Boundary what="Spent">
352+ <SpendCard spend={value} slug={slug} span={span} />
353+ </Boundary>
354+ )}
355+ </Await>
336356 </Suspense>
337357 </div>
338358 <div className="order-5">
343363 </Card>
344364 }
345365 >
346− <Await resolve={away}>{(value) => <SettledCard away={value} now={now} />}</Await>
366+ <Await resolve={away}>
367+ {(value) => (
368+ <Boundary what="Settled for you">
369+ <SettledCard away={value} now={now} />
370+ </Boundary>
371+ )}
372+ </Await>
347373 </Suspense>
348374 </div>
349375 </aside>
367393 </div>
368394 <div className="border-t border-line">
369395 <Suspense fallback={<SpanSkeleton />}>
370− <Await resolve={digest}>{(value) => <PeopleSummary digest={value} span={span} code={loaderData.code} />}</Await>
396+ <Await resolve={digest}>
397+ {(value) => (
398+ <Boundary what="Who did what">
399+ <PeopleSummary digest={value} span={span} code={loaderData.code} />
400+ </Boundary>
401+ )}
402+ </Await>
371403 </Suspense>
372404 </div>
373405 <Suspense
379411 >
380412 <Await resolve={happened}>
381413 {(value) => (
382− <LandedFeed
383− landed={value?.landed ?? null}
384− missing={value?.landedMissing ?? []}
385− code={loaderData.code}
386− now={now}
387− tz={tz}
388− byDay={span.key === "7d"}
389− scope={span.key}
390− />
414+ <Boundary what="What landed">
415+ <LandedFeed
416+ landed={value?.landed ?? null}
417+ missing={value?.landedMissing ?? []}
418+ code={loaderData.code}
419+ now={now}
420+ tz={tz}
421+ byDay={span.key === "7d"}
422+ scope={span.key}
423+ />
424+ </Boundary>
391425 )}
392426 </Await>
393427 </Suspense>
+25−10
2121 import { Combobox } from "../../components/ui/combobox";
2222 import { integrations, repos } from "../../lib/services.server";
2323 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
24+import { Unavailable } from "../../components/boundary";
25+import { isFailed, orElse, part, type Failed } from "../../lib/partial";
2426
2527 export function meta({ params, ...args }: Route.MetaArgs) {
2628 const kind = sectionKind(params.section);
3941 // One kind of connection's page: the directory at `-/integrations` finds them.
4042 const kind = sectionKind(params.section);
4143 if (!kind) throw new Response(null, { status: 404 });
42− const [connections, listed] = await Promise.all([integrations.list(slug, viewer), repos.list(viewer, { namespace: slug })]);
44+ // The connections are what the page is about. The repositories a new
45+ // one can file issues in, and what each alert source sent lately, are
46+ // beside them: each says so in its place when it cannot be read.
47+ const [connections, listed] = await Promise.all([integrations.list(slug, viewer), part(repos.list(viewer, { namespace: slug }), "Repositories")]);
4348 const all = unwrap(connections);
4449 // What each alert source has sent lately, to see it is wired up.
45− const deliveries: Record<string, Delivery[]> = {};
50+ const deliveries: Record<string, Delivery[] | Failed> = {};
4651 await Promise.all(
4752 all
4853 .filter((connection) => connection.kind === "alerts")
4954 .map(async (connection) => {
50− const found = await integrations.deliveries(slug, viewer, connection.id);
51− deliveries[connection.id] = found.ok ? found.value.slice(0, 5) : [];
55+ const found = await part(integrations.deliveries(slug, viewer, connection.id), "What it sent lately");
56+ deliveries[connection.id] = isFailed(found) ? found : found.ok ? found.value.slice(0, 5) : [];
5257 }),
5358 );
5459 const adding = new URL(request.url).searchParams.get("add");
5863 kind,
5964 connections: all,
6065 deliveries,
61− repos: listed.map((repo) => `${repo.namespace}/${repo.name}`),
66+ repos: isFailed(listed) ? listed : listed.map((repo) => `${repo.namespace}/${repo.name}`),
6267 adding: isProvider(adding) && PROVIDERS[adding].kind === kind ? adding : null,
6368 };
6469 }
139144 };
140145
141146 export default function WorkspaceIntegrations({ loaderData, actionData }: Route.ComponentProps) {
142− const { slug, role, kind, connections, deliveries, repos: repoNames, adding } = loaderData;
147+ const { slug, role, kind, connections, deliveries, adding } = loaderData;
148+ const repoNames = orElse(loaderData.repos, []);
149+ // A new connection can still be added without the list to choose a repository from: say why it is empty.
143150 const owner = role === "owner";
151+ const reposMissing = isFailed(loaderData.repos) && adding && owner ? <Unavailable what={loaderData.repos.what} compact /> : null;
144152 const justConnected = actionData && "connected" in actionData ? actionData.connected : null;
145153 const tested = (actionData && "tested" in actionData ? actionData.tested : null) ?? null;
146154 const error = (actionData && "error" in actionData ? actionData.error : null) ?? null;
167175 deliveries={deliveries}
168176 />
169177 {owner && <Choices kind="alerts" slug={slug} adding={adding} />}
178+ {adding && PROVIDERS[adding].kind === "alerts" && reposMissing}
170179 {adding && PROVIDERS[adding].kind === "alerts" && owner && (
171180 <AddForm
172181 provider={adding}
189198 deliveries={deliveries}
190199 />
191200 {owner && <Choices kind="tracker" slug={slug} adding={adding} />}
201+ {adding && PROVIDERS[adding].kind === "tracker" && reposMissing}
192202 {adding && PROVIDERS[adding].kind === "tracker" && owner && (
193203 <AddForm
194204 provider={adding}
232242 tested: { id: string; ok: boolean; message: string } | null;
233243 /** The connection whose secret was just saved. */
234244 updated: string | null;
235− deliveries: Record<string, Delivery[]>;
245+ deliveries: Record<string, Delivery[] | Failed>;
236246 }) {
237247 if (list.length === 0) return null;
238248 return (
275285 owner: boolean;
276286 tested: { id: string; ok: boolean; message: string } | null;
277287 updated: boolean;
278− deliveries: Delivery[];
288+ deliveries: Delivery[] | Failed;
279289 }) {
280290 const { config } = connection;
281291 const facts = [
286296 config.keys?.length ? config.keys.join(", ") : null,
287297 connection.secretHint && `key ${connection.secretHint}`,
288298 ].filter(Boolean);
289− const waitingForSecret = connection.provider === "sentry" && !deliveries.length && !connection.lastUsedAt;
299+ // Unread deliveries say nothing about whether its secret was set.
300+ const waitingForSecret = connection.provider === "sentry" && !isFailed(deliveries) && !deliveries.length && !connection.lastUsedAt;
290301 return (
291302 <div>
292303 <div className="flex items-center gap-3">
342353 {updated && <span className="pb-2 text-sm text-muted">Saved.</span>}
343354 </Form>
344355 )}
345− {deliveries.length > 0 && (
356+ {isFailed(deliveries) ? (
357+ <div className="mt-3">
358+ <Unavailable what={deliveries.what} compact />
359+ </div>
360+ ) : deliveries.length > 0 && (
346361 <ul className="mt-3 space-y-1 text-xs">
347362 {deliveries.map((delivery) => (
348363 <li key={delivery.id} className="flex items-center gap-2 text-muted">
+5−2
1616 import { WorkspacePageSkeleton } from "../../components/workspace-skeletons";
1717 import { identity } from "../../lib/services.server";
1818 import { getViewer, roleIn } from "../../lib/session.server";
19+import { isFailed, part } from "../../lib/partial";
1920
2021 export function meta({ loaderData, params, ...args }: Route.MetaArgs) {
2122 return page(args, { title: `${loaderData?.workspace.name ?? params.owner} · g1t` });
3738 const role = roleIn(viewer, workspace.slug);
3839 // Opening one of your workspaces makes it the one you are in.
3940 if (!role) {
40− const listed = await workspaceProjects(workspace.slug, viewer);
41− return { workspace, role, welcome: false, projects: listed.ok ? listed.value.map(shortcutOf) : [] };
41+ // The workspace is what the frame is about; its projects only fill the
42+ // sidebar, which goes without them rather than take every page down.
43+ const listed = await part(workspaceProjects(workspace.slug, viewer), "Projects");
44+ return { workspace, role, welcome: false, projects: !isFailed(listed) && listed.ok ? listed.value.map(shortcutOf) : [] };
4245 }
4346 const secure = new URL(request.url).protocol === "https:";
4447 const headers = new Headers({ "Set-Cookie": rememberWorkspace(workspace.slug, secure) });
+2−1
5151 if (roleIn(viewer, params.owner) !== "owner") throw data(null, { status: 404 });
5252 const [invites, workspace, free] = await Promise.all([
5353 identity.workspaceInvites(params.owner, viewer),
54− identity.getWorkspace(params.owner),
54+ // Only for the workspace's name, which its address stands in for.
55+ identity.getWorkspace(params.owner).catch(() => null),
5556 billing.freeWorkspaces([params.owner]).catch(() => [] as string[]),
5657 ]);
5758 return {
+42−17
4141 import { billing, identity } from "../../lib/services.server";
4242 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
4343 import { forgetWorkspace } from "../../lib/workspace-choice";
44+import { Unavailable } from "../../components/boundary";
45+import { isFailed, part, type Failed } from "../../lib/partial";
4446
4547 export function meta({ params, ...args }: Route.MetaArgs) {
4648 return page(args, { title: `Members · ${params.owner} · g1t` });
6062 if (!role) throw data(null, { status: 404 });
6163 const owner = role === "owner";
6264 const [members, workspace, outside, teams, free, invites] = await Promise.all([
65+ // The members are what the page is about. The rest is beside them, and
66+ // each part says so in its place when it cannot be read.
6367 identity.listMembers(params.owner, viewer),
64− identity.getWorkspace(params.owner),
65− owner ? identity.outsideCollaborators(viewer, params.owner).catch(() => null) : null,
68+ // Only for the workspace's name, which its address stands in for.
69+ identity.getWorkspace(params.owner).catch(() => null),
70+ owner ? part(identity.outsideCollaborators(viewer, params.owner), "Outside collaborators") : null,
6671 // Each member's teams, as the viewer may see them.
67− identity.teamMemberships(viewer, params.owner).catch(() => null),
72+ part(identity.teamMemberships(viewer, params.owner), "Each member's teams"),
6873 // A free workspace adds no one until it starts the plan; identity
6974 // refuses it either way, so a failure here only hides the note.
7075 billing.freeWorkspaces([params.owner]).catch(() => [] as string[]),
7176 // How many invitations wait, for the line that leads to them.
72− owner ? identity.workspaceInvites(params.owner, viewer).catch(() => null) : null,
77+ owner ? part(identity.workspaceInvites(params.owner, viewer), "Invitations") : null,
7378 ]);
7479 return {
7580 role,
7681 name: workspace?.name?.trim() || params.owner,
7782 free: free.includes(params.owner.toLowerCase()),
7883 members: unwrap(members),
79− outside: outside?.ok ? outside.value : [],
80− teams: Object.fromEntries((teams?.ok ? teams.value : []).map((person) => [person.username, person.teams])),
81− pendingInvitations: invites?.ok ? invites.value.filter((invite) => invitationFilterOf(invite.status) === "pending").length : 0,
84+ outside: isFailed(outside) ? outside : outside?.ok ? outside.value : [],
85+ teams: Object.fromEntries((!isFailed(teams) && teams.ok ? teams.value : []).map((person) => [person.username, person.teams])),
86+ teamsFailed: isFailed(teams) ? teams : null,
87+ /** Null when how many wait could not be read: the line still leads to them. */
88+ pendingInvitations: isFailed(invites) ? null : invites?.ok ? invites.value.filter((invite) => invitationFilterOf(invite.status) === "pending").length : 0,
8289 me: viewer?.username ?? null,
8390 };
8491 }
366373 }
367374
368375 export default function WorkspaceMembers({ loaderData, actionData, params }: Route.ComponentProps) {
369− const { role, members, outside, teams, free, me, name, pendingInvitations } = loaderData;
376+ const { role, members, outside, teams, teamsFailed, free, me, name, pendingInvitations } = loaderData;
370377 const owner = role === "owner";
371378 const owners = members.filter((member) => member.role === "owner").length;
372379 const rowError = (username: string) =>
386393 const membersTab = (
387394 <>
388395 <MembersToolbar owner={owner} teams={teamChoices} invite={invite} />
396+ {teamsFailed && (
397+ <div className="mb-4">
398+ <Unavailable what={teamsFailed.what} compact />
399+ </div>
400+ )}
389401 {owner && free && (
390402 <div className="mb-4">
391403 <StartPlanToInvite workspace={params.owner} owner={owner} />
446458 )}
447459 <p className="mt-2 text-xs text-faint">
448460 {filtered ? `${shown.length} of ${members.length}` : members.length} {members.length === 1 ? "member" : "members"}
449− {owner && pendingInvitations > 0 && (
461+ {owner && pendingInvitations === null && (
450462 <>
451463 {" · "}
452464 <Link to={`/${params.owner}/-/members/invitations?state=pending`} className="inline-flex items-center gap-1 text-muted underline-offset-4 hover:text-fg hover:underline">
453465 <Mail size={12} aria-hidden="true" />
466+ Invitations
467+ </Link>
468+ </>
469+ )}
470+ {owner && pendingInvitations != null && pendingInvitations > 0 && (
471+ <>
472+ {" · "}
473+ <Link to={`/${params.owner}/-/members/invitations?state=pending`} className="inline-flex items-center gap-1 text-muted underline-offset-4 hover:text-fg hover:underline">
474+ <Mail size={12} aria-hidden="true" />
454475 {pendingInvitations} {pendingInvitations === 1 ? "invitation" : "invitations"} waiting
455476 </Link>
456477 </>
502523 >
503524 <TabsList className="mb-4">
504525 <TabsTrigger value="members">Members · {members.length}</TabsTrigger>
505− <TabsTrigger value="outside">Outside collaborators · {outside.length}</TabsTrigger>
526+ <TabsTrigger value="outside">{isFailed(outside) ? "Outside collaborators" : `Outside collaborators · ${outside.length}`}</TabsTrigger>
506527 </TabsList>
507528 <TabsContent value="members">{membersTab}</TabsContent>
508529 <TabsContent value="outside">
509− <OutsideCollaborators
510− people={outside}
511− slug={params.owner}
512− free={free}
513− error={actionData && "converting" in actionData && actionData.converting ? actionData.error : null}
514− />
530+ {isFailed(outside) ? (
531+ <Unavailable what={outside.what} />
532+ ) : (
533+ <OutsideCollaborators
534+ people={outside}
535+ slug={params.owner}
536+ free={free}
537+ error={actionData && "converting" in actionData && actionData.converting ? actionData.error : null}
538+ />
539+ )}
515540 </TabsContent>
516541 </Tabs>
517542 ) : (
528553 free,
529554 error,
530555 }: {
531− people: Route.ComponentProps["loaderData"]["outside"];
556+ people: Exclude<Route.ComponentProps["loaderData"]["outside"], Failed>;
532557 slug: string;
533558 free: boolean;
534559 error: string | null | undefined;
+22−4
1919 import { deployments, identity, packages, projects as projectsApi, work } from "../../lib/services.server";
2020 import { getViewer, roleIn } from "../../lib/session.server";
2121 import { workspaceProjects } from "../../lib/workspace-projects.server";
22+import { Unavailable } from "../../components/boundary";
23+import { isFailed, part } from "../../lib/partial";
2224
2325 /** Projects whose open issues and pull requests are counted: the most active. */
2426 const MAX_COUNTED = 30;
6365 const pathOf = (project: Project) =>
6466 project.source.kind === "hosted" ? project.source.repo : { namespace: project.workspace, name: project.slug };
6567 const counted = [...new Map([...pinned, ...active, ...byActivity].map((project) => [project.id, project])).values()].slice(0, Math.max(MAX_COUNTED, pinned.length + active.length));
66− const counts = await Promise.all(counted.map((project) => work.counts(pathOf(project), viewer)));
68+ // The projects are what the page is about. What is open in each, and the
69+ // pull requests in progress, are beside them: a project whose counts
70+ // could not be read shows none, and the page says the totals are short.
71+ const counts = await Promise.all(counted.map((project) => part(work.counts(pathOf(project), viewer), "Open issues and pull requests")));
6772 const open: Record<string, { issues: number; pulls: number }> = {};
6873 counted.forEach((project, i) => {
6974 const result = counts[i];
70− if (result.ok) open[project.id] = result.value;
75+ if (!isFailed(result) && result.ok) open[project.id] = result.value;
7176 });
77+ const countsFailed = counts.find(isFailed) ?? null;
7278
7379 const busy = counted.filter((project) => (open[project.id]?.pulls ?? 0) > 0).slice(0, MAX_LISTED);
74− const lists = await Promise.all(busy.map((project) => work.listPulls(pathOf(project), viewer, "open")));
80+ const lists = await Promise.all(busy.map((project) => part(work.listPulls(pathOf(project), viewer, "open"), "Pull requests in progress")));
81+ const pullsFailed = lists.find(isFailed) ?? null;
7582 const pulls = lists
76− .flatMap((result, i) => (result.ok ? result.value.map((pull) => ({ pull, project: busy[i] })) : []))
83+ .flatMap((result, i) => (!isFailed(result) && result.ok ? result.value.map((pull) => ({ pull, project: busy[i] })) : []))
7784 .sort((a, b) => b.pull.updatedAt.localeCompare(a.pull.updatedAt))
7885 .slice(0, MAX_PULLS);
7986
102109 deploys: bySlug,
103110 packageOf,
104111 pulls: pulls.map(({ pull, project }) => ({ pull, slug: project.slug, name: project.name })),
112+ countsFailed,
113+ pullsFailed,
105114 members: members?.ok ? members.value : null,
106115 usage,
107116 };
277286 <Stat value={totals.issues} label="Open issues" />
278287 <Stat value={totals.pulls} label="Pull requests in progress" />
279288 </div>
289+ {loaderData.countsFailed && <Unavailable what={loaderData.countsFailed.what} compact />}
280290
281291 {role && (
282292 <section aria-labelledby="pinned">
327337 </span>
328338 </Link>
329339
340+ {loaderData.pullsFailed && pulls.length === 0 && (
341+ <section>
342+ <h2 className="text-sm font-medium text-muted">In progress</h2>
343+ <div className="mt-3">
344+ <Unavailable what={loaderData.pullsFailed.what} />
345+ </div>
346+ </section>
347+ )}
330348 {pulls.length > 0 && (
331349 <section>
332350 <h2 className="text-sm font-medium text-muted">In progress</h2>
+11−5
2525 } from "../../lib/packages";
2626 import { packages } from "../../lib/services.server";
2727 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
28+import { Unavailable } from "../../components/boundary";
29+import { isFailed, orElse, part } from "../../lib/partial";
2830
2931 const DOCS = "https://docs.g1t.sh";
3032
5052 // then is there a Deleted packages view.
5153 const [found, deletedFound] = await Promise.all([
5254 packages.list(params.owner, viewer, { ecosystem, query }),
53− member ? packages.deleted(params.owner, viewer) : null,
55+ // Beside the list: when they cannot be read, their view says so.
56+ member ? part(packages.deleted(params.owner, viewer), "Deleted packages") : null,
5457 ]);
5558 const listed = unwrap(found);
56− const deleted = deletedFound?.ok ? deletedFound.value : [];
59+ const deleted = isFailed(deletedFound) ? deletedFound : deletedFound?.ok ? deletedFound.value : [];
5760 // Whether the workspace has any at all decides between a filtered-out
5861 // list and the first-package page.
59− const any = listed.length > 0 || deleted.length > 0 || ecosystem != null || query != null;
62+ const any = listed.length > 0 || orElse(deleted, []).length > 0 || ecosystem != null || query != null;
6063 return {
6164 member,
6265 list: arrange(listed, visibility, sort),
104107 export default function Packages({ loaderData, actionData }: Route.ComponentProps) {
105108 const { list, deleted, view, any, ecosystem, query, visibility, sort, workspace } = loaderData;
106109 if (!any) return loaderData.member ? <ChooseRegistry workspace={workspace} /> : <EmptyState title="No public packages">This workspace has no packages you can view yet.</EmptyState>;
107− if (view === "deleted") return <DeletedPackages workspace={workspace} deleted={deleted} outcome={actionData as Outcome | undefined} />;
110+ if (view === "deleted") {
111+ if (isFailed(deleted)) return <Unavailable what={deleted.what} />;
112+ return <DeletedPackages workspace={workspace} deleted={deleted} outcome={actionData as Outcome | undefined} />;
113+ }
108114 const filters: Filters = { type: ecosystem, q: query, visibility, sort };
109115 const filtered = ecosystem != null || query != null || visibility !== "all";
110116 return (
167173 )}
168174 </span>
169175 <span className="flex items-center gap-4">
170− {deleted.length > 0 && (
176+ {!isFailed(deleted) && deleted.length > 0 && (
171177 <Link to={`/${workspace}/-/packages?view=deleted`} className="inline-flex items-center gap-1 text-xs text-muted hover:text-fg">
172178 <Trash2 size={12} />
173179 Deleted packages <span className="rounded-full bg-raised px-1.5 text-faint tabular-nums">{deleted.length}</span>
+20−7
1515 import { identity } from "../../lib/services.server";
1616 import { currentTokensPath, lifetimeFromForm } from "../../lib/access-tokens";
1717 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
18+import { Unavailable } from "../../components/boundary";
19+import { isFailed, orElse, part, type Failed } from "../../lib/partial";
1820
1921 // A workspace's rules for its members' personal access tokens, the tokens
2022 // that reach it, and approving the ones made for it. Owners only; identity
3133 if (moved) throw redirect(moved, 301);
3234 const viewer = getViewer(context);
3335 const role = roleIn(viewer, params.owner);
34− if (!viewer || role !== "owner") return { slug, role, policy: null, tokens: [] as MemberToken[] };
35− const [policy, tokens] = await Promise.all([identity.getTokenPolicy(slug, viewer), identity.listMemberTokens(viewer, slug)]);
36− return { slug, role, policy: unwrap(policy), tokens: unwrap(tokens) };
36+ if (!viewer || role !== "owner") return { slug, role, policy: null, tokens: [] as MemberToken[] | Failed };
37+ // The rules are what the page is about, and their form is never shown
38+ // without them. The tokens are listed beside them: when they cannot be
39+ // read, their sections say so and the rules can still be changed.
40+ const [policy, tokens] = await Promise.all([identity.getTokenPolicy(slug, viewer), part(identity.listMemberTokens(viewer, slug), "Members' tokens")]);
41+ return { slug, role, policy: unwrap(policy), tokens: isFailed(tokens) ? tokens : unwrap(tokens) };
3742 }
3843
3944 export async function action({ request, params, context }: Route.ActionArgs) {
9297 </EmptyState>
9398 );
9499 }
95− const pending = tokens.filter((member) => member.token.status === "pending");
96− const listed = tokens.filter((member) => member.token.status !== "pending");
100+ const pending = orElse(tokens, []).filter((member) => member.token.status === "pending");
101+ const listed = orElse(tokens, []).filter((member) => member.token.status !== "pending");
97102 const lifetime = policy.maxLifetimeDays == null ? "" : String(policy.maxLifetimeDays);
98103 return (
99104 <div className="max-w-3xl space-y-10">
169174 Waiting for approval
170175 {pending.length > 0 && <Badge tone="warn">{pending.length}</Badge>}
171176 </h2>
172− {pending.length === 0 ? (
177+ {isFailed(tokens) ? (
178+ <div className="mt-2">
179+ <Unavailable what={tokens.what} compact />
180+ </div>
181+ ) : pending.length === 0 ? (
173182 <p className="mt-2 text-sm text-muted">No token is waiting for approval.</p>
174183 ) : (
175184 <Card asChild tone="plain" divided className="mt-3">
209218 </Link>
210219 .
211220 </p>
212− {listed.length === 0 ? (
221+ {isFailed(tokens) ? (
222+ <div className="mt-3">
223+ <Unavailable what={tokens.what} />
224+ </div>
225+ ) : listed.length === 0 ? (
213226 <div className="mt-3">
214227 <EmptyState title="No tokens">No member has a personal access token that can reach {slug}.</EmptyState>
215228 </div>
+13−2
3232 import { deployments, projects } from "../../lib/services.server";
3333 import { getViewer, roleIn } from "../../lib/session.server";
3434 import { workspaceProjects } from "../../lib/workspace-projects.server";
35+import { Unavailable } from "../../components/boundary";
36+import { isFailed, part } from "../../lib/partial";
3537
3638 export function meta({ params, ...args }: Route.MetaArgs) {
3739 return pageMeta(args, { title: `Projects · ${params.owner} · g1t` });
4951 const query = readProjectQuery(new URL(request.url).searchParams);
5052 const [listed, deploys, shortcuts] = await Promise.all([
5153 workspaceProjects(slug, viewer),
52− role ? deployments.overview(slug, viewer).catch(() => null) : null,
54+ // Where each project runs: without it, rows lead to their homepage
55+ // rather than production, and the page says why.
56+ role ? part(deployments.overview(slug, viewer), "Production links") : null,
57+ // The viewer's pins only order the list; without them it is in its usual order.
5358 role ? projects.shortcuts(slug, viewer).catch(() => null) : null,
5459 ]);
55− const live = new Map((deploys?.ok ? deploys.value : []).map((entry) => [entry.slug, entry]));
60+ const live = new Map((!isFailed(deploys) && deploys?.ok ? deploys.value : []).map((entry) => [entry.slug, entry]));
5661 const full = new Map((listed.ok ? listed.value : []).map((project) => [project.id, project]));
5762 const all: Listed[] = (listed.ok ? listed.value : []).map((project) => ({
5863 id: project.id,
8691 facets: facetsOf(all),
8792 pinned: (shortcuts?.pinned ?? []).map((project) => project.id),
8893 failed: !listed.ok,
94+ deploysFailed: isFailed(deploys) ? deploys : null,
8995 };
9096 }
9197
494500 </div>
495501
496502 <div ref={list} onKeyDown={onListKey} aria-busy={busy || undefined} className={`mt-4 transition-opacity ${busy ? "opacity-60" : ""}`}>
503+ {!failed && loaderData.deploysFailed && (
504+ <div className="mb-3">
505+ <Unavailable what={loaderData.deploysFailed.what} compact />
506+ </div>
507+ )}
497508 {failed ? (
498509 <EmptyState title="Projects cannot be listed right now">Try again in a moment.</EmptyState>
499510 ) : total === 0 ? (
+9−3
2424 } from "../../lib/repo-lifecycle";
2525 import { projects, repos } from "../../lib/services.server";
2626 import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
27+import { Unavailable } from "../../components/boundary";
28+import { isFailed, orElse, part } from "../../lib/partial";
2729
2830 export function meta({ params, ...args }: Route.MetaArgs) {
2931 return page(args, { title: `Repositories · ${params.owner} · g1t` });
3739 const owner = role === "owner";
3840 const [list, deleted] = await Promise.all([
3941 repos.list(viewer, { namespace: params.owner, memberOnly: true }),
40− owner ? repos.deleted(viewer, params.owner) : Promise.resolve([]),
42+ // Beside the list: when it cannot be read, its section says so.
43+ owner ? part(repos.deleted(viewer, params.owner), "Recently deleted repositories") : Promise.resolve([]),
4144 ]);
4245 // A repository has no description: its project's is shown, and searched.
43− const about = await projects.descriptions(list.map((repo) => repo.id));
46+ // Without them each row goes without one, which is how a row without a description reads anyway.
47+ const about = orElse(await part(projects.descriptions(list.map((repo) => repo.id)), "Descriptions"), {} as Record<string, string | null>);
4448 return {
4549 repos: list.map((repo) => ({ ...repo, description: about[repo.id] ?? null })),
4650 deleted,
311315 Their names stay taken until then. Only owners see this list.
312316 </p>
313317 </div>
314− {deleted.length === 0 ? (
318+ {isFailed(deleted) ? (
319+ <Unavailable what={deleted.what} />
320+ ) : deleted.length === 0 ? (
315321 <Card asChild tone="plain" className="border-dashed px-4 py-6 text-center text-sm text-faint">
316322 <p>
317323 Nothing has been deleted in the last {RESTORE_DAYS} days.
+4−2
1212 import { Card } from "../../components/ui/card";
1313 import { securitySuite } from "../../lib/services.server";
1414 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
15+import { isFailed, part } from "../../lib/partial";
1516
1617 export function meta({ params, ...args }: Route.MetaArgs) {
1718 return page(args, { title: `Bypass requests · ${params.owner} · g1t` });
2526 const state = new URL(request.url).searchParams.get("state") ?? "pending";
2627 const [list, pending] = await Promise.all([
2728 securitySuite.bypassRequests(params.owner, viewer, STATES.has(state) ? state : null),
28− securitySuite.bypassRequests(params.owner, viewer, "pending"),
29+ // Only the tabs' badge: without it, the badge goes without a count.
30+ part(securitySuite.bypassRequests(params.owner, viewer, "pending"), "Pending bypass requests"),
2931 ]);
30− return { requests: unwrap(list), pending: pending.ok ? pending.value.length : 0, me: viewer?.username ?? "" };
32+ return { requests: unwrap(list), pending: !isFailed(pending) && pending.ok ? pending.value.length : 0, me: viewer?.username ?? "" };
3133 }
3234
3335 export async function action({ params, context, request }: Route.ActionArgs) {
+16−6
1414 import { repos, security, securitySuite } from "../../lib/services.server";
1515 import { getViewer, managesSecurity, roleIn, unwrap } from "../../lib/session.server";
1616 import { planPrice } from "../../lib/security-suite.server";
17+import { Unavailable } from "../../components/boundary";
18+import { isFailed, part } from "../../lib/partial";
1719
1820 export function meta({ params, ...args }: Route.MetaArgs) {
1921 return page(args, { title: `Security · ${params.owner} · g1t` });
2527 const [scanned, current, overview, requests, price] = await Promise.all([
2628 security.workspace(params.owner, viewer),
2729 repos.list(viewer, { namespace: params.owner.toLowerCase() }),
28− securitySuite.overview(params.owner, viewer, 30),
29− securitySuite.bypassRequests(params.owner, viewer, "pending"),
30+ // The alerts are what the page is about. The overview beside them says
31+ // so in its place when it cannot be read, rather than offering the plan
32+ // to a workspace that may have it; the pending count is only a badge.
33+ part(securitySuite.overview(params.owner, viewer, 30), "The security overview"),
34+ part(securitySuite.bypassRequests(params.owner, viewer, "pending"), "Pending bypass requests"),
3035 planPrice(params.owner, viewer),
3136 ]);
3237 // Only repositories that are still there: a deleted one's alerts stay
4550 (a, b) =>
4651 SEVERITIES.reduce((order, severity) => order || b.counts[severity] - a.counts[severity], 0) || a.name.localeCompare(b.name),
4752 );
48− const full = overview.ok
53+ const full = !isFailed(overview) && overview.ok
4954 ? {
5055 ...overview.value,
5156 repos: overview.value.repos
5762 projects,
5863 total,
5964 overview: full,
60− pending: requests.ok ? requests.value.length : 0,
65+ overviewFailed: isFailed(overview) ? overview : null,
66+ pending: !isFailed(requests) && requests.ok ? requests.value.length : 0,
6167 price,
6268 owner: managesSecurity(viewer, params.owner),
6369 };
7076 };
7177
7278 export default function WorkspaceSecurity({ loaderData, params }: Route.ComponentProps) {
73− const { projects, total, overview, pending, price, owner } = loaderData;
79+ const { projects, total, overview, overviewFailed, pending, price, owner } = loaderData;
7480 // The full overview counts private repositories with the plan; without it, a workspace
7581 // with private ones sees the free list of every repository's open alerts.
7682 const full = overview && (overview.activated || overview.privateHidden === 0);
102108 </>
103109 ) : (
104110 <>
105− <ActivationPrompt workspace={params.owner} feature="The security overview" monthlyCents={price} isOwner={owner} />
111+ {overviewFailed ? (
112+ <Unavailable what={overviewFailed.what} />
113+ ) : (
114+ <ActivationPrompt workspace={params.owner} feature="The security overview" monthlyCents={price} isOwner={owner} />
115+ )}
106116 <div>
107117 <SeverityCountsGrid counts={total} />
108118 <p className="mt-2 text-xs text-faint">
+56−25
3838 import { assertSameOrigin, getViewer, requireUser, roleIn } from "../../lib/session.server";
3939 import { forgetWorkspace } from "../../lib/workspace-choice";
4040 import { confirmsSlug, deletionRefusal, whatGoes } from "../../lib/workspace-deletion";
41+import { Unavailable } from "../../components/boundary";
42+import { isFailed, part, type Failed } from "../../lib/partial";
4143
4244 export function meta({ params, ...args }: Route.MetaArgs) {
4345 return page(args, { title: `Settings · ${params.owner} · g1t` });
6769 }
6870 // What deleting it would take with it, and anything in the way, shown
6971 // before anyone types. Its apps are counted by deployments.
70− let deletion: WorkspaceDeletion | null = null;
72+ // When that cannot be read, Delete says so in its place rather than
73+ // offering itself without saying what would go. How many apps go is only
74+ // a count in that list, which reads without it.
75+ let deletion: WorkspaceDeletion | Failed | null = null;
7176 let apps: number | null = null;
7277 if (viewer) {
7378 const [found, usage] = await Promise.all([
74− identity.checkWorkspaceDeletion(viewer, params.owner).catch(() => null),
79+ part(identity.checkWorkspaceDeletion(viewer, params.owner), "What deleting it would take with it"),
7580 deployments.usage(params.owner, viewer).catch(() => null),
7681 ]);
77− deletion = found?.ok ? found.value : null;
82+ deletion = isFailed(found) ? found : found.ok ? found.value : null;
7883 apps = usage?.ok ? usage.value.apps : null;
7984 }
8085 // Where its repositories are kept: offered once g1t can keep them in the
8186 // EU, and shown to a workspace that chose it whatever happens since.
87+ // Each of these is a section's: one that cannot be read says so in its
88+ // place, rather than show a choice that saving would put over the real
89+ // one, or say no one would be held out when that is not known.
8290 const [storage, residency, members, twoFactor] = await Promise.all([
91+ // Whether the EU is offered: without it, only a workspace that chose it sees the choice.
8392 repos.storageOptions().catch(() => ({ euAvailable: false })),
84− identity.workspaceResidency(params.owner).catch(() => null),
93+ part(identity.workspaceResidency(params.owner), "Data residency"),
8594 // Who would be held out by requiring two-factor authentication.
86− viewer ? identity.listMembers(params.owner, viewer).catch(() => null) : null,
87− viewer ? accounts.twoFactorStatus(viewer).catch(() => null) : null,
95+ viewer ? part(identity.listMembers(params.owner, viewer), "Two-factor authentication") : null,
96+ viewer ? part(accounts.twoFactorStatus(viewer), "Two-factor authentication") : null,
8897 ]);
89− const without = members?.ok ? members.value.filter((member) => member.two_factor === false).map((member) => member.username) : [];
98+ const without = isFailed(members) ? members : members?.ok ? members.value.filter((member) => member.two_factor === false).map((member) => member.username) : [];
9099 return {
91100 workspace,
92101 check,
93102 deletion,
94103 apps,
95104 euAvailable: storage.euAvailable,
96− residency: residency ?? "anywhere",
105+ residency: isFailed(residency) ? residency : (residency ?? "anywhere"),
97106 withoutTwoFactor: without,
98− ownTwoFactor: twoFactor?.ok ? twoFactor.value.enabled : false,
107+ ownTwoFactor: isFailed(twoFactor) ? twoFactor : twoFactor?.ok ? twoFactor.value.enabled : false,
99108 };
100109 }
101110
209218 error={actionData && "renameError" in actionData ? actionData.renameError : undefined}
210219 />
211220
212− <TwoFactorRequirementSection
213− key={String(workspace.twoFactorRequirementEnabled ?? false)}
214− slug={workspace.slug}
215− required={workspace.twoFactorRequirementEnabled ?? false}
216− without={loaderData.withoutTwoFactor}
217− ownTwoFactor={loaderData.ownTwoFactor}
218− saved={Boolean(actionData && "saved" in actionData && actionData.saved === "two-factor-requirement")}
219− error={actionData && "twoFactorError" in actionData ? actionData.twoFactorError : undefined}
220− />
221+ {isFailed(loaderData.withoutTwoFactor) || isFailed(loaderData.ownTwoFactor) ? (
222+ <section id="two-factor" className="scroll-mt-20">
223+ <h2 className="font-medium">Authentication security</h2>
224+ <div className="mt-5">
225+ <Unavailable what="Two-factor authentication" />
226+ </div>
227+ </section>
228+ ) : (
229+ <TwoFactorRequirementSection
230+ key={String(workspace.twoFactorRequirementEnabled ?? false)}
231+ slug={workspace.slug}
232+ required={workspace.twoFactorRequirementEnabled ?? false}
233+ without={loaderData.withoutTwoFactor}
234+ ownTwoFactor={loaderData.ownTwoFactor}
235+ saved={Boolean(actionData && "saved" in actionData && actionData.saved === "two-factor-requirement")}
236+ error={actionData && "twoFactorError" in actionData ? actionData.twoFactorError : undefined}
237+ />
238+ )}
221239
222240 <section>
223241 <h2 className="font-medium">Permissions</h2>
231249 </p>
232250 </section>
233251
234− {(loaderData.euAvailable || loaderData.residency === "eu") && (
252+ {isFailed(loaderData.residency) ? (
253+ <section>
254+ <h2 className="font-medium">Data residency</h2>
255+ <div className="mt-5">
256+ <Unavailable what={loaderData.residency.what} />
257+ </div>
258+ </section>
259+ ) : (loaderData.euAvailable || loaderData.residency === "eu") && (
235260 <ResidencySection
236261 // Starts from the saved choice whenever it changes.
237262 key={loaderData.residency}
243268 )}
244269
245270 <DangerZone>
246− <DeleteAction
247− workspace={workspace}
248− deletion={loaderData.deletion}
249− apps={loaderData.apps}
250− error={actionData && "deleteError" in actionData ? actionData.deleteError : undefined}
251− />
271+ {isFailed(loaderData.deletion) ? (
272+ <div className="p-4">
273+ <Unavailable what={loaderData.deletion.what} compact />
274+ </div>
275+ ) : (
276+ <DeleteAction
277+ workspace={workspace}
278+ deletion={loaderData.deletion}
279+ apps={loaderData.apps}
280+ error={actionData && "deleteError" in actionData ? actionData.deleteError : undefined}
281+ />
282+ )}
252283 </DangerZone>
253284 </div>
254285 );
+7−3
1313 import { teamPath } from "../../../lib/teams";
1414 import { identity, repos } from "../../../lib/services.server";
1515 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../../lib/session.server";
16+import { Unavailable } from "../../../components/boundary";
17+import { isFailed, orElse, part } from "../../../lib/partial";
1618
1719 const isRole = (value: string): value is RepoRole => (REPO_ROLES as readonly string[]).includes(value);
1820
2123 if (!roleIn(viewer, params.owner)) throw data(null, { status: 404 });
2224 const [granted, list] = await Promise.all([
2325 identity.teamRepos(viewer, params.owner, params.team).then(unwrap),
24− repos.list(viewer, { namespace: params.owner, memberOnly: true }).catch(() => []),
26+ // The repositories to add it to: without them, adding says so in its place.
27+ part(repos.list(viewer, { namespace: params.owner, memberOnly: true }), "Repositories to add"),
2528 ]);
26− return { granted, names: list.filter((repo) => !repo.archivedAt).map((repo) => repo.name) };
29+ return { granted, names: isFailed(list) ? list : list.filter((repo) => !repo.archivedAt).map((repo) => repo.name) };
2730 }
2831
2932 export async function action({ request, params, context }: Route.ActionArgs) {
6467 const team = useTeam();
6568 const { granted, names } = loaderData;
6669 const own = new Set(granted.filter((repo) => repo.inherited_from == null).map((repo) => repo.repo.split("/")[1]));
67− const addable = names.filter((name) => !own.has(name));
70+ const addable = orElse(names, []).filter((name) => !own.has(name));
6871 const [role, setRole] = useState<RepoRole>("write");
6972
7073 return (
8891 </Card>
8992 )}
9093
94+ {isFailed(names) && team.can_manage && <Unavailable what={names.what} compact />}
9195 {addable.length > 0 && (
9296 <Card asChild key={granted.length} className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center">
9397 <Form method="post">
+11−1
99 import { page } from "../../lib/meta";
1010 import { billing } from "../../lib/services.server";
1111 import { getViewer, managesBilling, roleIn } from "../../lib/session.server";
12+import { Unavailable } from "../../components/boundary";
1213
1314 /** Where the usage API is documented. */
1415 export const USAGE_API = "https://docs.g1t.sh/reference/api/#usage";
129130 </UsageSkeleton>
130131 }
131132 >
132− <Await resolve={report}>
133+ {/* When billing does not answer, the filters stay and the report says so in its place. */}
134+ <Await
135+ resolve={report}
136+ errorElement={
137+ <div className="space-y-6">
138+ <UsageFilterBar filters={filters} products={PRODUCTS} projects={filters.projects} report={null} apiHref={USAGE_API} />
139+ <Unavailable what="Usage" />
140+ </div>
141+ }
142+ >
133143 {(loaded) => <UsageView slug={slug} owner={owner} filters={filters} url={url} report={loaded.report} error={loaded.error} />}
134144 </Await>
135145 </Suspense>
+10−4
1515 import { Avatar } from "../../components/ui/avatar";
1616 import { Badge } from "../../components/ui/badge";
1717 import { Card } from "../../components/ui/card";
18+import { Unavailable } from "../../components/boundary";
19+import { isFailed, part } from "../../lib/partial";
1820
1921 export function meta({ params, ...args }: Route.MetaArgs) {
2022 return page(args, { title: `Workspace · ${params.owner} · g1t` });
3739 const setupAsked = new URL(request.url).searchParams.has("setup");
3840 const onboarding = loadOnboarding(viewer, slug, !setupAsked).catch(() => null);
3941 const [members, usage, projects] = await Promise.all([
40− identity.listMembers(slug, viewer).catch(() => null),
42+ part(identity.listMembers(slug, viewer), "The member list"),
4143 usageFor(slug, viewer),
4244 workspaceProjects(slug, viewer).catch(() => null),
4345 ]);
4648 role,
4749 onboarding,
4850 setupAsked,
49− members: members?.ok ? members.value : null,
51+ members: isFailed(members) ? members : members.ok ? members.value : null,
5052 usage,
5153 projects: projects?.ok ? projects.value.length : null,
5254 };
7375 <div className="space-y-8">
7476 <OnboardingOnPage slug={slug} view={loaderData.onboarding} asked={loaderData.setupAsked} />
7577 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
76− <Tile icon={<Users size={12} />} label="Members" value={members ? String(members.length) : "—"} to={role === "owner" ? `/${slug}/-/members` : `/${slug}/-/people`} action={role === "owner" ? "Manage people" : "See everyone"} />
78+ <Tile icon={<Users size={12} />} label="Members" value={members && !isFailed(members) ? String(members.length) : "—"} to={role === "owner" ? `/${slug}/-/members` : `/${slug}/-/people`} action={role === "owner" ? "Manage people" : "See everyone"} />
7779 <Tile icon={<Box size={12} />} label="Projects" value={projects == null ? "—" : String(projects)} to={`/${slug}/-/projects`} action="Open Code" />
7880 </div>
7981 <div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_22rem]">
125127 {role === "owner" ? "Manage" : "See all"}
126128 </Link>
127129 </div>
128− {members ? (
130+ {isFailed(members) ? (
131+ <div className="mt-3">
132+ <Unavailable what={members.what} compact />
133+ </div>
134+ ) : members ? (
129135 <>
130136 <ul className="mt-3 space-y-2">
131137 {members.slice(0, MAX_FACES).map((member) => (

Comments

Sign in to comment.