A repository's settings, security and observability pages fail one part at a time, and never say something false because a read failed. Settings: Add team ("No other team to add" was what a failure said); the default branch's rules (a failure said no rules held for it and anyone could push); General's Branches, Artifacts, Features and Commits sections show Unavailable instead of forms that could save over values never read (Branches and retention used to fail the page); Rules' "what holds for a branch" and Insights. Security: the overview's code scanning, policy and advisories rows say Unavailable instead of guessing "None yet" or "Needs setup", and Open alerts says when code scanning is not in its counts; custom patterns (a failure showed the sales prompt); a secret's validity check (a failure said checks were off); whether reporting is on is unknown, not off. Observability: the overview, open errors ("No open errors in this period"), the g1t.page switch, retention, exports, usage, tokens and a metric's chart each say so in place; the lists use Unavailable. Kept quiet on purpose: the creator's name, the deployments-on preview warning, checks seen for offering CI and in a ruleset's suggestions, a security row's fix status, filter facets (a filter in the address still applies). Checked: apps/web tsc -b; npm test (987, 1 skipped).
- G1t-Session:
- ses_01m4zzpc96ect8d41mffgr1a45
17 files+401−2000/17 viewed
| 14 | 14 | import { page } from "../../lib/meta"; | |
| 15 | 15 | import { count, errorIssue, viewHref } from "../../lib/observability"; | |
| 16 | 16 | import { observed, soft } from "../../lib/observability.server"; | |
| 17 | + | import { Unavailable } from "../../components/boundary"; | |
| 17 | 18 | import { work } from "../../lib/services.server"; | |
| 18 | 19 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 19 | 20 | ||
| ⋯ | |||
| 98 | 99 | ||
| 99 | 100 | <div className={cn("mt-2 transition-opacity", loading && "opacity-60")}> | |
| 100 | 101 | {!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 | + | <Unavailable what="Errors" /> | |
| 102 | 103 | ) : groups.length === 0 ? ( | |
| 103 | 104 | <EmptyState title={state === "open" ? "No open errors in this period" : `No ${state} errors in this period`}> | |
| 104 | 105 | Exceptions thrown by apps on g1t.page show here on their own; others arrive as OpenTelemetry exception events and log records. | |
| 15 | 15 | import { matchesLog, viewHref } from "../../lib/observability"; | |
| 16 | 16 | import { useTelemetryLive } from "../../lib/observability-live"; | |
| 17 | 17 | import { observed, soft } from "../../lib/observability.server"; | |
| 18 | + | import { Unavailable } from "../../components/boundary"; | |
| 18 | 19 | ||
| 19 | 20 | /** The levels offered as toggles: trace and debug go with debug, fatal with error. */ | |
| 20 | 21 | const LEVELS: LogSeverity[] = ["debug", "info", "warn", "error"]; | |
| ⋯ | |||
| 125 | 126 | ||
| 126 | 127 | <div className={cn("mt-4 transition-opacity", loading && "opacity-60")}> | |
| 127 | 128 | {!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 | + | <Unavailable what="Logs" /> | |
| 129 | 130 | ) : shown.length === 0 ? ( | |
| 130 | 131 | <EmptyState title={view.q || levels.length ? "No lines match" : "No logs in this period"}> | |
| 131 | 132 | {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."} | |
| 9 | 9 | import { page } from "../../lib/meta"; | |
| 10 | 10 | import { duration, viewHref } from "../../lib/observability"; | |
| 11 | 11 | import { observed, soft } from "../../lib/observability.server"; | |
| 12 | + | import { Unavailable } from "../../components/boundary"; | |
| 13 | + | import { isFailed, part } from "../../lib/partial"; | |
| 12 | 14 | import { PRODUCT_STYLE } from "../../lib/usage"; | |
| 13 | 15 | ||
| 14 | 16 | const AGGREGATES = ["avg", "sum", "min", "max", "p50", "p95", "p99"] as const; | |
| ⋯ | |||
| 26 | 28 | const info = metrics?.find((m) => m.name === name) ?? null; | |
| 27 | 29 | const aggregate = AGGREGATES.find((a) => a === search.get("agg")) ?? (info?.kind === "sum" ? "sum" : "avg"); | |
| 28 | 30 | const groupBy = search.get("by") || null; | |
| 29 | − | const series = name ? await soft(service.metric({ project_id: projectId, ...filter, name, aggregate, group_by: groupBy }), "metric") : null; | |
| 31 | + | // The chosen metric's chart says so in its place when it could not be read. | |
| 32 | + | const series = name ? await part(service.metric({ project_id: projectId, ...filter, name, aggregate, group_by: groupBy }), `${name}'s points`) : null; | |
| 30 | 33 | return { view, metrics, facets, name, info, aggregate, groupBy, series }; | |
| 31 | 34 | } | |
| 32 | 35 | ||
| ⋯ | |||
| 55 | 58 | ||
| 56 | 59 | <div className={cn("mt-4 transition-opacity", loading && "opacity-60")}> | |
| 57 | 60 | {!metrics ? ( | |
| 58 | − | <Card className="p-6 text-sm text-muted">Metrics could not be loaded just now. Reload the page to try again.</Card> | |
| 61 | + | <Unavailable what="Metrics" /> | |
| 59 | 62 | ) : metrics.length === 0 ? ( | |
| 60 | 63 | <EmptyState title="No metrics yet"> | |
| 61 | 64 | Request counts, errors and latency are on the Overview already. Metrics of your own arrive from an OpenTelemetry SDK's metric exporter, | |
| ⋯ | |||
| 114 | 117 | /> | |
| 115 | 118 | </div> | |
| 116 | 119 | )} | |
| 117 | − | {series && ( | |
| 120 | + | {isFailed(series) && <Unavailable what={series.what} />} | |
| 121 | + | {series && !isFailed(series) && ( | |
| 118 | 122 | <TimeChart | |
| 119 | 123 | title={`${series.name} (${aggregate})`} | |
| 120 | 124 | kind="lines" | |
| ⋯ | |||
| 130 | 134 | empty="No points in this period." | |
| 131 | 135 | /> | |
| 132 | 136 | )} | |
| 133 | − | {series && series.series.length > PRODUCT_STYLE.length && ( | |
| 137 | + | {series && !isFailed(series) && series.series.length > PRODUCT_STYLE.length && ( | |
| 134 | 138 | <p className="mt-2 text-xs text-faint">Showing the first {PRODUCT_STYLE.length} of {series.series.length} groups.</p> | |
| 135 | 139 | )} | |
| 136 | 140 | </div> | |
| 10 | 10 | import { page } from "../../lib/meta"; | |
| 11 | 11 | import { clock, duration, shortId, viewHref } from "../../lib/observability"; | |
| 12 | 12 | import { observed, soft } from "../../lib/observability.server"; | |
| 13 | + | import { Unavailable } from "../../components/boundary"; | |
| 13 | 14 | ||
| 14 | 15 | const STATUSES = ["2xx", "3xx", "4xx", "5xx"] as const; | |
| 15 | 16 | ||
| ⋯ | |||
| 94 | 95 | ||
| 95 | 96 | <div className={cn("mt-4 transition-opacity", loading && "opacity-60")}> | |
| 96 | 97 | {!list ? ( | |
| 97 | − | <Card className="p-6 text-sm text-muted">Requests could not be loaded just now. Reload the page to try again.</Card> | |
| 98 | + | <Unavailable what="Requests" /> | |
| 98 | 99 | ) : list.items.length === 0 ? ( | |
| 99 | 100 | <EmptyState title={cursor ? "No older requests" : "No requests match"}> | |
| 100 | 101 | Requests of apps on g1t.page arrive within seconds of being answered; others arrive with their server spans. | |
| 30 | 30 | import { Textarea } from "../../components/ui/textarea"; | |
| 31 | 31 | import { page } from "../../lib/meta"; | |
| 32 | 32 | import { bytes, count, otelEnv } from "../../lib/observability"; | |
| 33 | − | import { observed, soft } from "../../lib/observability.server"; | |
| 33 | + | import { observed } from "../../lib/observability.server"; | |
| 34 | + | import { Unavailable } from "../../components/boundary"; | |
| 35 | + | import { isFailed, part } from "../../lib/partial"; | |
| 34 | 36 | import { assertSameOrigin, requireUser } from "../../lib/session.server"; | |
| 35 | 37 | ||
| 36 | 38 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| ⋯ | |||
| 41 | 43 | const { projectId, service, project } = await observed(context, params, request, "manage_integrations"); | |
| 42 | 44 | // The workspace goes along, so the project's store knows whose free cap applies. | |
| 43 | 45 | const ref = { project_id: projectId, workspace: (params.owner ?? "").toLowerCase() }; | |
| 46 | + | // Each is a section of its own, which says so in its place when it could | |
| 47 | + | // not be read; a form whose values could not be read is not shown, so | |
| 48 | + | // nothing is saved over them. | |
| 44 | 49 | const [settings, tokens, exports, usage] = await Promise.all([ | |
| 45 | − | soft(service.settings(ref), "settings"), | |
| 46 | − | soft(service.tokens(ref), "tokens"), | |
| 47 | − | soft(service.exports(ref), "exports"), | |
| 48 | − | soft(service.usage(ref), "usage"), | |
| 50 | + | part(service.settings(ref), "These settings"), | |
| 51 | + | part(service.tokens(ref), "Ingest tokens"), | |
| 52 | + | part(service.exports(ref), "Exports"), | |
| 53 | + | part(service.usage(ref), "This month's usage"), | |
| 49 | 54 | ]); | |
| 50 | 55 | return { settings, tokens, exports, usage, slug: project.slug }; | |
| 51 | 56 | } | |
| ⋯ | |||
| 132 | 137 | <CopyLine text={TELEMETRY_OTLP_ENDPOINT} /> | |
| 133 | 138 | <pre className="overflow-x-auto rounded-lg border border-line bg-surface p-3 font-mono text-xs">{otelEnv(TELEMETRY_OTLP_ENDPOINT, slug, "g1to_…")}</pre> | |
| 134 | 139 | </div> | |
| 135 | − | {settings && ( | |
| 140 | + | {isFailed(settings) ? ( | |
| 141 | + | <div className="mt-4"> | |
| 142 | + | <Unavailable what="Whether apps on g1t.page report here" compact /> | |
| 143 | + | </div> | |
| 144 | + | ) : ( | |
| 136 | 145 | <Form method="post" className="mt-4"> | |
| 137 | 146 | <SwitchCard | |
| 138 | 147 | title="Apps on g1t.page report here" | |
| ⋯ | |||
| 157 | 166 | For software running anywhere other than g1t.page: a token lets it send to this project and do nothing else. Each is shown once, when | |
| 158 | 167 | it is made. Apps on g1t.page need none; g1t gives them their own. | |
| 159 | 168 | </p> | |
| 160 | − | {!tokens ? ( | |
| 161 | − | <Card className="mt-3 p-4 text-sm text-muted">Tokens could not be loaded just now.</Card> | |
| 169 | + | {isFailed(tokens) ? ( | |
| 170 | + | <div className="mt-3"> | |
| 171 | + | <Unavailable what={tokens.what} /> | |
| 172 | + | </div> | |
| 162 | 173 | ) : tokens.length === 0 ? ( | |
| 163 | 174 | <Card className="mt-3 p-4 text-sm text-muted">None yet.</Card> | |
| 164 | 175 | ) : ( | |
| ⋯ | |||
| 222 | 233 | Send everything that arrives on to any OTLP/HTTP endpoint as well: a collector of your own, or any tool that takes OpenTelemetry. | |
| 223 | 234 | Requests on g1t.page go as server spans, their logs as log records. | |
| 224 | 235 | </p> | |
| 225 | − | {exports && exports.length > 0 && ( | |
| 236 | + | {isFailed(exports) && ( | |
| 237 | + | <div className="mt-3"> | |
| 238 | + | <Unavailable what={exports.what} /> | |
| 239 | + | </div> | |
| 240 | + | )} | |
| 241 | + | {!isFailed(exports) && exports.length > 0 && ( | |
| 226 | 242 | <Card className="mt-3 divide-y divide-line"> | |
| 227 | 243 | {exports.map((e) => ( | |
| 228 | 244 | <div key={e.id} className="flex flex-wrap items-center justify-between gap-3 px-4 py-3"> | |
| ⋯ | |||
| 277 | 293 | )} | |
| 278 | 294 | </section> | |
| 279 | 295 | ||
| 280 | − | {settings && ( | |
| 281 | − | <section className="mt-10" aria-labelledby="retention-title"> | |
| 282 | − | <h2 id="retention-title" className="text-sm font-semibold"> | |
| 283 | − | Retention | |
| 284 | − | </h2> | |
| 285 | − | <p className="mt-1 text-sm text-muted"> | |
| 286 | − | How long requests, logs, spans and metric points are kept. The Overview's per-minute numbers are kept for 90 days whatever this is. | |
| 287 | − | </p> | |
| 296 | + | <section className="mt-10" aria-labelledby="retention-title"> | |
| 297 | + | <h2 id="retention-title" className="text-sm font-semibold"> | |
| 298 | + | Retention | |
| 299 | + | </h2> | |
| 300 | + | <p className="mt-1 text-sm text-muted"> | |
| 301 | + | How long requests, logs, spans and metric points are kept. The Overview's per-minute numbers are kept for 90 days whatever this is. | |
| 302 | + | </p> | |
| 303 | + | {isFailed(settings) ? ( | |
| 304 | + | <div className="mt-3"> | |
| 305 | + | <Unavailable what="The retention" /> | |
| 306 | + | </div> | |
| 307 | + | ) : ( | |
| 288 | 308 | <Form method="post" className="mt-3 flex items-center gap-2"> | |
| 289 | 309 | <input type="hidden" name="intent" value="retention" /> | |
| 290 | 310 | <SelectField | |
| ⋯ | |||
| 298 | 318 | Save | |
| 299 | 319 | </SubmitButton> | |
| 300 | 320 | </Form> | |
| 301 | − | </section> | |
| 302 | − | )} | |
| 321 | + | )} | |
| 322 | + | </section> | |
| 303 | 323 | ||
| 304 | − | {usage && ( | |
| 305 | − | <section className="mt-10" aria-labelledby="usage-title"> | |
| 306 | − | <h2 id="usage-title" className="text-sm font-semibold"> | |
| 307 | − | This month | |
| 308 | − | </h2> | |
| 324 | + | <section className="mt-10" aria-labelledby="usage-title"> | |
| 325 | + | <h2 id="usage-title" className="text-sm font-semibold"> | |
| 326 | + | This month | |
| 327 | + | </h2> | |
| 328 | + | {isFailed(usage) ? ( | |
| 329 | + | <div className="mt-3"> | |
| 330 | + | <Unavailable what={usage.what} /> | |
| 331 | + | </div> | |
| 332 | + | ) : ( | |
| 309 | 333 | <Card className="mt-3 grid grid-cols-2 gap-4 p-4 sm:grid-cols-3"> | |
| 310 | 334 | {( | |
| 311 | 335 | [ | |
| ⋯ | |||
| 324 | 348 | </div> | |
| 325 | 349 | ))} | |
| 326 | 350 | </Card> | |
| 327 | − | </section> | |
| 328 | − | )} | |
| 351 | + | )} | |
| 352 | + | </section> | |
| 329 | 353 | </div> | |
| 330 | 354 | ); | |
| 331 | 355 | } | |
| 10 | 10 | import { page } from "../../lib/meta"; | |
| 11 | 11 | import { clock, duration, viewHref } from "../../lib/observability"; | |
| 12 | 12 | import { observed, soft } from "../../lib/observability.server"; | |
| 13 | + | import { Unavailable } from "../../components/boundary"; | |
| 13 | 14 | ||
| 14 | 15 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 15 | 16 | return page(args, { title: `Traces · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 77 | 78 | ||
| 78 | 79 | <div className={cn("mt-4 transition-opacity", loading && "opacity-60")}> | |
| 79 | 80 | {!list ? ( | |
| 80 | − | <Card className="p-6 text-sm text-muted">Traces could not be loaded just now. Reload the page to try again.</Card> | |
| 81 | + | <Unavailable what="Traces" /> | |
| 81 | 82 | ) : list.items.length === 0 ? ( | |
| 82 | 83 | <EmptyState title={cursor ? "No older traces" : "No traces in this period"}> | |
| 83 | 84 | Traces come from OpenTelemetry spans. An app on g1t.page sends them as soon as it uses an OpenTelemetry SDK: its endpoint and token | |
| 13 | 13 | import { page } from "../../lib/meta"; | |
| 14 | 14 | import { SERIES, count, duration, percent, shortId, viewHref } from "../../lib/observability"; | |
| 15 | 15 | import { observed, soft } from "../../lib/observability.server"; | |
| 16 | + | import { Unavailable } from "../../components/boundary"; | |
| 17 | + | import { isFailed, part } from "../../lib/partial"; | |
| 16 | 18 | ||
| 17 | 19 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 18 | 20 | return page(args, { title: `Observability · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 20 | 22 | ||
| 21 | 23 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 22 | 24 | const { projectId, service, view, filter, access } = await observed(context, params, request); | |
| 25 | + | // The overview and its open errors each say so in their place when they | |
| 26 | + | // could not be read. The filter's choices quietly go without (soft): a | |
| 27 | + | // filter in the address still applies. | |
| 23 | 28 | const [overview, facets, errors] = await Promise.all([ | |
| 24 | − | soft(service.overview({ project_id: projectId, ...filter }), "overview"), | |
| 29 | + | part(service.overview({ project_id: projectId, ...filter }), "Observability"), | |
| 25 | 30 | soft(service.facets({ project_id: projectId }), "facets"), | |
| 26 | − | soft(service.errors({ project_id: projectId, ...filter, state: "open" }), "errors"), | |
| 31 | + | part(service.errors({ project_id: projectId, ...filter, state: "open" }), "Open errors"), | |
| 27 | 32 | ]); | |
| 28 | − | return { view, overview, facets, errors: errors?.slice(0, 5) ?? null, canManage: access.can.manage_integrations }; | |
| 33 | + | return { view, overview, facets, errors: isFailed(errors) ? errors : errors.slice(0, 5), canManage: access.can.manage_integrations }; | |
| 29 | 34 | } | |
| 30 | 35 | ||
| 31 | 36 | export default function Observability({ loaderData, params }: Route.ComponentProps) { | |
| ⋯ | |||
| 33 | 38 | const base = `/${params.owner}/${params.repo}`; | |
| 34 | 39 | const path = `${base}/observability`; | |
| 35 | 40 | const loading = useLoadingView(); | |
| 36 | − | const nothing = overview && overview.totals.requests === 0 && overview.services.length === 0 && (facets?.services.length ?? 0) === 0; | |
| 41 | + | const nothing = !isFailed(overview) && overview.totals.requests === 0 && overview.services.length === 0 && (facets?.services.length ?? 0) === 0; | |
| 37 | 42 | ||
| 38 | 43 | return ( | |
| 39 | 44 | <div className="mx-auto max-w-6xl"> | |
| ⋯ | |||
| 44 | 49 | <FilterBar path={path} view={view} environments={facets?.environments ?? []} services={facets?.services ?? []} /> | |
| 45 | 50 | ||
| 46 | 51 | <div className={cn("mt-6 transition-opacity", loading && "opacity-60")}> | |
| 47 | − | {!overview ? ( | |
| 48 | − | <Card className="p-6 text-sm text-muted">Observability could not be loaded just now. Reload the page to try again.</Card> | |
| 52 | + | {isFailed(overview) ? ( | |
| 53 | + | <Unavailable what={overview.what} /> | |
| 49 | 54 | ) : nothing ? ( | |
| 50 | 55 | <NothingYet base={base} canManage={canManage} /> | |
| 51 | 56 | ) : ( | |
| ⋯ | |||
| 187 | 192 | All <ArrowRight size={12} className="inline" /> | |
| 188 | 193 | </Link> | |
| 189 | 194 | </div> | |
| 190 | − | {!errors || errors.length === 0 ? ( | |
| 195 | + | {isFailed(errors) ? ( | |
| 196 | + | <div className="px-4 py-4"> | |
| 197 | + | <Unavailable what={errors.what} compact /> | |
| 198 | + | </div> | |
| 199 | + | ) : errors.length === 0 ? ( | |
| 191 | 200 | <p className="px-4 py-6 text-sm text-muted">No open errors in this period.</p> | |
| 192 | 201 | ) : ( | |
| 193 | 202 | <ul className="divide-y divide-line"> | |
| 19 | 19 | import { advisories } from "../../lib/services.server"; | |
| 20 | 20 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 21 | 21 | import { requireRepo } from "../../lib/access.server"; | |
| 22 | + | import { orElse, part } from "../../lib/partial"; | |
| 22 | 23 | import { ADVISORY_STATES, STATE_LABEL, STATE_TONE, advisoryStanding, parseAdvisoryState } from "../../lib/advisories"; | |
| 23 | 24 | ||
| 24 | 25 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| ⋯ | |||
| 30 | 31 | const { access } = await requireRepo(context, params, "read"); | |
| 31 | 32 | const path = { namespace: params.owner, name: params.repo }; | |
| 32 | 33 | const insider = Boolean(access.insider && access.can.security_alerts); | |
| 34 | + | // How many reports are new is only a count on the Reports link, which | |
| 35 | + | // goes without it when it cannot be read: the Reports page has them all. | |
| 33 | 36 | const [list, reports] = await Promise.all([ | |
| 34 | 37 | advisories.list(path, viewer), | |
| 35 | − | insider ? advisories.reports(path, viewer, "new") : Promise.resolve(null), | |
| 38 | + | insider ? part(advisories.reports(path, viewer, "new"), "New reports").then((found) => orElse(found, null)) : Promise.resolve(null), | |
| 36 | 39 | ]); | |
| 37 | 40 | return { | |
| 38 | 41 | advisories: unwrap(list), | |
| 31 | 31 | import { SEVERITY_LABEL } from "../../lib/advisories"; | |
| 32 | 32 | import { repoPages } from "../../lib/repo-live"; | |
| 33 | 33 | import { useRepoLive } from "../../lib/use-repo-live"; | |
| 34 | + | import { Unavailable } from "../../components/boundary"; | |
| 35 | + | import { type Failed, isFailed, part } from "../../lib/partial"; | |
| 34 | 36 | ||
| 35 | 37 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 36 | 38 | return page(args, { title: `Security · ${params.owner}/${params.repo} · g1t` }); | |
| 37 | 39 | } | |
| 38 | 40 | ||
| 39 | − | /** The security policy on the default branch, if any: the first of the places it may be. */ | |
| 40 | − | async function policyPath(path: { namespace: string; name: string }, viewer: ReturnType<typeof getViewer>, defaultBranch: string): Promise<string | null> { | |
| 41 | − | const found = await Promise.all(POLICY_PATHS.map((file) => repos.blob(path, viewer, defaultBranch, file).catch(() => null))); | |
| 42 | − | return POLICY_PATHS.find((_, at) => found[at]?.ok) ?? null; | |
| 41 | + | /** | |
| 42 | + | * The security policy on the default branch, if any: the first of the | |
| 43 | + | * places it may be. When none was found but a place could not be read, it | |
| 44 | + | * is not known to be missing: Failed, so the row does not say "None yet". | |
| 45 | + | */ | |
| 46 | + | async function policyPath( | |
| 47 | + | path: { namespace: string; name: string }, | |
| 48 | + | viewer: ReturnType<typeof getViewer>, | |
| 49 | + | defaultBranch: string, | |
| 50 | + | ): Promise<string | null | Failed> { | |
| 51 | + | const found = await Promise.all(POLICY_PATHS.map((file) => part(repos.blob(path, viewer, defaultBranch, file), "The security policy"))); | |
| 52 | + | const at = POLICY_PATHS.find((_, index) => { | |
| 53 | + | const one = found[index]; | |
| 54 | + | return !isFailed(one) && one.ok; | |
| 55 | + | }); | |
| 56 | + | return at ?? found.find(isFailed) ?? null; | |
| 43 | 57 | } | |
| 44 | 58 | ||
| 45 | 59 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| ⋯ | |||
| 52 | 66 | const path = { namespace: params.owner, name: params.repo }; | |
| 53 | 67 | if (!access.insider || !access.can.security_alerts) { | |
| 54 | 68 | // The public face: for anyone who can see the repository. | |
| 69 | + | // Each is a row or a section of its own, which says so when it could not be read. | |
| 55 | 70 | const [policy, published, reporting] = await Promise.all([ | |
| 56 | 71 | policyPath(path, viewer, repo.defaultBranch), | |
| 57 | − | advisories.list(path, viewer, "published"), | |
| 58 | − | advisories.reportSettings(path, viewer), | |
| 72 | + | part(advisories.list(path, viewer, "published"), "Published advisories"), | |
| 73 | + | part(advisories.reportSettings(path, viewer), "Private vulnerability reporting"), | |
| 59 | 74 | ]); | |
| 60 | 75 | return { | |
| 61 | 76 | kind: "public" as const, | |
| 62 | 77 | policy, | |
| 63 | − | advisories: published.ok ? published.value : [], | |
| 64 | − | reporting: reporting.ok && reporting.value.privateVulnerabilityReporting, | |
| 78 | + | advisories: isFailed(published) ? published : published.ok ? published.value : [], | |
| 79 | + | reporting: isFailed(reporting) ? reporting : reporting.ok && reporting.value.privateVulnerabilityReporting, | |
| 65 | 80 | signedIn: Boolean(viewer), | |
| 66 | 81 | }; | |
| 67 | 82 | } | |
| 83 | + | // The alerts and the settings are what the overview is about; code | |
| 84 | + | // scanning, the policy, the advisories and the reports are a row or a | |
| 85 | + | // count each, which says so in its place when it could not be read. | |
| 68 | 86 | const [overview, code, view, policy, published, reports] = await Promise.all([ | |
| 69 | 87 | security.overview(path, viewer), | |
| 70 | − | securitySuite.codeScanning(path, viewer), | |
| 88 | + | part(securitySuite.codeScanning(path, viewer), "Code scanning"), | |
| 71 | 89 | securitySuite.settings(path, viewer), | |
| 72 | 90 | policyPath(path, viewer, repo.defaultBranch), | |
| 73 | − | advisories.list(path, viewer, "published"), | |
| 74 | − | advisories.reports(path, viewer, "new"), | |
| 91 | + | part(advisories.list(path, viewer, "published"), "Security advisories"), | |
| 92 | + | part(advisories.reports(path, viewer, "new"), "New vulnerability reports"), | |
| 75 | 93 | ]); | |
| 76 | 94 | const settings = unwrap(view); | |
| 95 | + | const scanning = isFailed(code) ? null : code; | |
| 96 | + | // Rows whose facts could not be read, by key, and what they are. | |
| 97 | + | const unavailable: Record<string, string> = {}; | |
| 98 | + | if (isFailed(code)) unavailable.code_scanning = code.what; | |
| 99 | + | if (isFailed(policy)) unavailable.policy = policy.what; | |
| 100 | + | if (isFailed(published)) unavailable.advisories = published.what; | |
| 77 | 101 | return { | |
| 78 | 102 | kind: "insider" as const, | |
| 79 | 103 | overview: unwrap(overview), | |
| 80 | − | code: code.ok ? code.value : null, | |
| 104 | + | code: scanning?.ok ? scanning.value : null, | |
| 105 | + | codeFailed: isFailed(code), | |
| 81 | 106 | rows: overviewRows({ | |
| 82 | 107 | base, | |
| 83 | − | policyPath: policy, | |
| 108 | + | policyPath: isFailed(policy) ? null : policy, | |
| 84 | 109 | reporting: settings.settings.privateVulnerabilityReporting, | |
| 85 | − | advisories: published.ok ? published.value.length : 0, | |
| 110 | + | advisories: !isFailed(published) && published.ok ? published.value.length : 0, | |
| 86 | 111 | upkeep: settings.upkeep, | |
| 87 | − | codeScanning: { configured: code.ok ? code.value.configured : false, analyses: code.ok ? code.value.analyses.length : 0, entitled: settings.entitled }, | |
| 112 | + | codeScanning: { | |
| 113 | + | configured: scanning?.ok ? scanning.value.configured : false, | |
| 114 | + | analyses: scanning?.ok ? scanning.value.analyses.length : 0, | |
| 115 | + | entitled: settings.entitled, | |
| 116 | + | }, | |
| 88 | 117 | historyAlerts: settings.settings.historyAlerts, | |
| 89 | 118 | canManage: Boolean(access.can.manage_settings), | |
| 90 | 119 | canPush: Boolean(access.can.push), | |
| 91 | 120 | }), | |
| 92 | − | newReports: reports.ok ? reports.value.length : 0, | |
| 121 | + | unavailable, | |
| 122 | + | newReports: isFailed(reports) ? reports : reports.ok ? reports.value.length : 0, | |
| 93 | 123 | can: access.can, | |
| 94 | 124 | }; | |
| 95 | 125 | } | |
| ⋯ | |||
| 157 | 187 | ); | |
| 158 | 188 | } | |
| 159 | 189 | ||
| 160 | − | function FeatureRows({ title, rows, action, canManage }: { title: string; rows: OverviewRow[]; action: string; canManage: boolean }) { | |
| 190 | + | function FeatureRows({ | |
| 191 | + | title, | |
| 192 | + | rows, | |
| 193 | + | action, | |
| 194 | + | canManage, | |
| 195 | + | unavailable, | |
| 196 | + | }: { | |
| 197 | + | title: string; | |
| 198 | + | rows: OverviewRow[]; | |
| 199 | + | action: string; | |
| 200 | + | canManage: boolean; | |
| 201 | + | /** Rows whose facts could not be read: each says so in place of its status. */ | |
| 202 | + | unavailable: Record<string, string>; | |
| 203 | + | }) { | |
| 161 | 204 | return ( | |
| 162 | 205 | <section aria-labelledby={`rows-${title}`} className="space-y-2"> | |
| 163 | 206 | <h3 id={`rows-${title}`} className="text-sm font-semibold"> | |
| ⋯ | |||
| 165 | 208 | </h3> | |
| 166 | 209 | <Card asChild divided> | |
| 167 | 210 | <ul> | |
| 168 | − | {rows.map((row) => ( | |
| 169 | − | <li key={row.key} className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-6"> | |
| 170 | − | <div className="min-w-0 grow"> | |
| 171 | − | <div className="flex flex-wrap items-center gap-2"> | |
| 172 | − | <span className="text-sm font-medium">{row.title}</span> | |
| 173 | − | <Hint label={row.hint}> | |
| 174 | − | <Badge tone={STATUS_TONE[row.status]}>{STATUS_LABEL[row.status]}</Badge> | |
| 175 | − | </Hint> | |
| 211 | + | {rows.map((row) => | |
| 212 | + | unavailable[row.key] ? ( | |
| 213 | + | <UnavailableRow key={row.key} title={row.title} what={unavailable[row.key]} /> | |
| 214 | + | ) : ( | |
| 215 | + | <li key={row.key} className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-6"> | |
| 216 | + | <div className="min-w-0 grow"> | |
| 217 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 218 | + | <span className="text-sm font-medium">{row.title}</span> | |
| 219 | + | <Hint label={row.hint}> | |
| 220 | + | <Badge tone={STATUS_TONE[row.status]}>{STATUS_LABEL[row.status]}</Badge> | |
| 221 | + | </Hint> | |
| 222 | + | </div> | |
| 223 | + | <p className="mt-1 text-sm text-muted">{row.about}</p> | |
| 176 | 224 | </div> | |
| 177 | − | <p className="mt-1 text-sm text-muted">{row.about}</p> | |
| 178 | − | </div> | |
| 179 | − | <div className="shrink-0 sm:text-right"> | |
| 180 | − | <RowAction row={row} action={action} canManage={canManage} /> | |
| 181 | − | </div> | |
| 182 | − | </li> | |
| 183 | − | ))} | |
| 225 | + | <div className="shrink-0 sm:text-right"> | |
| 226 | + | <RowAction row={row} action={action} canManage={canManage} /> | |
| 227 | + | </div> | |
| 228 | + | </li> | |
| 229 | + | ), | |
| 230 | + | )} | |
| 184 | 231 | </ul> | |
| 185 | 232 | </Card> | |
| 186 | 233 | </section> | |
| 187 | 234 | ); | |
| 188 | 235 | } | |
| 189 | 236 | ||
| 237 | + | /** A row whose status could not be read: its title, and that, in place of a status that would be a guess. */ | |
| 238 | + | function UnavailableRow({ title, what }: { title: string; what: string }) { | |
| 239 | + | return ( | |
| 240 | + | <li className="space-y-1 p-4"> | |
| 241 | + | <span className="text-sm font-medium">{title}</span> | |
| 242 | + | <Unavailable what={what} compact /> | |
| 243 | + | </li> | |
| 244 | + | ); | |
| 245 | + | } | |
| 246 | + | ||
| 190 | 247 | function AdvisoryLine({ advisory, base }: { advisory: Advisory; base: string }) { | |
| 191 | 248 | return ( | |
| 192 | 249 | <li className="flex items-center gap-3 px-4 py-3"> | |
| ⋯ | |||
| 216 | 273 | title="Security" | |
| 217 | 274 | about="How to report a vulnerability in this repository, and what its maintainers have published about vulnerabilities in what it ships." | |
| 218 | 275 | actions={ | |
| 219 | − | reporting ? ( | |
| 276 | + | reporting === true ? ( | |
| 220 | 277 | <ButtonLink to={`${base}/security/report`} prefetch="intent"> | |
| 221 | 278 | Report a vulnerability | |
| 222 | 279 | </ButtonLink> | |
| ⋯ | |||
| 225 | 282 | /> | |
| 226 | 283 | <Card asChild divided> | |
| 227 | 284 | <ul> | |
| 228 | − | <li className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-6"> | |
| 229 | − | <div className="min-w-0 grow"> | |
| 230 | − | <div className="flex flex-wrap items-center gap-2"> | |
| 231 | − | <span className="text-sm font-medium">Security policy</span> | |
| 232 | − | <Badge tone={policy ? "success" : "neutral"}>{policy ? "Published" : "None yet"}</Badge> | |
| 285 | + | {isFailed(policy) ? ( | |
| 286 | + | <UnavailableRow title="Security policy" what={policy.what} /> | |
| 287 | + | ) : ( | |
| 288 | + | <li className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-6"> | |
| 289 | + | <div className="min-w-0 grow"> | |
| 290 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 291 | + | <span className="text-sm font-medium">Security policy</span> | |
| 292 | + | <Badge tone={policy ? "success" : "neutral"}>{policy ? "Published" : "None yet"}</Badge> | |
| 293 | + | </div> | |
| 294 | + | <p className="mt-1 text-sm text-muted"> | |
| 295 | + | {policy ? `${policy} says how to report a vulnerability and which versions are supported.` : "This repository has not published a security policy."} | |
| 296 | + | </p> | |
| 233 | 297 | </div> | |
| 234 | − | <p className="mt-1 text-sm text-muted"> | |
| 235 | − | {policy ? `${policy} says how to report a vulnerability and which versions are supported.` : "This repository has not published a security policy."} | |
| 236 | − | </p> | |
| 237 | − | </div> | |
| 238 | − | {policy && ( | |
| 239 | − | <ButtonLink to={`${base}/blob/HEAD/${policy}`} variant="outline" size="sm"> | |
| 240 | − | View policy | |
| 241 | − | </ButtonLink> | |
| 242 | − | )} | |
| 243 | − | </li> | |
| 244 | − | <li className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-6"> | |
| 245 | − | <div className="min-w-0 grow"> | |
| 246 | − | <div className="flex flex-wrap items-center gap-2"> | |
| 247 | − | <span className="text-sm font-medium">Private vulnerability reporting</span> | |
| 248 | − | <Badge tone={reporting ? "success" : "neutral"}>{reporting ? "Enabled" : "Disabled"}</Badge> | |
| 298 | + | {policy && ( | |
| 299 | + | <ButtonLink to={`${base}/blob/HEAD/${policy}`} variant="outline" size="sm"> | |
| 300 | + | View policy | |
| 301 | + | </ButtonLink> | |
| 302 | + | )} | |
| 303 | + | </li> | |
| 304 | + | )} | |
| 305 | + | {isFailed(reporting) ? ( | |
| 306 | + | <UnavailableRow title="Private vulnerability reporting" what={reporting.what} /> | |
| 307 | + | ) : ( | |
| 308 | + | <li className="flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:gap-6"> | |
| 309 | + | <div className="min-w-0 grow"> | |
| 310 | + | <div className="flex flex-wrap items-center gap-2"> | |
| 311 | + | <span className="text-sm font-medium">Private vulnerability reporting</span> | |
| 312 | + | <Badge tone={reporting ? "success" : "neutral"}>{reporting ? "Enabled" : "Disabled"}</Badge> | |
| 313 | + | </div> | |
| 314 | + | <p className="mt-1 text-sm text-muted"> | |
| 315 | + | {reporting | |
| 316 | + | ? signedIn | |
| 317 | + | ? "Tell the maintainers privately, under your name. Only they see the report." | |
| 318 | + | : "Tell the maintainers privately. You can report without an account; leave an email so they can answer you." | |
| 319 | + | : "This repository does not take private reports here. Its security policy, if any, says how to reach the maintainers."} | |
| 320 | + | </p> | |
| 249 | 321 | </div> | |
| 250 | − | <p className="mt-1 text-sm text-muted"> | |
| 251 | − | {reporting | |
| 252 | − | ? signedIn | |
| 253 | − | ? "Tell the maintainers privately, under your name. Only they see the report." | |
| 254 | − | : "Tell the maintainers privately. You can report without an account; leave an email so they can answer you." | |
| 255 | − | : "This repository does not take private reports here. Its security policy, if any, says how to reach the maintainers."} | |
| 256 | − | </p> | |
| 257 | − | </div> | |
| 258 | − | </li> | |
| 322 | + | </li> | |
| 323 | + | )} | |
| 259 | 324 | </ul> | |
| 260 | 325 | </Card> | |
| 261 | 326 | <section aria-labelledby="public-advisories" className="space-y-2"> | |
| ⋯ | |||
| 263 | 328 | <h3 id="public-advisories" className="text-sm font-semibold"> | |
| 264 | 329 | Security advisories | |
| 265 | 330 | </h3> | |
| 266 | − | {published.length > 0 && ( | |
| 331 | + | {!isFailed(published) && published.length > 0 && ( | |
| 267 | 332 | <Link to={`${base}/security/advisories`} className="text-sm text-muted underline underline-offset-2 hover:text-fg"> | |
| 268 | 333 | All advisories | |
| 269 | 334 | </Link> | |
| 270 | 335 | )} | |
| 271 | 336 | </div> | |
| 272 | − | {published.length === 0 ? ( | |
| 337 | + | {isFailed(published) ? ( | |
| 338 | + | <Unavailable what={published.what} /> | |
| 339 | + | ) : published.length === 0 ? ( | |
| 273 | 340 | <EmptyState title="No published advisories">The maintainers have not published any security advisories for this repository.</EmptyState> | |
| 274 | 341 | ) : ( | |
| 275 | 342 | <Card asChild divided> | |
| ⋯ | |||
| 284 | 351 | </div> | |
| 285 | 352 | ); | |
| 286 | 353 | } | |
| 287 | − | const { overview, code, rows, newReports, can } = loaderData; | |
| 354 | + | const { overview, code, codeFailed, rows, unavailable, newReports, can } = loaderData; | |
| 288 | 355 | const codeOpen = code ? severityCounts(code.alerts) : null; | |
| 289 | 356 | // Open alerts of every kind, by severity. | |
| 290 | 357 | const counts = { ...overview.counts }; | |
| ⋯ | |||
| 297 | 364 | title="Security" | |
| 298 | 365 | about="What keeps this repository safe, and where each part stands. Pushes that add a secret are refused before they land, every package the lockfiles resolve is checked for known vulnerabilities, code scanning reads your tools' results, and the maintainers publish advisories and take private reports." | |
| 299 | 366 | actions={ | |
| 300 | − | newReports > 0 ? ( | |
| 367 | + | isFailed(newReports) ? ( | |
| 368 | + | <Unavailable what={newReports.what} compact /> | |
| 369 | + | ) : newReports > 0 ? ( | |
| 301 | 370 | <ButtonLink to={`${base}/security/reports`} variant="outline" size="sm" prefetch="intent"> | |
| 302 | 371 | {newReports === 1 ? "1 new vulnerability report" : `${newReports} new vulnerability reports`} | |
| 303 | 372 | </ButtonLink> | |
| 304 | 373 | ) : null | |
| 305 | 374 | } | |
| 306 | 375 | /> | |
| 307 | − | <FeatureRows title="Policy" rows={rowsIn(rows, "policy")} action={action} canManage={canManage} /> | |
| 308 | − | <FeatureRows title="Findings" rows={rowsIn(rows, "findings")} action={action} canManage={canManage} /> | |
| 309 | − | <FeatureRows title="Reporting" rows={rowsIn(rows, "reporting")} action={action} canManage={canManage} /> | |
| 376 | + | <FeatureRows title="Policy" rows={rowsIn(rows, "policy")} action={action} canManage={canManage} unavailable={unavailable} /> | |
| 377 | + | <FeatureRows title="Findings" rows={rowsIn(rows, "findings")} action={action} canManage={canManage} unavailable={unavailable} /> | |
| 378 | + | <FeatureRows title="Reporting" rows={rowsIn(rows, "reporting")} action={action} canManage={canManage} unavailable={unavailable} /> | |
| 310 | 379 | <section aria-labelledby="open-alerts" className="space-y-3"> | |
| 311 | 380 | <h3 id="open-alerts" className="flex items-center gap-2 text-sm font-semibold"> | |
| 312 | 381 | <ShieldCheck size={15} className="text-accent" /> | |
| 313 | 382 | Open alerts | |
| 314 | 383 | </h3> | |
| 315 | 384 | <SeverityCountsGrid counts={counts} /> | |
| 385 | + | {/* Without code scanning's alerts, the counts above are of the rest. */} | |
| 386 | + | {codeFailed && <Unavailable what="Code scanning alerts" compact />} | |
| 316 | 387 | <p className="text-xs text-faint"> | |
| 317 | 388 | Open alerts of every kind by severity ({SEVERITY_LABEL.critical.toLowerCase()} to {SEVERITY_LABEL.low.toLowerCase()}). A secret in the history | |
| 318 | 389 | that looks real counts as critical; blocked pushes and likely test values do not. | |
| 22 | 22 | import { advisories } from "../../lib/services.server"; | |
| 23 | 23 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 24 | 24 | import { refusal, requireInsider } from "../../lib/access.server"; | |
| 25 | + | import { isFailed, part } from "../../lib/partial"; | |
| 25 | 26 | import { whyNot } from "../../lib/access"; | |
| 26 | 27 | import { REPORT_STATES, REPORT_STATE_LABEL, REPORT_STATE_TONE, parseReportState } from "../../lib/advisories"; | |
| 27 | 28 | ||
| ⋯ | |||
| 33 | 34 | const viewer = getViewer(context) ?? requireUser(context, request); | |
| 34 | 35 | const { access } = await requireInsider(context, params, "security_alerts"); | |
| 35 | 36 | const path = { namespace: params.owner, name: params.repo }; | |
| 36 | − | const [reports, settings] = await Promise.all([advisories.reports(path, viewer), advisories.reportSettings(path, viewer)]); | |
| 37 | − | return { reports: unwrap(reports), reporting: settings.ok && settings.value.privateVulnerabilityReporting, can: access.can }; | |
| 37 | + | // The reports are what the page is about. Whether reporting is on only | |
| 38 | + | // changes a line: unknown (null) when it cannot be read, so the page | |
| 39 | + | // does not say it is off. | |
| 40 | + | const [reports, settings] = await Promise.all([advisories.reports(path, viewer), part(advisories.reportSettings(path, viewer), "Private vulnerability reporting")]); | |
| 41 | + | return { | |
| 42 | + | reports: unwrap(reports), | |
| 43 | + | reporting: isFailed(settings) ? null : settings.ok && settings.value.privateVulnerabilityReporting, | |
| 44 | + | can: access.can, | |
| 45 | + | }; | |
| 38 | 46 | } | |
| 39 | 47 | ||
| 40 | 48 | export async function action({ params, context, request }: Route.ActionArgs) { | |
| ⋯ | |||
| 111 | 119 | <SectionHeader | |
| 112 | 120 | title="Vulnerability reports" | |
| 113 | 121 | about={ | |
| 114 | − | reporting | |
| 122 | + | reporting !== false | |
| 115 | 123 | ? "What people told this repository's maintainers privately. Accept a report to draft an advisory from it, in the reporter's words and crediting them; decline what is not a vulnerability. Reporters never see this list." | |
| 116 | 124 | : "Private vulnerability reporting is off for this repository: nobody can send a new report. Turn it on from the Security overview. Reports already sent are here." | |
| 117 | 125 | } | |
| ⋯ | |||
| 136 | 144 | </div> | |
| 137 | 145 | {shown.length === 0 ? ( | |
| 138 | 146 | <EmptyState title={state ? `No ${REPORT_STATE_LABEL[state].toLowerCase()} reports` : "No reports"}> | |
| 139 | − | {reporting ? "Nobody has reported a vulnerability privately yet." : "Nothing was reported while private reporting was on."} | |
| 147 | + | {reporting !== false ? "Nobody has reported a vulnerability privately yet." : "Nothing was reported while private reporting was on."} | |
| 140 | 148 | </EmptyState> | |
| 141 | 149 | ) : ( | |
| 142 | 150 | <div className="space-y-3"> | |
| 13 | 13 | import { security, securitySuite } from "../../lib/services.server"; | |
| 14 | 14 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 15 | 15 | import { refusal, requireInsider } from "../../lib/access.server"; | |
| 16 | + | import { Unavailable } from "../../components/boundary"; | |
| 17 | + | import { isFailed, part } from "../../lib/partial"; | |
| 16 | 18 | ||
| 17 | 19 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 18 | 20 | return page(args, { title: `Secret alert · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 23 | 25 | const viewer = getViewer(context) ?? requireUser(context, request); | |
| 24 | 26 | const { access } = await requireInsider(context, params, "security_alerts"); | |
| 25 | 27 | const repo = { namespace: params.owner, name: params.repo }; | |
| 26 | − | const [detail, settings] = await Promise.all([securitySuite.secretAlert(repo, params.id, viewer), securitySuite.settings(repo, viewer)]); | |
| 27 | − | return { detail: unwrap(detail), settings: settings.ok ? settings.value : null, can: access.can }; | |
| 28 | + | // The settings only say whether its validity can be asked: when they | |
| 29 | + | // cannot be read, that says so, rather than that checks are off. | |
| 30 | + | const [detail, settings] = await Promise.all([ | |
| 31 | + | securitySuite.secretAlert(repo, params.id, viewer), | |
| 32 | + | part(securitySuite.settings(repo, viewer), "Whether validity checks are on"), | |
| 33 | + | ]); | |
| 34 | + | return { detail: unwrap(detail), settings: isFailed(settings) ? settings : settings.ok ? settings.value : null, can: access.can }; | |
| 28 | 35 | } | |
| 29 | 36 | ||
| 30 | 37 | const REASONS = new Set(["false_positive", "used_in_tests", "revoked", "wont_fix"]); | |
| ⋯ | |||
| 159 | 166 | </p> | |
| 160 | 167 | <p className="mt-1 text-xs text-muted">{validity.about}</p> | |
| 161 | 168 | <div className="mt-2"> | |
| 162 | − | <ValidityCheck action={action} checkable={detail.checkable && landed} enabled={Boolean(settings?.workspace.validityChecks && settings.entitled)} /> | |
| 169 | + | {isFailed(settings) ? ( | |
| 170 | + | <Unavailable what={settings.what} compact /> | |
| 171 | + | ) : ( | |
| 172 | + | <ValidityCheck action={action} checkable={detail.checkable && landed} enabled={Boolean(settings?.workspace.validityChecks && settings.entitled)} /> | |
| 173 | + | )} | |
| 163 | 174 | </div> | |
| 164 | 175 | </div> | |
| 165 | 176 | <div> | |
| 12 | 12 | import { security, securitySuite } from "../../lib/services.server"; | |
| 13 | 13 | import { assertSameOrigin, getViewer, managesSecurity, requireUser, unwrap } from "../../lib/session.server"; | |
| 14 | 14 | import { refusal, requireInsider } from "../../lib/access.server"; | |
| 15 | + | import { Unavailable } from "../../components/boundary"; | |
| 16 | + | import { isFailed, part } from "../../lib/partial"; | |
| 15 | 17 | import { planPrice } from "../../lib/security-suite.server"; | |
| 16 | 18 | import { countStates, keepSecret, secretFilters, secretTypes } from "../../lib/security-suite"; | |
| 17 | 19 | import { repoPages } from "../../lib/repo-live"; | |
| ⋯ | |||
| 25 | 27 | const viewer = getViewer(context) ?? requireUser(context, request); | |
| 26 | 28 | const { access } = await requireInsider(context, params, "push"); | |
| 27 | 29 | const repo = { namespace: params.owner, name: params.repo }; | |
| 30 | + | // The alerts are what the page is about. Custom patterns are a section | |
| 31 | + | // of their own: when they cannot be read it says so, rather than offer | |
| 32 | + | // to turn on what may well be on. | |
| 28 | 33 | const [overview, patterns, price] = await Promise.all([ | |
| 29 | 34 | security.overview(repo, viewer), | |
| 30 | − | securitySuite.patterns(params.owner, repo, viewer), | |
| 35 | + | part(securitySuite.patterns(params.owner, repo, viewer), "Custom patterns"), | |
| 31 | 36 | planPrice(params.owner, viewer), | |
| 32 | 37 | ]); | |
| 33 | 38 | return { | |
| 34 | 39 | overview: unwrap(overview), | |
| 35 | − | patterns: patterns.ok ? patterns.value : { patterns: [], entitled: false }, | |
| 40 | + | patterns: isFailed(patterns) ? patterns : patterns.ok ? patterns.value : { patterns: [], entitled: false }, | |
| 36 | 41 | price, | |
| 37 | 42 | can: access.can, | |
| 38 | 43 | owner: managesSecurity(viewer, params.owner), | |
| ⋯ | |||
| 79 | 84 | else next.delete(key); | |
| 80 | 85 | setSearch(next, { replace: true, preventScrollReset: true }); | |
| 81 | 86 | }; | |
| 82 | − | const own = patterns.patterns.filter((pattern) => pattern.scope === "repository"); | |
| 83 | − | const inherited = patterns.patterns.filter((pattern) => pattern.scope === "workspace"); | |
| 87 | + | const listed = isFailed(patterns) ? [] : patterns.patterns; | |
| 88 | + | const own = listed.filter((pattern) => pattern.scope === "repository"); | |
| 89 | + | const inherited = listed.filter((pattern) => pattern.scope === "workspace"); | |
| 84 | 90 | return ( | |
| 85 | 91 | <div className="max-w-5xl space-y-8"> | |
| 86 | 92 | <SectionHeader | |
| ⋯ | |||
| 130 | 136 | </h3> | |
| 131 | 137 | <p className="mt-1 text-sm text-muted">Formats of your own, found by push protection and history scans with the built-in ones.</p> | |
| 132 | 138 | </div> | |
| 133 | − | {patterns.entitled && can.manage_integrations && ( | |
| 139 | + | {!isFailed(patterns) && patterns.entitled && can.manage_integrations && ( | |
| 134 | 140 | <Link to={`${base}/security/secret-scanning/patterns`} className="inline-flex items-center gap-1.5 rounded-md border border-line px-3 py-1.5 text-sm hover:border-line-strong"> | |
| 135 | 141 | <Plus size={14} /> New pattern | |
| 136 | 142 | </Link> | |
| 137 | 143 | )} | |
| 138 | 144 | </div> | |
| 139 | − | {!patterns.entitled ? ( | |
| 145 | + | {isFailed(patterns) ? ( | |
| 146 | + | <Unavailable what={patterns.what} /> | |
| 147 | + | ) : !patterns.entitled ? ( | |
| 140 | 148 | <ActivationPrompt workspace={params.owner} feature="Custom patterns" monthlyCents={price} isOwner={owner} /> | |
| 141 | 149 | ) : own.length + inherited.length === 0 ? ( | |
| 142 | 150 | <Card asChild tone="plain" className="border-dashed px-4 py-6 text-sm text-muted"> | |
| 39 | 39 | import { billing, identity } from "../../lib/services.server"; | |
| 40 | 40 | import { assertSameOrigin, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 41 | 41 | import { UserCard } from "../../components/user-card"; | |
| 42 | + | import { Unavailable } from "../../components/boundary"; | |
| 43 | + | import { isFailed, part } from "../../lib/partial"; | |
| 42 | 44 | ||
| 43 | 45 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 44 | 46 | return page(args, { title: `Access · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 50 | 52 | const { viewer, access } = await requireInsider(context, params, "push"); | |
| 51 | 53 | const manage = access.can.manage_access; | |
| 52 | 54 | // The workspace's teams, for Add team: only Admins add one, and a | |
| 53 | − | // failure there leaves the rest of the page as it is. | |
| 55 | + | // failure there says so in Add team's place, not as "no other team". | |
| 54 | 56 | const [found, teams, free] = await Promise.all([ | |
| 55 | 57 | identity.repoAccess(params.owner, params.repo, viewer), | |
| 56 | − | manage ? identity.listTeams(viewer, params.owner).catch(() => null) : Promise.resolve(null), | |
| 58 | + | manage ? part(identity.listTeams(viewer, params.owner), "Teams to add") : Promise.resolve(null), | |
| 57 | 59 | // A free workspace invites no one from outside; identity refuses it | |
| 58 | 60 | // either way, so a failure here only hides the note. | |
| 59 | 61 | manage ? billing.freeWorkspaces([params.owner]).catch(() => [] as string[]) : Promise.resolve([] as string[]), | |
| 60 | 62 | ]); | |
| 61 | 63 | return { | |
| 62 | 64 | access: unwrap(found), | |
| 63 | − | teams: teams?.ok ? teams.value : ([] as Team[]), | |
| 65 | + | teams: isFailed(teams) ? teams : teams?.ok ? teams.value : ([] as Team[]), | |
| 64 | 66 | manage, | |
| 65 | 67 | free: free.includes(params.owner.toLowerCase()), | |
| 66 | 68 | // Owners change the base permission, on the workspace's People page. | |
| ⋯ | |||
| 165 | 167 | (a, b) => REPO_ROLES.indexOf(b.role) - REPO_ROLES.indexOf(a.role) || a.name.localeCompare(b.name), | |
| 166 | 168 | ); | |
| 167 | 169 | const listed = new Set(repoTeams.map((team) => team.slug)); | |
| 168 | − | const addable = teams.filter((team) => !listed.has(team.slug)); | |
| 170 | + | const addable = isFailed(teams) ? null : teams.filter((team) => !listed.has(team.slug)); | |
| 169 | 171 | // Roles are changed and people removed from their own rows, which say | |
| 170 | 172 | // what went wrong; what went right is said once, under the list. | |
| 171 | 173 | const [notice, setNotice] = useState<string | null>(null); | |
| ⋯ | |||
| 262 | 264 | </ul> | |
| 263 | 265 | </Card> | |
| 264 | 266 | )} | |
| 265 | − | {manage && <AddTeamForm teams={addable} result={teamResult} workspace={params.owner} />} | |
| 267 | + | {manage && addable && <AddTeamForm teams={addable} result={teamResult} workspace={params.owner} />} | |
| 268 | + | {manage && isFailed(teams) && <Unavailable what={teams.what} compact />} | |
| 266 | 269 | </Section> | |
| 267 | 270 | ||
| 268 | 271 | {manage && ( | |
| 19 | 19 | import { actions, repos, work } from "../../lib/services.server"; | |
| 20 | 20 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 21 | 21 | import { requireCapability, requireInsider } from "../../lib/access.server"; | |
| 22 | + | import { Unavailable } from "../../components/boundary"; | |
| 23 | + | import { isFailed, part } from "../../lib/partial"; | |
| 22 | 24 | ||
| 23 | 25 | const COMMIT_MESSAGE_DEFAULTS: CommitMessageDefault[] = [ | |
| 24 | 26 | "default", | |
| ⋯ | |||
| 40 | 42 | const [repo, settings, seen, workflows] = await Promise.all([ | |
| 41 | 43 | repos.get(path, viewer), | |
| 42 | 44 | work.getSettings(path, viewer), | |
| 43 | − | work.seenChecks(path, viewer), | |
| 44 | − | actions.workflows(path, viewer), | |
| 45 | + | // Only for offering to add CI: when either cannot be read, it is not offered. | |
| 46 | + | part(work.seenChecks(path, viewer), "Checks seen"), | |
| 47 | + | part(actions.workflows(path, viewer), "Workflows"), | |
| 45 | 48 | ]); | |
| 46 | 49 | const found = unwrap(repo); | |
| 47 | − | // What holds for the default branch: shown here, changed in Rules. | |
| 48 | − | const effective = await work.effectiveRules(path, found.defaultBranch, viewer).catch(() => null); | |
| 50 | + | // What holds for the default branch: shown here, changed in Rules. A | |
| 51 | + | // failure says so in its place: read as no rules, it would say the | |
| 52 | + | // branch is unprotected. | |
| 53 | + | const effective = await part(work.effectiveRules(path, found.defaultBranch, viewer), `The rules for ${found.defaultBranch}`); | |
| 49 | 54 | // Streamed: the CODEOWNERS file is read and checked on its own time. | |
| 50 | 55 | const codeowners = work | |
| 51 | 56 | .codeownersErrors(path, viewer) | |
| ⋯ | |||
| 55 | 60 | codeowners, | |
| 56 | 61 | repo: found, | |
| 57 | 62 | settings: unwrap(settings), | |
| 58 | − | effective: effective?.ok ? effective.value : null, | |
| 63 | + | effective: isFailed(effective) ? effective : effective.ok ? effective.value : null, | |
| 59 | 64 | // Nothing to require until something runs: the page offers to add CI. | |
| 60 | − | noChecks: workflows.ok && workflows.value.length === 0 && seen.ok && seen.value.length === 0, | |
| 65 | + | noChecks: !isFailed(workflows) && workflows.ok && workflows.value.length === 0 && !isFailed(seen) && seen.ok && seen.value.length === 0, | |
| 61 | 66 | canPush: access.can.push, | |
| 62 | 67 | }; | |
| 63 | 68 | } | |
| ⋯ | |||
| 111 | 116 | const branch = repo.defaultBranch; | |
| 112 | 117 | const base = `/${repo.namespace}/${repo.name}`; | |
| 113 | 118 | const archived = Boolean(repo.archivedAt); | |
| 114 | − | const active = effective?.rules.filter((rule) => rule.enforcement === "active") ?? []; | |
| 119 | + | const rules = isFailed(effective) ? null : effective; | |
| 120 | + | const active = rules?.rules.filter((rule) => rule.enforcement === "active") ?? []; | |
| 115 | 121 | const labels = [...new Set(active.map((rule) => ruleInfo(rule.type)?.label ?? rule.type))]; | |
| 116 | 122 | return ( | |
| 117 | 123 | <> | |
| ⋯ | |||
| 134 | 140 | <Scale size={16} className="mt-0.5 shrink-0 text-accent" /> | |
| 135 | 141 | <span className="min-w-0 grow"> | |
| 136 | 142 | <span className="block text-sm font-medium">Rules for {branch}</span> | |
| 137 | − | <span className="mt-1 block text-sm text-muted"> | |
| 138 | − | {labels.length > 0 ? labels.join(" · ") : `No active rules hold for ${branch}: anyone who may push can change it.`} | |
| 139 | − | </span> | |
| 140 | − | {effective && effective.rulesets.length > 0 && ( | |
| 143 | + | {!isFailed(effective) && ( | |
| 144 | + | <span className="mt-1 block text-sm text-muted"> | |
| 145 | + | {labels.length > 0 ? labels.join(" · ") : `No active rules hold for ${branch}: anyone who may push can change it.`} | |
| 146 | + | </span> | |
| 147 | + | )} | |
| 148 | + | {rules && rules.rulesets.length > 0 && ( | |
| 141 | 149 | <span className="mt-2 flex flex-wrap gap-2"> | |
| 142 | − | {effective.rulesets.map((ruleset) => ( | |
| 150 | + | {rules.rulesets.map((ruleset) => ( | |
| 143 | 151 | <span key={ruleset.id} className="inline-flex items-center gap-1.5 text-xs text-muted"> | |
| 144 | 152 | {ruleset.name} <EnforcementBadge enforcement={ruleset.enforcement} /> | |
| 145 | 153 | </span> | |
| ⋯ | |||
| 149 | 157 | </span> | |
| 150 | 158 | <ChevronRight size={16} className="mt-0.5 shrink-0 text-faint transition-transform group-hover:translate-x-0.5" /> | |
| 151 | 159 | </Link> | |
| 160 | + | {isFailed(effective) && <Unavailable what={effective.what} compact />} | |
| 152 | 161 | <Link | |
| 153 | 162 | to={`${base}/settings/guardrails`} | |
| 154 | 163 | className="group flex items-center gap-3 rounded-xl border border-line p-4 transition-colors hover:border-line-strong hover:bg-surface" | |
| 26 | 26 | requireUser, | |
| 27 | 27 | } from "../../lib/session.server"; | |
| 28 | 28 | import { refusal, requireInsider } from "../../lib/access.server"; | |
| 29 | + | import { Unavailable } from "../../components/boundary"; | |
| 30 | + | import { isFailed, part } from "../../lib/partial"; | |
| 29 | 31 | ||
| 30 | 32 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 31 | 33 | return page(args, { title: `Repository settings · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 36 | 38 | // Maintain and up; to anyone without a role here the page does not exist. | |
| 37 | 39 | const { repo, access } = await requireInsider(context, params, "manage_settings"); | |
| 38 | 40 | const path = { namespace: params.owner, name: params.repo }; | |
| 41 | + | // Each of these is one section's: when one cannot be read, that section | |
| 42 | + | // says so and has no form to save over what it could not show. | |
| 39 | 43 | const [branches, retention, settings] = await Promise.all([ | |
| 40 | − | repos.branches(path, viewer), | |
| 41 | − | actions.artifactRetention(path, viewer), | |
| 42 | − | work.getSettings(path, viewer).catch(() => null), | |
| 44 | + | part(repos.branches(path, viewer), "Its branches"), | |
| 45 | + | part(actions.artifactRetention(path, viewer), "How long artifacts are kept"), | |
| 46 | + | part(work.getSettings(path, viewer), "These settings"), | |
| 43 | 47 | ]); | |
| 44 | 48 | // Renaming and the default branch are for Admins; visibility, for owners | |
| 45 | 49 | // of the workspace unless its member privileges let its members with | |
| ⋯ | |||
| 48 | 52 | return { | |
| 49 | 53 | repo, | |
| 50 | 54 | // An empty repository has no branches yet; the page still works. | |
| 51 | − | branches: branches.ok ? branches.value.map((b) => b.name) : [], | |
| 55 | + | branches: isFailed(branches) ? branches : branches.ok ? branches.value.map((b) => b.name) : [], | |
| 52 | 56 | owner, | |
| 53 | 57 | visibility: access.can.change_visibility, | |
| 54 | 58 | // How long workflow runs' artifacts are kept. | |
| 55 | − | retention: retention.ok ? retention.value : { days: 14, maximum_allowed_days: 90 }, | |
| 56 | − | // Whether issues can be opened in it; null when that could not be read. | |
| 57 | − | issues: settings?.ok ? { on: settings.value.issuesEnabled ?? true, chosen: settings.value.hasIssues != null } : null, | |
| 58 | − | // How commits and issues are handled; null when that could not be read. | |
| 59 | − | pullSettings: settings?.ok | |
| 60 | − | ? { | |
| 61 | − | autoCloseIssues: settings.value.autoCloseIssues, | |
| 62 | − | requireWebCommitSignOff: settings.value.requireWebCommitSignOff, | |
| 63 | − | allowCommitComments: settings.value.allowCommitComments, | |
| 64 | − | } | |
| 65 | − | : null, | |
| 59 | + | retention: isFailed(retention) ? retention : retention.ok ? retention.value : { days: 14, maximum_allowed_days: 90 }, | |
| 60 | + | // Whether issues can be opened in it; null when it was not answered. | |
| 61 | + | issues: isFailed(settings) | |
| 62 | + | ? settings | |
| 63 | + | : settings.ok | |
| 64 | + | ? { on: settings.value.issuesEnabled ?? true, chosen: settings.value.hasIssues != null } | |
| 65 | + | : null, | |
| 66 | + | // How commits and issues are handled; null when it was not answered. | |
| 67 | + | pullSettings: isFailed(settings) | |
| 68 | + | ? settings | |
| 69 | + | : settings.ok | |
| 70 | + | ? { | |
| 71 | + | autoCloseIssues: settings.value.autoCloseIssues, | |
| 72 | + | requireWebCommitSignOff: settings.value.requireWebCommitSignOff, | |
| 73 | + | allowCommitComments: settings.value.allowCommitComments, | |
| 74 | + | } | |
| 75 | + | : null, | |
| 66 | 76 | }; | |
| 67 | 77 | } | |
| 68 | 78 | ||
| ⋯ | |||
| 167 | 177 | } | |
| 168 | 178 | ||
| 169 | 179 | export default function RepoSettings({ loaderData, actionData }: Route.ComponentProps) { | |
| 170 | − | const { repo, branches, owner, visibility, retention, issues, pullSettings } = loaderData; | |
| 180 | + | const { repo, branches, owner, visibility, retention, issues: features, pullSettings: commits } = loaderData; | |
| 181 | + | // Settings that could not be read are said to be, in their sections. | |
| 182 | + | const issues = isFailed(features) ? null : features; | |
| 183 | + | const pullSettings = isFailed(commits) ? null : commits; | |
| 171 | 184 | const navigation = useNavigation(); | |
| 172 | 185 | const posting = (intent: string) => navigation.state !== "idle" && navigation.formData?.get("intent") === intent; | |
| 173 | 186 | const base = `/${repo.namespace}/${repo.name}`; | |
| ⋯ | |||
| 221 | 234 | title="Branches" | |
| 222 | 235 | about="The branch everything lands on, and renaming branches." | |
| 223 | 236 | > | |
| 224 | − | <DefaultBranchForm repo={repo} branches={branches} archived={archived || !owner} busy={posting("default-branch")} result={actionData} /> | |
| 225 | − | {/* Keyed to the branches, so after a rename it starts over on the branches there are now. */} | |
| 226 | − | <RenameBranchForm | |
| 227 | − | key={branches.join(" ")} | |
| 228 | − | repo={repo} | |
| 229 | − | branches={branches} | |
| 230 | − | owner={owner} | |
| 231 | − | archived={archived} | |
| 232 | − | busy={posting("rename-branch")} | |
| 233 | − | result={actionData} | |
| 234 | − | /> | |
| 237 | + | {isFailed(branches) ? ( | |
| 238 | + | <Unavailable what={branches.what} /> | |
| 239 | + | ) : ( | |
| 240 | + | <> | |
| 241 | + | <DefaultBranchForm repo={repo} branches={branches} archived={archived || !owner} busy={posting("default-branch")} result={actionData} /> | |
| 242 | + | {/* Keyed to the branches, so after a rename it starts over on the branches there are now. */} | |
| 243 | + | <RenameBranchForm | |
| 244 | + | key={branches.join(" ")} | |
| 245 | + | repo={repo} | |
| 246 | + | branches={branches} | |
| 247 | + | owner={owner} | |
| 248 | + | archived={archived} | |
| 249 | + | busy={posting("rename-branch")} | |
| 250 | + | result={actionData} | |
| 251 | + | /> | |
| 252 | + | </> | |
| 253 | + | )} | |
| 235 | 254 | <Link | |
| 236 | 255 | to={`${base}/settings/branches`} | |
| 237 | 256 | className="group flex items-center gap-3 rounded-xl border border-line p-4 transition-colors hover:border-line-strong hover:bg-surface" | |
| ⋯ | |||
| 248 | 267 | </Link> | |
| 249 | 268 | </Section> | |
| 250 | 269 | ||
| 270 | + | {isFailed(features) && ( | |
| 271 | + | <Section title="Features" about="What the repository has besides its code."> | |
| 272 | + | <Unavailable what="Its features" /> | |
| 273 | + | </Section> | |
| 274 | + | )} | |
| 251 | 275 | {issues && ( | |
| 252 | 276 | <Section title="Features" about="What the repository has besides its code."> | |
| 253 | 277 | <Form method="post" key={`${issues.on}|${pullSettings?.autoCloseIssues}`} className="space-y-3"> | |
| ⋯ | |||
| 282 | 306 | </Section> | |
| 283 | 307 | )} | |
| 284 | 308 | ||
| 309 | + | {isFailed(commits) && ( | |
| 310 | + | <Section id="commits" title="Commits" about="Commits made on g1t, and talking about commits."> | |
| 311 | + | <Unavailable what="Its commit settings" /> | |
| 312 | + | </Section> | |
| 313 | + | )} | |
| 285 | 314 | {pullSettings && ( | |
| 286 | 315 | <Section id="commits" title="Commits" about="Commits made on g1t, and talking about commits."> | |
| 287 | 316 | <Form method="post" key={`${pullSettings.requireWebCommitSignOff}|${pullSettings.allowCommitComments}`} className="space-y-3"> | |
| ⋯ | |||
| 347 | 376 | </Section> | |
| 348 | 377 | ||
| 349 | 378 | <Section title="Artifacts" about="How long the files workflow runs upload are kept."> | |
| 350 | − | <Form method="post" key={retention.days} className="space-y-3"> | |
| 351 | − | <input type="hidden" name="intent" value="artifacts" /> | |
| 352 | − | <Field | |
| 353 | − | label="Days to keep artifacts" | |
| 354 | − | hint={`1 to ${retention.maximum_allowed_days}. A workflow's retention-days can ask for fewer, never more. Artifacts already uploaded keep their expiry.`} | |
| 355 | − | > | |
| 356 | − | <Input | |
| 357 | − | name="days" | |
| 358 | − | type="number" | |
| 359 | − | min={1} | |
| 360 | − | max={retention.maximum_allowed_days} | |
| 361 | − | required | |
| 362 | − | defaultValue={retention.days} | |
| 363 | − | className="w-28" | |
| 364 | − | /> | |
| 365 | − | </Field> | |
| 366 | − | <div className="flex flex-wrap items-center gap-4 pt-1"> | |
| 367 | − | <SubmitButton match={{ intent: "artifacts" }} pending="Saving…"> | |
| 368 | − | Save | |
| 369 | − | </SubmitButton> | |
| 370 | − | <Status intent="artifacts" data={actionData} /> | |
| 371 | − | </div> | |
| 372 | − | </Form> | |
| 379 | + | {isFailed(retention) ? ( | |
| 380 | + | <Unavailable what={retention.what} /> | |
| 381 | + | ) : ( | |
| 382 | + | <Form method="post" key={retention.days} className="space-y-3"> | |
| 383 | + | <input type="hidden" name="intent" value="artifacts" /> | |
| 384 | + | <Field | |
| 385 | + | label="Days to keep artifacts" | |
| 386 | + | hint={`1 to ${retention.maximum_allowed_days}. A workflow's retention-days can ask for fewer, never more. Artifacts already uploaded keep their expiry.`} | |
| 387 | + | > | |
| 388 | + | <Input | |
| 389 | + | name="days" | |
| 390 | + | type="number" | |
| 391 | + | min={1} | |
| 392 | + | max={retention.maximum_allowed_days} | |
| 393 | + | required | |
| 394 | + | defaultValue={retention.days} | |
| 395 | + | className="w-28" | |
| 396 | + | /> | |
| 397 | + | </Field> | |
| 398 | + | <div className="flex flex-wrap items-center gap-4 pt-1"> | |
| 399 | + | <SubmitButton match={{ intent: "artifacts" }} pending="Saving…"> | |
| 400 | + | Save | |
| 401 | + | </SubmitButton> | |
| 402 | + | <Status intent="artifacts" data={actionData} /> | |
| 403 | + | </div> | |
| 404 | + | </Form> | |
| 405 | + | )} | |
| 373 | 406 | </Section> | |
| 374 | 407 | ||
| 375 | 408 | {repo.isPrivate && ( | |
| 12 | 12 | import { getViewer, unwrap } from "../../lib/session.server"; | |
| 13 | 13 | import { requireInsider } from "../../lib/access.server"; | |
| 14 | 14 | import { movedTab, rulesAt, rulesPath } from "../../lib/tab-paths"; | |
| 15 | + | import { Unavailable } from "../../components/boundary"; | |
| 16 | + | import { isFailed, part } from "../../lib/partial"; | |
| 15 | 17 | ||
| 16 | 18 | export function meta({ params, ...args }: Route.MetaArgs) { | |
| 17 | 19 | return page(args, { title: `Rules · ${params.owner}/${params.repo} · g1t` }); | |
| ⋯ | |||
| 29 | 31 | const path = { namespace: params.owner, name: params.repo }; | |
| 30 | 32 | const tagged = url.searchParams.get("tag"); | |
| 31 | 33 | const branch = (tagged ?? url.searchParams.get("branch") ?? repo.defaultBranch).trim() || repo.defaultBranch; | |
| 34 | + | // What holds for a branch, and Insights, each say in their place when | |
| 35 | + | // they could not be read; the rulesets are what the page is about. | |
| 32 | 36 | const [rulesets, effective, evaluations] = await Promise.all([ | |
| 33 | 37 | work.listRulesets({ repo: path }, viewer, true), | |
| 34 | − | tab === "rulesets" ? work.effectiveRules(path, branch, viewer, tagged ? "tag" : "branch") : Promise.resolve(null), | |
| 38 | + | tab === "rulesets" ? part(work.effectiveRules(path, branch, viewer, tagged ? "tag" : "branch"), `The rules for ${branch}`) : Promise.resolve(null), | |
| 35 | 39 | tab === "insights" | |
| 36 | − | ? work.ruleEvaluations({ repo: path }, viewer, { | |
| 37 | − | before: url.searchParams.get("before") ?? undefined, | |
| 38 | − | problems_only: url.searchParams.get("problems") === "1", | |
| 39 | − | }) | |
| 40 | + | ? part( | |
| 41 | + | work.ruleEvaluations({ repo: path }, viewer, { | |
| 42 | + | before: url.searchParams.get("before") ?? undefined, | |
| 43 | + | problems_only: url.searchParams.get("problems") === "1", | |
| 44 | + | }), | |
| 45 | + | "Insights", | |
| 46 | + | ) | |
| 40 | 47 | : Promise.resolve(null), | |
| 41 | 48 | ]); | |
| 42 | 49 | return { | |
| ⋯ | |||
| 45 | 52 | target: tagged ? ("tag" as const) : ("branch" as const), | |
| 46 | 53 | defaultBranch: repo.defaultBranch, | |
| 47 | 54 | rulesets: unwrap(rulesets), | |
| 48 | − | effective: effective?.ok ? effective.value : null, | |
| 49 | − | evaluations: evaluations?.ok ? evaluations.value : null, | |
| 55 | + | effective: isFailed(effective) ? effective : effective?.ok ? effective.value : null, | |
| 56 | + | evaluations: isFailed(evaluations) ? evaluations : evaluations?.ok ? evaluations.value : null, | |
| 50 | 57 | editable: access.can.manage_protection && !repo.archivedAt, | |
| 51 | 58 | }; | |
| 52 | 59 | } | |
| ⋯ | |||
| 134 | 141 | Show rules | |
| 135 | 142 | </Button> | |
| 136 | 143 | </Form> | |
| 137 | − | {effective ? ( | |
| 144 | + | {isFailed(effective) ? ( | |
| 145 | + | <Unavailable what={effective.what} /> | |
| 146 | + | ) : effective ? ( | |
| 138 | 147 | <EffectiveRulesView effective={effective} hrefFor={hrefFor} /> | |
| 139 | 148 | ) : ( | |
| 140 | 149 | <p className="text-sm text-muted">The rules for that name could not be read.</p> | |
| 141 | 150 | )} | |
| 142 | 151 | </section> | |
| 143 | 152 | </div> | |
| 153 | + | ) : isFailed(evaluations) ? ( | |
| 154 | + | <Unavailable what={evaluations.what} /> | |
| 144 | 155 | ) : evaluations ? ( | |
| 145 | 156 | <InsightsView page={evaluations} olderHref={evaluations.next ? `${base}/settings/rules/insights?before=${encodeURIComponent(evaluations.next)}` : null} /> | |
| 146 | 157 | ) : ( | |
| 11 | 11 | import { work } from "../../lib/services.server"; | |
| 12 | 12 | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 13 | 13 | import { requireCapability, requireInsider } from "../../lib/access.server"; | |
| 14 | + | import { orElse, part } from "../../lib/partial"; | |
| 14 | 15 | ||
| 15 | 16 | export function meta({ params, loaderData, ...args }: Route.MetaArgs) { | |
| 16 | 17 | const name = loaderData?.existing?.name ?? "New ruleset"; | |
| ⋯ | |||
| 21 | 22 | const viewer = getViewer(context); | |
| 22 | 23 | const { repo, access } = await requireInsider(context, params, "manage_protection"); | |
| 23 | 24 | const path = { namespace: params.owner, name: params.repo }; | |
| 24 | − | const seen = await work.seenChecks(path, viewer).catch(() => null); | |
| 25 | + | // Only suggestions for required checks, deep in a rule; any name can be | |
| 26 | + | // typed without them, so the form quietly does without. | |
| 27 | + | const seen = orElse(await part(work.seenChecks(path, viewer), "Checks seen"), null); | |
| 25 | 28 | const base = { | |
| 26 | 29 | seen: seen?.ok ? seen.value : [], | |
| 27 | 30 | editable: access.can.manage_protection && !repo.archivedAt, | |
Comments
Sign in to comment.