Runners are owner-only: the page, its actions, the Settings entry and the links to it
Members no longer see the workspace's machines or registration tokens. A self-hosted job's badge links to Runners only for owners; mission control's stuck-job card offers the link to owners only.
4 files+37−210/4 viewed
| 723 | 723 | <SidebarLink to={`/${slug}/-/secrets`} icon={<Lock size={15} />}> | |
| 724 | 724 | Secrets and variables | |
| 725 | 725 | </SidebarLink> | |
| 726 | − | <SidebarLink to={`/${slug}/-/runners`} icon={<ServerCog size={15} />}> | |
| 727 | − | Runners | |
| 728 | − | </SidebarLink> | |
| 726 | + | {owner && ( | |
| 727 | + | <SidebarLink to={`/${slug}/-/runners`} icon={<ServerCog size={15} />}> | |
| 728 | + | Runners | |
| 729 | + | </SidebarLink> | |
| 730 | + | )} | |
| 729 | 731 | </SidebarGroup> | |
| 730 | 732 | <SidebarGroup title="Connections" className="mt-3"> | |
| 731 | 733 | <SidebarLink to={`/${slug}/-/integrations`} icon={<Plug size={15} />}> |
| 66 | 66 | repos as reposApi, | |
| 67 | 67 | work, | |
| 68 | 68 | } from "../lib/services.server"; | |
| 69 | − | import { assertSameOrigin, getViewer, requireUser } from "../lib/session.server"; | |
| 69 | + | import { assertSameOrigin, getViewer, requireUser, roleIn } from "../lib/session.server"; | |
| 70 | 70 | import { runners } from "../lib/runners.server"; | |
| 71 | 71 | import { readableRepos } from "../lib/access.server"; | |
| 72 | 72 | ||
| 431 | 431 | { label: "Labels", value: job.labels, tone: null }, | |
| 432 | 432 | { label: "Waiting", value: `${minutes} min`, tone: "warn" }, | |
| 433 | 433 | ], | |
| 434 | − | link: { label: "Runners", to: `/${namespace}/-/runners` }, | |
| 434 | + | // Runners are the owners' to see to. | |
| 435 | + | ...(namespace && roleIn(viewer, namespace) === "owner" ? { link: { label: "Runners", to: `/${namespace}/-/runners` } } : {}), | |
| 435 | 436 | }); | |
| 436 | 437 | } | |
| 437 | 438 | for (const run of liveRuns) { |
| 1 | 1 | import { AlertTriangle, ChevronRight, Cloud, Download, GitBranch, GitCommitHorizontal, Info, Package, RotateCw, ServerCog, Square, XCircle } from "lucide-react"; | |
| 2 | 2 | import { type ReactNode } from "react"; | |
| 3 | − | import { Form, Link, useNavigation, useSearchParams } from "react-router"; | |
| 3 | + | import { Form, Link, useLoaderData, useNavigation, useSearchParams } from "react-router"; | |
| 4 | 4 | ||
| 5 | 5 | import type { Annotation, Job, StepState } from "@g1t/contracts"; | |
| 6 | 6 | ||
| 10 | 10 | import { Button, ErrorText, TimeAgo } from "../../components/ui"; | |
| 11 | 11 | import { listArtifacts } from "../../lib/artifacts.server"; | |
| 12 | 12 | import { actions } from "../../lib/services.server"; | |
| 13 | − | import { assertSameOrigin, getViewer, requireUser, unwrap } from "../../lib/session.server"; | |
| 13 | + | import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server"; | |
| 14 | 14 | import { accessTo, refusal } from "../../lib/access.server"; | |
| 15 | 15 | import { useRefreshWhile } from "../../lib/refresh"; | |
| 16 | 16 | ||
| 24 | 24 | const detail = unwrap(await actions.run({ namespace: params.owner, name: params.repo }, viewer, params.id)); | |
| 25 | 25 | const artifacts = await listArtifacts(params.id).catch(() => []); | |
| 26 | 26 | // Cancelling and re-running need Write. | |
| 27 | − | return { detail, artifacts, member: (await accessTo(context, params)).can.run }; | |
| 27 | + | return { | |
| 28 | + | detail, | |
| 29 | + | artifacts, | |
| 30 | + | member: (await accessTo(context, params)).can.run, | |
| 31 | + | // The runners page is the workspace owners'. | |
| 32 | + | runnersPage: roleIn(viewer, params.owner) === "owner", | |
| 33 | + | }; | |
| 28 | 34 | } | |
| 29 | 35 | ||
| 30 | 36 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 89 | 95 | ||
| 90 | 96 | /** Where the job ran: g1t's own runners, or a self-hosted one by name. */ | |
| 91 | 97 | function RanOn({ job, workspace }: { job: Job; workspace: string }) { | |
| 98 | + | const runnersPage = useLoaderData<typeof loader>().runnersPage; | |
| 92 | 99 | if (job.selfHosted) { | |
| 93 | − | return ( | |
| 94 | − | <Link | |
| 95 | − | to={`/${workspace}/-/runners`} | |
| 96 | − | className="ml-auto inline-flex items-center gap-1.5 rounded-full border border-line px-2.5 py-0.5 text-xs text-muted hover:text-fg" | |
| 97 | − | title="Self-hosted runner" | |
| 98 | − | > | |
| 100 | + | const badge = "ml-auto inline-flex items-center gap-1.5 rounded-full border border-line px-2.5 py-0.5 text-xs text-muted"; | |
| 101 | + | const label = ( | |
| 102 | + | <> | |
| 99 | 103 | <ServerCog size={13} /> | |
| 100 | 104 | {job.runner ? `Self-hosted: ${job.runner}` : "Self-hosted"} | |
| 105 | + | </> | |
| 106 | + | ); | |
| 107 | + | // A link to the runners for those who manage them, a label for everyone else. | |
| 108 | + | return runnersPage ? ( | |
| 109 | + | <Link to={`/${workspace}/-/runners`} className={`${badge} hover:text-fg`} title="Self-hosted runner"> | |
| 110 | + | {label} | |
| 101 | 111 | </Link> | |
| 112 | + | ) : ( | |
| 113 | + | <span className={badge} title="Self-hosted runner"> | |
| 114 | + | {label} | |
| 115 | + | </span> | |
| 102 | 116 | ); | |
| 103 | 117 | } | |
| 104 | 118 | if (!job.startedAt) return null; |
| 10 | 10 | } | |
| 11 | 11 | ||
| 12 | 12 | export async function loader({ params, context, request }: Route.LoaderArgs) { | |
| 13 | − | const role = roleIn(getViewer(context), params.owner); | |
| 14 | − | if (!role) throw new Response(null, { status: 404 }); | |
| 13 | + | // The workspace's own machines and their tokens: owners only. | |
| 14 | + | if (roleIn(getViewer(context), params.owner) !== "owner") throw new Response(null, { status: 404 }); | |
| 15 | 15 | const user = requireUser(context, request); | |
| 16 | 16 | const workspace = params.owner.toLowerCase(); | |
| 17 | − | // Members see the runners; owners add, remove and group them. | |
| 18 | − | const names = role === "owner" ? (await repos.list(user, { namespace: workspace })).map((repo) => repo.name) : []; | |
| 19 | − | return { role, ...(await loadRunners({ workspace }, user, names)) }; | |
| 17 | + | const names = (await repos.list(user, { namespace: workspace })).map((repo) => repo.name); | |
| 18 | + | return loadRunners({ workspace }, user, names); | |
| 20 | 19 | } | |
| 21 | 20 | ||
| 22 | 21 | export async function action({ request, params, context }: Route.ActionArgs) { | |
| 23 | 22 | assertSameOrigin(request); | |
| 23 | + | if (roleIn(getViewer(context), params.owner) !== "owner") throw new Response(null, { status: 404 }); | |
| 24 | 24 | const user = requireUser(context, request); | |
| 25 | 25 | return actOnRunners({ workspace: params.owner.toLowerCase() }, user, await request.formData()); | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | 28 | export default function WorkspaceRunners({ loaderData, actionData }: Route.ComponentProps) { | |
| 29 | − | const { role, ...data } = loaderData; | |
| 30 | − | return <RunnersPanel data={data} action={actionData} scope="workspace" manage={role === "owner"} />; | |
| 29 | + | return <RunnersPanel data={loaderData} action={actionData} scope="workspace" manage />; | |
| 31 | 30 | } |