- Users, sessions, keys, tokens, repositories and issues live in PocketBase (schema in pb_migrations/). Vectrum starts bin/pocketbase itself unless PB_URL points at a server of its own; the registry keeps its records in SQLite. - Workflows in .vectrum/workflows/*.yml (a subset of GitHub Actions syntax) run on push or by hand, on runners registered for one repository or for all of a user's repositories, with live logs, cancel, re-run and CI status marks in the commit list. - Docker image and compose file, SIGNUPS_DISABLED and scripts/create-user.mjs for closed instances, and /api/health. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Showing 86 changed files with 4380 additions and 946 deletions.
| @@ -0,0 +1,11 @@ | ||
| 1 | + .git | |
| 2 | + .next | |
| 3 | + node_modules | |
| 4 | + bin | |
| 5 | + data | |
| 6 | + .env* | |
| 7 | + *.tsbuildinfo | |
| 8 | + next-env.d.ts | |
| 9 | + Dockerfile | |
| 10 | + docker-compose.yml | |
| 11 | + .dockerignore | |
| @@ -42,3 +42,6 @@ next-env.d.ts | ||
| 42 | 42 | |
| 43 | 43 | # git repositories and issue data |
| 44 | 44 | /data/ |
| 45 | + | |
| 46 | + # the PocketBase server binary (scripts/install-pocketbase.mjs) | |
| 47 | + /bin/ | |
| @@ -0,0 +1,47 @@ | ||
| 1 | + # syntax=docker/dockerfile:1 | |
| 2 | + | |
| 3 | + # ---- build: install dependencies (which also downloads PocketBase into bin/) and build Next.js | |
| 4 | + FROM node:22-bookworm-slim AS build | |
| 5 | + WORKDIR /app | |
| 6 | + COPY --from=oven/bun:1.4.2 /usr/local/bin/bun /usr/local/bin/bun | |
| 7 | + RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates unzip && rm -rf /var/lib/apt/lists/* | |
| 8 | + | |
| 9 | + COPY package.json bun.lock ./ | |
| 10 | + COPY scripts ./scripts | |
| 11 | + RUN bun install --frozen-lockfile | |
| 12 | + | |
| 13 | + COPY . . | |
| 14 | + ENV NEXT_TELEMETRY_DISABLED=1 NEXT_OUTPUT=standalone | |
| 15 | + RUN bun run build | |
| 16 | + | |
| 17 | + # ---- runtime: the standalone server, PocketBase, git | |
| 18 | + FROM node:22-bookworm-slim | |
| 19 | + RUN apt-get update && apt-get install -y --no-install-recommends git tini ca-certificates && rm -rf /var/lib/apt/lists/* | |
| 20 | + WORKDIR /app | |
| 21 | + | |
| 22 | + COPY --from=build --chown=node:node /app/.next/standalone ./ | |
| 23 | + COPY --from=build --chown=node:node /app/.next/static ./.next/static | |
| 24 | + COPY --from=build --chown=node:node /app/public ./public | |
| 25 | + COPY --from=build --chown=node:node /app/pb_migrations ./pb_migrations | |
| 26 | + COPY --from=build --chown=node:node /app/bin ./bin | |
| 27 | + COPY --from=build --chown=node:node /app/scripts/create-user.mjs ./scripts/ | |
| 28 | + | |
| 29 | + RUN mkdir /data && chown node:node /data | |
| 30 | + VOLUME /data | |
| 31 | + | |
| 32 | + ENV NODE_ENV=production \ | |
| 33 | + NEXT_TELEMETRY_DISABLED=1 \ | |
| 34 | + HOSTNAME=0.0.0.0 \ | |
| 35 | + PORT=3000 \ | |
| 36 | + DATA_ROOT=/data \ | |
| 37 | + SSH_PORT=2222 | |
| 38 | + | |
| 39 | + USER node | |
| 40 | + EXPOSE 3000 2222 | |
| 41 | + | |
| 42 | + HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --start-interval=2s \ | |
| 43 | + CMD node -e "fetch('http://127.0.0.1:'+process.env.PORT+'/api/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))" | |
| 44 | + | |
| 45 | + # tini forwards stop signals to the whole process group, so PocketBase shuts down cleanly too. | |
| 46 | + ENTRYPOINT ["/usr/bin/tini", "-g", "--"] | |
| 47 | + CMD ["node", "server.js"] | |
| @@ -6,7 +6,7 @@ git repositories on disk, and you push to them with plain git over **SSH** or | ||
| 6 | 6 | |
| 7 | 7 | ## Features |
| 8 | 8 | |
| 9 | - - **Accounts**: sign up, sign in, profiles, password change (scrypt-hashed passwords, database-backed sessions) | |
| 9 | + - **Accounts**: sign up, sign in, profiles, password change (PocketBase auth: bcrypt-hashed passwords, token sessions) | |
| 10 | 10 | - **Repositories**: public or private, rename, delete, optional README on creation |
| 11 | 11 | - **Collaborators**: give other users push access to a repository |
| 12 | 12 | - **Git over SSH**: built-in SSH server (default port 2222). Add public keys under *Settings → SSH keys* |
| @@ -23,19 +23,36 @@ git repositories on disk, and you push to them with plain git over **SSH** or | ||
| 23 | 23 | - Quick-create dialog and keyboard shortcuts: `C` new issue, `J`/`K` move, `Enter` open, `S`/`P`/`A`/`L` set status, priority, assignee, labels |
| 24 | 24 | - Inline editing of every property, with an activity feed of changes next to the markdown comments |
| 25 | 25 | - Anyone who can read a repository can open issues and comment, but only collaborators and the owner can change an issue (title, description, status and all other properties). Only an issue's creator and the repository owner can delete it. Labels are managed under *Settings → Labels* or created from the label menu |
| 26 | + - **Actions with self-hosted runners**: workflows in `.vectrum/workflows/*.yml` (a subset of GitHub Actions syntax) run on push or by hand, on runners you register for one repository or for all of yours. Live logs, cancel and re-run, and CI status marks in the commit list | |
| 26 | 27 | - Light and dark mode |
| 27 | 28 | |
| 28 | 29 | ## Getting started |
| 29 | 30 | |
| 30 | - Requires Node.js 22.5+ (for the built-in `node:sqlite`) and `git` on the `PATH`. | |
| 31 | + Requires Node.js 22+ and `git` on the `PATH`. | |
| 31 | 32 | |
| 32 | 33 | ```bash |
| 33 | - bun install | |
| 34 | + bun install # also downloads the PocketBase server into bin/ | |
| 34 | 35 | bun dev |
| 35 | 36 | ``` |
| 36 | 37 | |
| 37 | 38 | Open http://localhost:3000, sign up, and create a repository. |
| 38 | 39 | |
| 40 | + The data lives in [PocketBase](https://pocketbase.io). Vectrum starts `bin/pocketbase` itself on `127.0.0.1:8090`, with its data in `data/pb_data` and the schema from `pb_migrations/`; its superuser login is generated into `data/pocketbase-superuser.json` (also good for the PocketBase dashboard at http://127.0.0.1:8090/_/). Run `bun run pocketbase:install` if the binary is missing. To use a PocketBase server of your own instead, set `PB_URL` and the superuser credentials; it needs the migrations in `pb_migrations/` (`pocketbase serve --migrationsDir pb_migrations`). | |
| 41 | + | |
| 42 | + ### Running with Docker | |
| 43 | + | |
| 44 | + ```bash | |
| 45 | + docker compose up -d --build | |
| 46 | + ``` | |
| 47 | + | |
| 48 | + The image runs the web UI on port 3000 and the SSH server on port 2222, with all data in the `vectrum-data` volume (mounted at `/data`). Settings go under `environment:` in `docker-compose.yml` (see [Configuration](#configuration)). To close registration, set `SIGNUPS_DISABLED: "1"` before you deploy, and create accounts from the command line instead: | |
| 49 | + | |
| 50 | + ```bash | |
| 51 | + docker compose exec -it vectrum node scripts/create-user.mjs <username> <email> [name] | |
| 52 | + ``` | |
| 53 | + | |
| 54 | + It asks for the password (or reads `VECTRUM_PASSWORD`). Outside Docker, run `node scripts/create-user.mjs` the same way while Vectrum runs. Clone URLs show `SSH_PORT`, so publish SSH on the same port on the host, and set `SSH_HOST` to your domain. Put a TLS reverse proxy in front of port 3000 if the server is reachable from the internet. | |
| 55 | + | |
| 39 | 56 | ### Pushing over SSH |
| 40 | 57 | |
| 41 | 58 | 1. Add your public key (`~/.ssh/id_ed25519.pub`) under **Settings → SSH keys**. |
| @@ -76,28 +93,64 @@ talks plain HTTP to `localhost`; anywhere else, serve Vectrum over HTTPS (or add | ||
| 76 | 93 | the host to Docker's `insecure-registries`). A reverse proxy in front of |
| 77 | 94 | Vectrum must pass large request bodies through (e.g. nginx `client_max_body_size 0;`). |
| 78 | 95 | |
| 96 | + ### Running workflows | |
| 97 | + | |
| 98 | + 1. Register a runner under **Settings → Runners** (for all your repositories) or under a repository's **Settings → Runners**, and copy its token. | |
| 99 | + 2. Start it on any machine with Node.js 18+ and git: | |
| 100 | + ```bash | |
| 101 | + curl -fsSLO http://localhost:3000/vectrum-runner.mjs | |
| 102 | + node vectrum-runner.mjs --url http://localhost:3000 --token vrn_… | |
| 103 | + ``` | |
| 104 | + 3. Add `.vectrum/workflows/ci.yml` and push: | |
| 105 | + ```yaml | |
| 106 | + name: CI | |
| 107 | + on: | |
| 108 | + push: | |
| 109 | + branches: [main] | |
| 110 | + workflow_dispatch: # adds a "Run workflow" button | |
| 111 | + jobs: | |
| 112 | + test: | |
| 113 | + runs-on: self-hosted # labels the runner must have | |
| 114 | + steps: | |
| 115 | + - run: npm ci | |
| 116 | + - name: Test | |
| 117 | + run: npm test | |
| 118 | + ``` | |
| 119 | + | |
| 120 | + Supported: `on: push` (with `branches`, `tags`, `paths` and their `-ignore` forms) and `workflow_dispatch`; `env` at every level; jobs with `runs-on`, `needs`, `timeout-minutes`; steps with `run`, `name`, `shell`, `working-directory`, `env`, `if: success()/failure()/always()`, `continue-on-error` and `timeout-minutes`. The repository is checked out before the first step (`uses: actions/checkout` is accepted and skipped). Other `uses:` actions, `${{ }}` expressions, matrices, services and secrets are not supported; steps get `VECTRUM_*` environment variables (`VECTRUM_SHA`, `VECTRUM_REF_NAME`, `VECTRUM_REPOSITORY`, …) instead. | |
| 121 | + | |
| 122 | + Runners poll the server over HTTPS (`/api/runner/*`), so they need no inbound port. Every runner has the labels `self-hosted`, its OS (`linux`, `macos`, `windows`) and architecture (`x64`, `arm64`), plus any you give it. Each job gets a short-lived, read-only token that can only clone its own repository. Steps run as the user that started the runner, with no sandbox, so only run runners for repositories whose workflows you trust. | |
| 123 | + | |
| 79 | 124 | ## Configuration |
| 80 | 125 | |
| 81 | 126 | | Variable | Default | Purpose | |
| 82 | 127 | | ----------------- | ----------- | --------------------------------------------------------- | |
| 83 | 128 | | `DATA_ROOT` | `./data` | Where repositories, the database and the SSH host key live | |
| 129 | + | `PB_PORT` | `8090` | Port of the PocketBase server Vectrum runs | | |
| 130 | + | `PB_URL` | — | Use this PocketBase server instead of running one | | |
| 131 | + | `PB_SUPERUSER_EMAIL`, `PB_SUPERUSER_PASSWORD` | generated | Superuser login; required with `PB_URL` | | |
| 132 | + | `PB_BINARY` | `bin/pocketbase` | PocketBase executable to run | | |
| 84 | 133 | | `SSH_PORT` | `2222` | Port of the built-in SSH server | |
| 85 | 134 | | `SSH_LISTEN_HOST` | `0.0.0.0` | Interface the SSH server binds to | |
| 86 | 135 | | `SSH_HOST` | request host | Host name shown in SSH clone URLs, e.g. `vectrum.cc` | |
| 87 | 136 | | `SSH_DISABLED` | — | Set to `1` to not start the SSH server | |
| 137 | + | `SIGNUPS_DISABLED` | — | Set to `1` to close registration (add users with `scripts/create-user.mjs`) | | |
| 88 | 138 | |
| 89 | 139 | Data layout: |
| 90 | 140 | |
| 91 | 141 | ``` |
| 92 | 142 | data/ |
| 93 | - app.db users, sessions, keys, tokens, repos, issues (SQLite) | |
| 143 | + pb_data/ PocketBase: users, keys, tokens, repos, issues, workflow runs and logs | |
| 144 | + pocketbase-superuser.json the generated PocketBase superuser login | |
| 94 | 145 | ssh_host_ed25519_key generated on first start |
| 95 | 146 | repos/<owner>/<repo>.git bare git repositories |
| 147 | + registry.db container image manifests, tags and blob records (SQLite) | |
| 96 | 148 | registry/blobs/sha256/… container image layers, shared by all repositories |
| 97 | 149 | registry/uploads/ image uploads in progress |
| 98 | 150 | ``` |
| 99 | 151 | |
| 100 | - Image manifests and tags are stored in the database. A blob is deleted once no | |
| 152 | + The registry keeps its records in SQLite rather than PocketBase so that storing a | |
| 153 | + blob and pruning unused ones never interleave. A blob is deleted once no | |
| 101 | 154 | manifest of any repository references it (blobs pushed in the last 24 hours are |
| 102 | 155 | kept so pushes in progress don't lose their layers). |
| 103 | 156 | |
| @@ -109,12 +162,13 @@ kept so pushes in progress don't lose their layers). | ||
| 109 | 162 | |
| 110 | 163 | ## Layout |
| 111 | 164 | |
| 112 | - - `lib/db.ts`: SQLite schema and connection | |
| 113 | - - `lib/users.ts`, `lib/auth.ts`: accounts, password hashing, sessions | |
| 165 | + - `lib/db.ts`: starting and connecting to PocketBase; `pb_migrations/`: the schema | |
| 166 | + - `lib/users.ts`, `lib/auth.ts`: accounts and sessions (PocketBase auth records and tokens) | |
| 114 | 167 | - `lib/repos.ts`: repository records, permissions (`repoAccess`), collaborators |
| 115 | 168 | - `lib/issues.ts`, `lib/issue-meta.ts`: issue tracker data and its shared vocabulary (statuses, priorities); UI in `components/issues/` |
| 116 | 169 | - `lib/keys.ts`, `lib/tokens.ts`: SSH keys and API tokens |
| 117 | 170 | - `lib/git.ts`, `lib/git-write.ts`: reading from and committing to bare repositories |
| 118 | 171 | - `lib/git-http.ts`, `lib/ssh-server.ts`: git transports (started from `instrumentation.ts`) |
| 119 | 172 | - `lib/registry.ts`, `lib/registry-http.ts`: container registry storage and the OCI distribution API, served under `/v2` |
| 173 | + - `lib/workflows.ts`, `lib/ci.ts`, `lib/runs.ts`, `lib/runners.ts`: workflow parsing, starting runs on push, the job queue and runner registration; `app/api/runner/*` is the runner API and `public/vectrum-runner.mjs` the runner | |
| 120 | 174 | - `app/actions/*`: server actions, each re-checking the session and permissions |
| @@ -0,0 +1,171 @@ | ||
| 1 | + import Link from "next/link"; | |
| 2 | + import type { Metadata } from "next"; | |
| 3 | + import { dispatchWorkflowAction } from "@/app/actions/ci"; | |
| 4 | + import { DispatchButton } from "@/components/ci/dispatch-form"; | |
| 5 | + import { LiveRefresh } from "@/components/ci/live-refresh"; | |
| 6 | + import { RunStatusIcon } from "@/components/ci/run-status"; | |
| 7 | + import { refName, runDuration, triggerText } from "@/components/ci/run-text"; | |
| 8 | + import { WorkflowNav } from "@/components/ci/workflow-nav"; | |
| 9 | + import { ChevronLeftIcon, ChevronRightIcon, PlayIcon } from "@/components/icons"; | |
| 10 | + import { liftRowClass } from "@/components/repo-rows"; | |
| 11 | + import { Alert, Blankslate, Box, ButtonLink, PageTitle, RefChip } from "@/components/ui"; | |
| 12 | + import { readWorkflows } from "@/lib/ci"; | |
| 13 | + import { timeAgo } from "@/lib/format"; | |
| 14 | + import { listBranches, revParse } from "@/lib/git"; | |
| 15 | + import { getRepo } from "@/lib/repo"; | |
| 16 | + import { runnerSummary } from "@/lib/runners"; | |
| 17 | + import { countRuns, isFinished, listRunWorkflows, listRuns, queuedJobCount, sweepStaleJobs } from "@/lib/runs"; | |
| 18 | + import { WORKFLOW_DIR } from "@/lib/workflows"; | |
| 19 | + | |
| 20 | + export const metadata: Metadata = { title: "Actions" }; | |
| 21 | + | |
| 22 | + const PAGE_SIZE = 25; | |
| 23 | + | |
| 24 | + const EXAMPLE = `name: CI | |
| 25 | + on: | |
| 26 | + push: | |
| 27 | + branches: [main] | |
| 28 | + workflow_dispatch: | |
| 29 | + jobs: | |
| 30 | + test: | |
| 31 | + runs-on: self-hosted | |
| 32 | + steps: | |
| 33 | + - run: echo "Hello from $VECTRUM_REPOSITORY at $VECTRUM_SHA" | |
| 34 | + - name: Test | |
| 35 | + run: npm ci && npm test`; | |
| 36 | + | |
| 37 | + export default async function ActionsPage({ params, searchParams }: PageProps<"/[owner]/[repo]/actions">) { | |
| 38 | + const { owner, repo: repoParam } = await params; | |
| 39 | + const query = await searchParams; | |
| 40 | + const repo = await getRepo(owner, repoParam); | |
| 41 | + const workflowPath = typeof query.workflow === "string" ? query.workflow : null; | |
| 42 | + const page = Math.max(1, Number(query.page) || 1); | |
| 43 | + | |
| 44 | + await sweepStaleJobs(); | |
| 45 | + const headSha = repo.defaultBranch ? await revParse(repo.dir, `refs/heads/${repo.defaultBranch}`) : null; | |
| 46 | + const [files, branches, pastWorkflows] = await Promise.all([ | |
| 47 | + headSha ? readWorkflows(repo.dir, headSha) : [], | |
| 48 | + listBranches(repo.dir), | |
| 49 | + listRunWorkflows(repo.id), | |
| 50 | + ]); | |
| 51 | + | |
| 52 | + // The workflows on the default branch, then ones that only exist in past runs (deleted or on other branches). | |
| 53 | + const workflows = files.map((f) => ({ path: f.path, name: "workflow" in f ? f.workflow.name : f.name })); | |
| 54 | + for (const w of pastWorkflows) if (!workflows.some((x) => x.path === w.path)) workflows.push(w); | |
| 55 | + const current = workflows.find((w) => w.path === workflowPath) ?? null; | |
| 56 | + const currentFile = files.find((f) => f.path === current?.path); | |
| 57 | + | |
| 58 | + const [total, runs, runners, waiting] = await Promise.all([ | |
| 59 | + countRuns(repo.id, current?.path), | |
| 60 | + listRuns(repo.id, { workflowPath: current?.path, limit: PAGE_SIZE, offset: (page - 1) * PAGE_SIZE }), | |
| 61 | + runnerSummary(repo.id, repo.ownerId), | |
| 62 | + queuedJobCount(repo.id), | |
| 63 | + ]); | |
| 64 | + const pageHref = (p: number) => `${repo.base}/actions?${new URLSearchParams({ ...(current ? { workflow: current.path } : {}), page: String(p) })}`; | |
| 65 | + | |
| 66 | + return ( | |
| 67 | + <div className="grid gap-8 md:grid-cols-[220px_1fr]"> | |
| 68 | + <LiveRefresh active={runs.some((r) => !isFinished(r.status))} interval={4000} /> | |
| 69 | + <WorkflowNav base={repo.base} workflows={workflows} current={current?.path ?? null} /> | |
| 70 | + | |
| 71 | + <div className="min-w-0"> | |
| 72 | + <PageTitle | |
| 73 | + title={current?.name ?? "All workflows"} | |
| 74 | + description={current ? <RefChip>{current.path}</RefChip> : "Runs of this repository’s workflows, newest first."} | |
| 75 | + actions={ | |
| 76 | + current && | |
| 77 | + currentFile && | |
| 78 | + "workflow" in currentFile && | |
| 79 | + currentFile.workflow.dispatch && | |
| 80 | + repo.canWrite && | |
| 81 | + repo.defaultBranch && ( | |
| 82 | + <DispatchButton | |
| 83 | + action={dispatchWorkflowAction.bind(null, repo.owner, repo.name, current.path)} | |
| 84 | + branches={branches.map((b) => b.name)} | |
| 85 | + defaultBranch={repo.defaultBranch} | |
| 86 | + /> | |
| 87 | + ) | |
| 88 | + } | |
| 89 | + /> | |
| 90 | + | |
| 91 | + {waiting > 0 && runners.online === 0 && ( | |
| 92 | + <div className="mb-5"> | |
| 93 | + <Alert kind="info"> | |
| 94 | + {waiting === 1 ? "A job is" : `${waiting} jobs are`} waiting for a runner, but none{" "} | |
| 95 | + {runners.total === 0 ? "is registered" : "is online"}.{" "} | |
| 96 | + {repo.isAdmin ? ( | |
| 97 | + <Link href={`${repo.base}/settings/runners`} className="underline underline-offset-2"> | |
| 98 | + {runners.total === 0 ? "Add a runner" : "See runners"} | |
| 99 | + </Link> | |
| 100 | + ) : ( | |
| 101 | + "Ask the owner to add one." | |
| 102 | + )} | |
| 103 | + </Alert> | |
| 104 | + </div> | |
| 105 | + )} | |
| 106 | + | |
| 107 | + <Box className="p-1.5"> | |
| 108 | + {runs.length === 0 ? ( | |
| 109 | + <Blankslate icon={<PlayIcon />} title={current ? "This workflow hasn’t run yet" : "No workflow runs yet"}> | |
| 110 | + {!current && ( | |
| 111 | + <span className="flex flex-col items-center gap-3"> | |
| 112 | + <span> | |
| 113 | + Add a workflow file under <RefChip>{WORKFLOW_DIR}/</RefChip> and push. Jobs run on your own machines: register one | |
| 114 | + under {repo.isAdmin ? <Link href={`${repo.base}/settings/runners`} className="underline underline-offset-2">Settings → Runners</Link> : "Settings → Runners"}. | |
| 115 | + </span> | |
| 116 | + <pre className="well w-full max-w-md overflow-x-auto rounded-row px-3.5 py-3 text-left font-mono text-[12px] leading-5 text-ink"> | |
| 117 | + {EXAMPLE} | |
| 118 | + </pre> | |
| 119 | + </span> | |
| 120 | + )} | |
| 121 | + </Blankslate> | |
| 122 | + ) : ( | |
| 123 | + <ul> | |
| 124 | + {runs.map((run) => { | |
| 125 | + const href = `${repo.base}/actions/runs/${run.number}`; | |
| 126 | + const duration = runDuration(run); | |
| 127 | + return ( | |
| 128 | + <li key={run.id} className={`${liftRowClass} flex items-center gap-3.5 px-3 py-2.5`}> | |
| 129 | + <RunStatusIcon status={run.status} size={18} /> | |
| 130 | + <div className="min-w-0 flex-1"> | |
| 131 | + <Link href={href} className="block truncate type-title text-ink decoration-ink3 underline-offset-4 hover:underline"> | |
| 132 | + {run.title} | |
| 133 | + </Link> | |
| 134 | + <div className="mt-0.5 truncate type-meta text-ink3"> | |
| 135 | + <span className="font-medium text-ink2">{run.workflowName}</span> #{run.number}: {triggerText(run)} | |
| 136 | + {run.error && <span className="text-red"> · Invalid workflow file</span>} | |
| 137 | + </div> | |
| 138 | + </div> | |
| 139 | + <RefChip className="hidden max-w-40 truncate sm:inline-flex">{refName(run.ref)}</RefChip> | |
| 140 | + <div className="hidden w-28 shrink-0 flex-col items-end gap-0.5 text-right md:flex"> | |
| 141 | + <time dateTime={new Date(run.createdAt).toISOString()} className="type-meta text-ink2"> | |
| 142 | + {timeAgo(run.createdAt)} | |
| 143 | + </time> | |
| 144 | + {duration && <span className="type-readout text-ink3">{duration}</span>} | |
| 145 | + </div> | |
| 146 | + </li> | |
| 147 | + ); | |
| 148 | + })} | |
| 149 | + </ul> | |
| 150 | + )} | |
| 151 | + </Box> | |
| 152 | + | |
| 153 | + {(page > 1 || page * PAGE_SIZE < total) && ( | |
| 154 | + <div className="mt-8 flex justify-center gap-2"> | |
| 155 | + {page > 1 && ( | |
| 156 | + <ButtonLink href={pageHref(page - 1)} icon={<ChevronLeftIcon />}> | |
| 157 | + Newer | |
| 158 | + </ButtonLink> | |
| 159 | + )} | |
| 160 | + {page * PAGE_SIZE < total && ( | |
| 161 | + <ButtonLink href={pageHref(page + 1)}> | |
| 162 | + Older | |
| 163 | + <ChevronRightIcon /> | |
| 164 | + </ButtonLink> | |
| 165 | + )} | |
| 166 | + </div> | |
| 167 | + )} | |
| 168 | + </div> | |
| 169 | + </div> | |
| 170 | + ); | |
| 171 | + } | |
| @@ -0,0 +1,29 @@ | ||
| 1 | + import { getViewer } from "@/lib/auth"; | |
| 2 | + import { normalizeRepoName } from "@/lib/git"; | |
| 3 | + import { findRepo, repoAccess } from "@/lib/repos"; | |
| 4 | + import { getRun, listJobs, listSteps, readJobLogs } from "@/lib/runs"; | |
| 5 | + | |
| 6 | + /** A job's whole log as plain text, every step under a heading. */ | |
| 7 | + export async function GET(_request: Request, ctx: RouteContext<"/[owner]/[repo]/actions/runs/[number]/logs/[job]">) { | |
| 8 | + const { owner, repo: repoParam, number, job: jobKey } = await ctx.params; | |
| 9 | + const repo = await findRepo(owner, normalizeRepoName(repoParam)); | |
| 10 | + const viewer = await getViewer(); | |
| 11 | + const notFound = new Response("Not found", { status: 404 }); | |
| 12 | + if (!repo || !(await repoAccess(repo, viewer?.id))) return notFound; | |
| 13 | + const run = await getRun(repo.id, Number(number)); | |
| 14 | + const job = run && (await listJobs(run.id)).find((j) => j.key === decodeURIComponent(jobKey)); | |
| 15 | + if (!run || !job) return notFound; | |
| 16 | + | |
| 17 | + const [logs, steps] = await Promise.all([readJobLogs(job.id), listSteps(job.id)]); | |
| 18 | + const text = steps | |
| 19 | + .map((s) => `##[${s.status}] ${s.name}\n${logs.get(s.index) ?? ""}`.replace(/\n?$/, "\n")) | |
| 20 | + .join("\n"); | |
| 21 | + return new Response(text, { | |
| 22 | + headers: { | |
| 23 | + "Content-Type": "text/plain; charset=utf-8", | |
| 24 | + "Content-Disposition": `inline; filename="${repo.name}-run${run.number}-${job.key}.log"`, | |
| 25 | + "Cache-Control": "private, no-store", | |
| 26 | + "X-Content-Type-Options": "nosniff", | |
| 27 | + }, | |
| 28 | + }); | |
| 29 | + } | |
| @@ -0,0 +1,202 @@ | ||
| 1 | + import Link from "next/link"; | |
| 2 | + import { notFound } from "next/navigation"; | |
| 3 | + import { cancelRunAction, rerunAction } from "@/app/actions/ci"; | |
| 4 | + import { ConfirmButton } from "@/components/confirm-button"; | |
| 5 | + import { JobSteps, type StepView } from "@/components/ci/job-steps"; | |
| 6 | + import { LiveRefresh } from "@/components/ci/live-refresh"; | |
| 7 | + import { RunStatusIcon, statusLed, statusText } from "@/components/ci/run-status"; | |
| 8 | + import { refName, runDuration, triggerText } from "@/components/ci/run-text"; | |
| 9 | + import { ShaChip } from "@/components/file-table"; | |
| 10 | + import { ChevronLeftIcon, DownloadIcon, PlayIcon, ServerIcon } from "@/components/icons"; | |
| 11 | + import { liftRowClass, plateHeadingClass } from "@/components/repo-rows"; | |
| 12 | + import { Alert, Box, Button, ButtonLink, RefChip } from "@/components/ui"; | |
| 13 | + import { StatusBadge } from "@unlocalhosted/metalui"; | |
| 14 | + import { formatDate, timeAgo } from "@/lib/format"; | |
| 15 | + import { logTail } from "@/lib/logs"; | |
| 16 | + import { getRepo } from "@/lib/repo"; | |
| 17 | + import { runnerSummary } from "@/lib/runners"; | |
| 18 | + import { getRun, isFinished, listJobs, listSteps, readJobLogs, sweepStaleJobs, type Job } from "@/lib/runs"; | |
| 19 | + | |
| 20 | + export async function generateMetadata({ params }: PageProps<"/[owner]/[repo]/actions/runs/[number]">) { | |
| 21 | + const { owner, repo: repoParam, number } = await params; | |
| 22 | + const repo = await getRepo(owner, repoParam); | |
| 23 | + const run = await getRun(repo.id, Number(number)); | |
| 24 | + return { title: run ? `${run.title} · ${run.workflowName} #${run.number}` : "Run" }; | |
| 25 | + } | |
| 26 | + | |
| 27 | + /** The job to show first: one that is running, else one that failed, else the first. */ | |
| 28 | + function defaultJob(jobs: Job[]) { | |
| 29 | + return jobs.find((j) => j.status === "in_progress") ?? jobs.find((j) => j.status === "failure") ?? jobs[0]; | |
| 30 | + } | |
| 31 | + | |
| 32 | + export default async function RunPage({ params, searchParams }: PageProps<"/[owner]/[repo]/actions/runs/[number]">) { | |
| 33 | + const { owner, repo: repoParam, number } = await params; | |
| 34 | + const { job: jobParam } = await searchParams; | |
| 35 | + const repo = await getRepo(owner, repoParam); | |
| 36 | + await sweepStaleJobs(); | |
| 37 | + const run = await getRun(repo.id, Number(number)); | |
| 38 | + if (!run) notFound(); | |
| 39 | + | |
| 40 | + const jobs = await listJobs(run.id); | |
| 41 | + const job = jobs.find((j) => j.key === jobParam) ?? defaultJob(jobs); | |
| 42 | + const finished = isFinished(run.status); | |
| 43 | + const runHref = `${repo.base}/actions/runs/${run.number}`; | |
| 44 | + | |
| 45 | + let steps: StepView[] = []; | |
| 46 | + if (job) { | |
| 47 | + const [logs, jobSteps] = await Promise.all([readJobLogs(job.id), listSteps(job.id)]); | |
| 48 | + steps = jobSteps.map((s) => ({ | |
| 49 | + index: s.index, | |
| 50 | + name: s.name, | |
| 51 | + status: s.status, | |
| 52 | + exitCode: s.exitCode, | |
| 53 | + duration: runDuration(s), | |
| 54 | + ...logTail(logs.get(s.index) ?? ""), | |
| 55 | + })); | |
| 56 | + } | |
| 57 | + const runners = job?.status === "queued" ? await runnerSummary(repo.id, repo.ownerId) : null; | |
| 58 | + | |
| 59 | + return ( | |
| 60 | + <div> | |
| 61 | + <LiveRefresh active={!finished} /> | |
| 62 | + <Link href={`${repo.base}/actions`} className="mb-3 inline-flex items-center gap-1 type-ui text-ink2 transition-colors hover:text-ink"> | |
| 63 | + <ChevronLeftIcon width={14} height={14} /> All runs | |
| 64 | + </Link> | |
| 65 | + | |
| 66 | + <div className="mb-6 flex flex-wrap items-start gap-4"> | |
| 67 | + <RunStatusIcon status={run.status} size={26} className="mt-0.5" /> | |
| 68 | + <div className="min-w-0 flex-1"> | |
| 69 | + <h1 className="text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink"> | |
| 70 | + {run.title} <span className="font-normal text-ink3">#{run.number}</span> | |
| 71 | + </h1> | |
| 72 | + <div className="mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 type-meta text-ink3"> | |
| 73 | + <StatusBadge led={statusLed(run.status)}>{statusText(run.status)}</StatusBadge> | |
| 74 | + <Link href={`${repo.base}/actions?workflow=${encodeURIComponent(run.workflowPath)}`} className="font-medium text-ink2 hover:underline"> | |
| 75 | + {run.workflowName} | |
| 76 | + </Link> | |
| 77 | + <span>·</span> | |
| 78 | + <span>{triggerText(run)}</span> | |
| 79 | + <RefChip>{refName(run.ref)}</RefChip> | |
| 80 | + <ShaChip sha={run.sha} href={`${repo.base}/commit/${run.sha}`} /> | |
| 81 | + <span>·</span> | |
| 82 | + <time dateTime={new Date(run.createdAt).toISOString()} title={formatDate(run.createdAt)}> | |
| 83 | + {timeAgo(run.createdAt)} | |
| 84 | + </time> | |
| 85 | + {runDuration(run) && ( | |
| 86 | + <> | |
| 87 | + <span>·</span> | |
| 88 | + <span className="type-readout">{runDuration(run)}</span> | |
| 89 | + </> | |
| 90 | + )} | |
| 91 | + </div> | |
| 92 | + </div> | |
| 93 | + {repo.canWrite && ( | |
| 94 | + <div className="flex shrink-0 gap-2"> | |
| 95 | + {!finished ? ( | |
| 96 | + <ConfirmButton | |
| 97 | + action={cancelRunAction.bind(null, repo.owner, repo.name, run.number)} | |
| 98 | + message={`Cancel run #${run.number}? Running steps are stopped and the remaining jobs won’t start.`} | |
| 99 | + variant="default" | |
| 100 | + > | |
| 101 | + Cancel Run | |
| 102 | + </ConfirmButton> | |
| 103 | + ) : ( | |
| 104 | + <form action={rerunAction.bind(null, repo.owner, repo.name, run.number)}> | |
| 105 | + <Button icon={<PlayIcon />}>Re-run</Button> | |
| 106 | + </form> | |
| 107 | + )} | |
| 108 | + </div> | |
| 109 | + )} | |
| 110 | + </div> | |
| 111 | + | |
| 112 | + {run.error && ( | |
| 113 | + <div className="mb-6"> | |
| 114 | + <Alert> | |
| 115 | + <p className="font-medium">Invalid workflow file {run.workflowPath}</p> | |
| 116 | + <p className="mt-1 font-mono text-[12.5px] whitespace-pre-wrap">{run.error}</p> | |
| 117 | + </Alert> | |
| 118 | + </div> | |
| 119 | + )} | |
| 120 | + | |
| 121 | + {job && ( | |
| 122 | + <div className="grid gap-6 md:grid-cols-[240px_1fr]"> | |
| 123 | + <Box className="self-start p-1.5"> | |
| 124 | + <h2 className={plateHeadingClass}>Jobs</h2> | |
| 125 | + <ul> | |
| 126 | + {jobs.map((j) => ( | |
| 127 | + <li key={j.id} className={`${liftRowClass} ${j.id === job.id ? "recipe-row-list-hover" : ""}`}> | |
| 128 | + <Link | |
| 129 | + href={`${runHref}?job=${encodeURIComponent(j.key)}`} | |
| 130 | + aria-current={j.id === job.id ? "page" : undefined} | |
| 131 | + className="flex h-10 items-center gap-2.5 rounded-row px-3 outline-none focus-visible:focus-ring" | |
| 132 | + > | |
| 133 | + <RunStatusIcon status={j.status} size={15} /> | |
| 134 | + <span className="min-w-0 flex-1 truncate type-ui text-ink">{j.name}</span> | |
| 135 | + {runDuration(j) && <span className="shrink-0 type-readout text-ink3">{runDuration(j)}</span>} | |
| 136 | + </Link> | |
| 137 | + </li> | |
| 138 | + ))} | |
| 139 | + </ul> | |
| 140 | + </Box> | |
| 141 | + | |
| 142 | + <Box className="min-w-0 p-1.5"> | |
| 143 | + <div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 pt-2 pb-3"> | |
| 144 | + <h2 className="type-title text-ink">{job.name}</h2> | |
| 145 | + <span className="type-meta text-ink3"> | |
| 146 | + {job.status === "waiting" && `Waiting for ${job.needs.join(", ")}`} | |
| 147 | + {job.status === "skipped" && "Skipped because a job it needs didn’t succeed"} | |
| 148 | + {job.runnerName && ( | |
| 149 | + <span className="inline-flex items-center gap-1"> | |
| 150 | + <ServerIcon width={12} height={12} /> {job.runnerName} | |
| 151 | + </span> | |
| 152 | + )} | |
| 153 | + </span> | |
| 154 | + <span className="ml-auto flex items-center gap-1.5"> | |
| 155 | + {job.runsOn.map((l) => ( | |
| 156 | + <RefChip key={l}>{l}</RefChip> | |
| 157 | + ))} | |
| 158 | + {(job.startedAt || steps.some((s) => s.lines.length)) && ( | |
| 159 | + <ButtonLink | |
| 160 | + href={`${runHref}/logs/${encodeURIComponent(job.key)}`} | |
| 161 | + variant="invisible" | |
| 162 | + icon={<DownloadIcon width={14} height={14} />} | |
| 163 | + prefetch={false} | |
| 164 | + > | |
| 165 | + Raw log | |
| 166 | + </ButtonLink> | |
| 167 | + )} | |
| 168 | + </span> | |
| 169 | + </div> | |
| 170 | + {job.status === "queued" && runners && ( | |
| 171 | + <div className="px-3 pb-3"> | |
| 172 | + <Alert kind="info"> | |
| 173 | + Waiting for a runner with the label{job.runsOn.length === 1 ? "" : "s"} {job.runsOn.join(", ")} to pick up this job. | |
| 174 | + {runners.online === 0 && ( | |
| 175 | + <> | |
| 176 | + {" "} | |
| 177 | + {runners.total === 0 ? "No runners are registered for this repository" : "None of this repository’s runners are online"} | |
| 178 | + {repo.isAdmin && ( | |
| 179 | + <> | |
| 180 | + {" "} | |
| 181 | + ·{" "} | |
| 182 | + <Link href={`${repo.base}/settings/runners`} className="underline underline-offset-2"> | |
| 183 | + Runners | |
| 184 | + </Link> | |
| 185 | + </> | |
| 186 | + )} | |
| 187 | + . | |
| 188 | + </> | |
| 189 | + )} | |
| 190 | + </Alert> | |
| 191 | + </div> | |
| 192 | + )} | |
| 193 | + {job.cancelRequested && job.status === "in_progress" && ( | |
| 194 | + <p className="px-3 pb-3 type-meta text-ink3">Cancelling: waiting for the runner to stop…</p> | |
| 195 | + )} | |
| 196 | + <JobSteps steps={steps} rawHref={`${runHref}/logs/${encodeURIComponent(job.key)}`} /> | |
| 197 | + </Box> | |
| 198 | + </div> | |
| 199 | + )} | |
| 200 | + </div> | |
| 201 | + ); | |
| 202 | + } | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | import Link from "next/link"; |
| 2 | 2 | import { Tooltip } from "@unlocalhosted/metalui"; |
| 3 | 3 | import { BranchSelect } from "@/components/branch-select"; |
| 4 | + import { RunStatusIcon } from "@/components/ci/run-status"; | |
| 4 | 5 | import { CopyButton } from "@/components/clone-button"; |
| 5 | 6 | import { ShaChip } from "@/components/file-table"; |
| 6 | 7 | import { ChevronLeftIcon, ChevronRightIcon, CodeIcon, CommitIcon, HistoryIcon } from "@/components/icons"; |
| @@ -9,6 +10,7 @@ import { Avatar, Blankslate, Box, ButtonLink, RefChip } from "@/components/ui"; | ||
| 9 | 10 | import { listBranches, log, type Commit } from "@/lib/git"; |
| 10 | 11 | import { encodePath, formatDate, timeAgo } from "@/lib/format"; |
| 11 | 12 | import { getRefAndPath, getRepo } from "@/lib/repo"; |
| 13 | + import { commitStatuses } from "@/lib/runs"; | |
| 12 | 14 | |
| 13 | 15 | const PAGE_SIZE = 35; |
| 14 | 16 | |
| @@ -32,6 +34,7 @@ export default async function CommitsPage({ params, searchParams }: PageProps<"/ | ||
| 32 | 34 | listBranches(repo.dir), |
| 33 | 35 | ]); |
| 34 | 36 | const hasNext = commits.length > PAGE_SIZE; |
| 37 | + const statuses = await commitStatuses(repo.id, commits.map((c) => c.sha)); | |
| 35 | 38 | const selfHref = `${repo.base}/commits/${encodePath(ref)}${path ? `/${encodePath(path)}` : ""}`; |
| 36 | 39 | |
| 37 | 40 | return ( |
| @@ -74,6 +77,11 @@ export default async function CommitsPage({ params, searchParams }: PageProps<"/ | ||
| 74 | 77 | <div className="mt-0.5 flex items-center gap-1 type-meta text-ink3"> |
| 75 | 78 | <span className="font-medium text-ink2">{c.authorName}</span> committed{" "} |
| 76 | 79 | <time dateTime={new Date(c.date).toISOString()}>{timeAgo(c.date)}</time> |
| 80 | + {statuses.has(c.sha) && ( | |
| 81 | + <Link href={`${repo.base}/actions/runs/${statuses.get(c.sha)!.runNumber}`} className="ml-1 transition-opacity hover:opacity-80"> | |
| 82 | + <RunStatusIcon status={statuses.get(c.sha)!.status} size={13} /> | |
| 83 | + </Link> | |
| 84 | + )} | |
| 77 | 85 | </div> |
| 78 | 86 | </div> |
| 79 | 87 | <div className="flex shrink-0 items-center gap-1"> |
| @@ -5,11 +5,11 @@ import { findRepo, getRepoImage, repoAccess } from "@/lib/repos"; | ||
| 5 | 5 | /** The repository's uploaded image. URLs carry `?v=<upload time>`, so responses can be cached for good. */ |
| 6 | 6 | export async function GET(_request: Request, ctx: RouteContext<"/[owner]/[repo]/image">) { |
| 7 | 7 | const { owner, repo: repoParam } = await ctx.params; |
| 8 | - const repo = findRepo(owner, normalizeRepoName(repoParam)); | |
| 8 | + const repo = await findRepo(owner, normalizeRepoName(repoParam)); | |
| 9 | 9 | const viewer = await getViewer(); |
| 10 | - if (!repo || !repoAccess(repo, viewer?.id)) return new Response("Not found", { status: 404 }); | |
| 10 | + if (!repo || !(await repoAccess(repo, viewer?.id))) return new Response("Not found", { status: 404 }); | |
| 11 | 11 | |
| 12 | - const image = getRepoImage(repo.id); | |
| 12 | + const image = await getRepoImage(repo.id); | |
| 13 | 13 | if (!image) return new Response("Not found", { status: 404 }); |
| 14 | 14 | return new Response(new Uint8Array(image.data), { |
| 15 | 15 | headers: { |
| @@ -19,7 +19,7 @@ import { trackerProps } from "@/lib/tracker"; | ||
| 19 | 19 | export async function generateMetadata({ params }: PageProps<"/[owner]/[repo]/issues/[number]">) { |
| 20 | 20 | const { owner, repo: repoParam, number } = await params; |
| 21 | 21 | const repo = await getRepo(owner, repoParam); |
| 22 | - const issue = getIssue(repo.id, Number(number)); | |
| 22 | + const issue = await getIssue(repo.id, Number(number)); | |
| 23 | 23 | return { title: issue ? `${issue.title} · Issue #${issue.number}` : "Issue" }; |
| 24 | 24 | } |
| 25 | 25 | |
| @@ -120,20 +120,20 @@ function describe(e: IssueEvent): { icon: ReactNode; text: ReactNode } { | ||
| 120 | 120 | export default async function IssuePage({ params }: PageProps<"/[owner]/[repo]/issues/[number]">) { |
| 121 | 121 | const { owner, repo: repoParam, number } = await params; |
| 122 | 122 | const repo = await getRepo(owner, repoParam); |
| 123 | - const issue = getIssue(repo.id, Number(number)); | |
| 123 | + const issue = await getIssue(repo.id, Number(number)); | |
| 124 | 124 | if (!issue) notFound(); |
| 125 | 125 | const viewer = repo.viewer; |
| 126 | 126 | const action = commentAction.bind(null, repo.owner, repo.name, issue.number); |
| 127 | 127 | |
| 128 | 128 | // Comments and property changes, oldest first. |
| 129 | 129 | const activity = [ |
| 130 | - ...listComments(issue.id).map((c) => ({ kind: "comment" as const, at: c.createdAt, comment: c })), | |
| 131 | - ...listEvents(issue.id).map((e) => ({ kind: "event" as const, at: e.createdAt, event: e })), | |
| 130 | + ...(await listComments(issue.id)).map((c) => ({ kind: "comment" as const, at: c.createdAt, comment: c })), | |
| 131 | + ...(await listEvents(issue.id)).map((e) => ({ kind: "event" as const, at: e.createdAt, event: e })), | |
| 132 | 132 | ].sort((a, b) => a.at - b.at); |
| 133 | 133 | |
| 134 | 134 | return ( |
| 135 | 135 | <div className="mx-auto max-w-6xl"> |
| 136 | - <TrackerProvider {...trackerProps(repo)}> | |
| 136 | + <TrackerProvider {...await trackerProps(repo)}> | |
| 137 | 137 | <IssueDetail |
| 138 | 138 | issue={issue} |
| 139 | 139 | description={<Markdown>{issue.body}</Markdown>} |
| @@ -15,7 +15,7 @@ export default async function NewIssuePage({ params }: PageProps<"/[owner]/[repo | ||
| 15 | 15 | return ( |
| 16 | 16 | <div className="max-w-3xl"> |
| 17 | 17 | <PageTitle title="Create new issue" /> |
| 18 | - <TrackerProvider {...trackerProps(repo)}> | |
| 18 | + <TrackerProvider {...await trackerProps(repo)}> | |
| 19 | 19 | <NewIssueForm /> |
| 20 | 20 | </TrackerProvider> |
| 21 | 21 | </div> |
| @@ -13,8 +13,8 @@ export default async function IssuesPage({ params, searchParams }: PageProps<"/[ | ||
| 13 | 13 | const repo = await getRepo(owner, repoParam); |
| 14 | 14 | |
| 15 | 15 | return ( |
| 16 | - <TrackerProvider {...trackerProps(repo)}> | |
| 17 | - <IssueTracker issues={listIssues(repo.id)} initialView={parseView(await searchParams)} /> | |
| 16 | + <TrackerProvider {...await trackerProps(repo)}> | |
| 17 | + <IssueTracker issues={await listIssues(repo.id)} initialView={parseView(await searchParams)} /> | |
| 18 | 18 | </TrackerProvider> |
| 19 | 19 | ); |
| 20 | 20 | } |
| @@ -38,7 +38,7 @@ export default async function RepoLayout({ params, children }: LayoutProps<"/[ow | ||
| 38 | 38 | <VisibilityLabel visibility={repo.visibility} /> |
| 39 | 39 | {repo.access === "write" && <Label tone="accent">Collaborator</Label>} |
| 40 | 40 | </div> |
| 41 | - <RepoTabs base={repo.base} openIssues={openIssueCount(repo.id)} images={countImages(repo.id)} showSettings={repo.isAdmin} /> | |
| 41 | + <RepoTabs base={repo.base} openIssues={await openIssueCount(repo.id)} images={countImages(repo.id)} showSettings={repo.isAdmin} /> | |
| 42 | 42 | </div> |
| 43 | 43 | </div> |
| 44 | 44 | <div className="mx-auto w-full max-w-[1600px] flex-1 px-4 py-4 md:px-5">{children}</div> |
| @@ -145,7 +145,7 @@ export default async function RepoHome({ params }: PageProps<"/[owner]/[repo]">) | ||
| 145 | 145 | ref ? listFilesWithSizes(repo.dir, ref) : [], |
| 146 | 146 | ref ? listContributors(repo.dir, ref) : [], |
| 147 | 147 | ]); |
| 148 | - const collaborators = listCollaborators(repo.id); | |
| 148 | + const collaborators = await listCollaborators(repo.id); | |
| 149 | 149 | const languages = languageBreakdown(files); |
| 150 | 150 | const historyHref = ref ? `${repo.base}/commits/${encodePath(ref)}` : `${repo.base}/commits`; |
| 151 | 151 | |
| @@ -171,7 +171,7 @@ export default async function RepoHome({ params }: PageProps<"/[owner]/[repo]">) | ||
| 171 | 171 | <Stat href={historyHref} icon={HistoryIcon} label="Commits" value={commits} /> |
| 172 | 172 | <Stat href={`${repo.base}/branches`} icon={BranchIcon} label="Branches" value={branches.length} /> |
| 173 | 173 | <Stat href={`${repo.base}/branches`} icon={TagIcon} label="Tags" value={tags.length} /> |
| 174 | - <Stat href={`${repo.base}/issues`} icon={IssueOpenedIcon} label="Open issues" value={openIssueCount(repo.id)} /> | |
| 174 | + <Stat href={`${repo.base}/issues`} icon={IssueOpenedIcon} label="Open issues" value={await openIssueCount(repo.id)} /> | |
| 175 | 175 | <Stat |
| 176 | 176 | href={repo.isAdmin ? `${repo.base}/settings/access` : `/${repo.owner}`} |
| 177 | 177 | icon={PeopleIcon} |
| @@ -5,9 +5,9 @@ import { findRepo, repoAccess } from "@/lib/repos"; | ||
| 5 | 5 | |
| 6 | 6 | export async function GET(_request: Request, ctx: RouteContext<"/[owner]/[repo]/raw/[...segments]">) { |
| 7 | 7 | const { owner, repo: repoParam, segments } = await ctx.params; |
| 8 | - const repo = findRepo(owner, normalizeRepoName(repoParam)); | |
| 8 | + const repo = await findRepo(owner, normalizeRepoName(repoParam)); | |
| 9 | 9 | const viewer = await getViewer(); |
| 10 | - if (!repo || !repoAccess(repo, viewer?.id)) return new Response("Not found", { status: 404 }); | |
| 10 | + if (!repo || !(await repoAccess(repo, viewer?.id))) return new Response("Not found", { status: 404 }); | |
| 11 | 11 | |
| 12 | 12 | const resolved = await resolveRefAndPath(repo.dir, segments); |
| 13 | 13 | if (!resolved || (await objectType(repo.dir, resolved.ref, resolved.path)) !== "blob") { |
| @@ -17,7 +17,7 @@ export const metadata: Metadata = { title: "Collaborators" }; | ||
| 17 | 17 | export default async function AccessPage({ params }: PageProps<"/[owner]/[repo]/settings/access">) { |
| 18 | 18 | const { owner, repo: repoParam } = await params; |
| 19 | 19 | const repo = await getRepo(owner, repoParam); |
| 20 | - const collaborators = listCollaborators(repo.id); | |
| 20 | + const collaborators = await listCollaborators(repo.id); | |
| 21 | 21 | |
| 22 | 22 | return ( |
| 23 | 23 | <div> |
| @@ -16,8 +16,7 @@ export const metadata: Metadata = { title: "Labels" }; | ||
| 16 | 16 | export default async function LabelsPage({ params }: PageProps<"/[owner]/[repo]/settings/labels">) { |
| 17 | 17 | const { owner, repo: repoParam } = await params; |
| 18 | 18 | const repo = await getRepo(owner, repoParam); |
| 19 | - const labels = listLabels(repo.id); | |
| 20 | - const usage = labelUsage(repo.id); | |
| 19 | + const [labels, usage] = await Promise.all([listLabels(repo.id), labelUsage(repo.id)]); | |
| 21 | 20 | |
| 22 | 21 | return ( |
| 23 | 22 | <div> |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { forbidden } from "next/navigation"; |
| 2 | 2 | import { SettingsNav } from "@/components/settings-nav"; |
| 3 | - import { GearIcon, PeopleIcon, TagIcon } from "@/components/icons"; | |
| 3 | + import { GearIcon, PeopleIcon, ServerIcon, TagIcon } from "@/components/icons"; | |
| 4 | 4 | import { getRepo } from "@/lib/repo"; |
| 5 | 5 | |
| 6 | 6 | export default async function RepoSettingsLayout({ params, children }: LayoutProps<"/[owner]/[repo]/settings">) { |
| @@ -15,6 +15,7 @@ export default async function RepoSettingsLayout({ params, children }: LayoutPro | ||
| 15 | 15 | { href: `${repo.base}/settings`, label: "General", icon: <GearIcon /> }, |
| 16 | 16 | { href: `${repo.base}/settings/access`, label: "Collaborators", icon: <PeopleIcon /> }, |
| 17 | 17 | { href: `${repo.base}/settings/labels`, label: "Labels", icon: <TagIcon /> }, |
| 18 | + { href: `${repo.base}/settings/runners`, label: "Runners", icon: <ServerIcon /> }, | |
| 18 | 19 | ]} |
| 19 | 20 | /> |
| 20 | 21 | <div className="min-w-0">{children}</div> |
| @@ -0,0 +1,45 @@ | ||
| 1 | + import type { Metadata } from "next"; | |
| 2 | + import Link from "next/link"; | |
| 3 | + import { createRunnerAction, deleteRunnerAction } from "@/app/actions/ci"; | |
| 4 | + import { NewRunnerForm } from "@/components/ci/new-runner-form"; | |
| 5 | + import { RunnerList } from "@/components/ci/runner-list"; | |
| 6 | + import { PlateSection } from "@/components/settings-plate"; | |
| 7 | + import { PageTitle } from "@/components/ui"; | |
| 8 | + import { getRepo } from "@/lib/repo"; | |
| 9 | + import { serverOrigin } from "@/lib/request-origin"; | |
| 10 | + import { listRunners } from "@/lib/runners"; | |
| 11 | + | |
| 12 | + export const metadata: Metadata = { title: "Runners" }; | |
| 13 | + | |
| 14 | + export default async function RepoRunnersPage({ params }: PageProps<"/[owner]/[repo]/settings/runners">) { | |
| 15 | + const { owner, repo: repoParam } = await params; | |
| 16 | + const repo = await getRepo(owner, repoParam); | |
| 17 | + const target = { owner: repo.owner, name: repo.name }; | |
| 18 | + | |
| 19 | + return ( | |
| 20 | + <div> | |
| 21 | + <PageTitle | |
| 22 | + title="Runners" | |
| 23 | + description={ | |
| 24 | + <> | |
| 25 | + Machines that run this repository’s workflow jobs. Runners under{" "} | |
| 26 | + <Link href="/settings/runners" className="underline underline-offset-2"> | |
| 27 | + your account settings | |
| 28 | + </Link>{" "} | |
| 29 | + run jobs for all your repositories. | |
| 30 | + </> | |
| 31 | + } | |
| 32 | + /> | |
| 33 | + <div className="flex flex-col gap-7"> | |
| 34 | + <RunnerList runners={await listRunners({ repoId: repo.id })} deleteAction={deleteRunnerAction.bind(null, target)} /> | |
| 35 | + <PlateSection title="Register a runner"> | |
| 36 | + <NewRunnerForm | |
| 37 | + action={createRunnerAction.bind(null, target)} | |
| 38 | + origin={await serverOrigin()} | |
| 39 | + scopeText={`This runner will only pick up jobs from ${repo.owner}/${repo.name}.`} | |
| 40 | + /> | |
| 41 | + </PlateSection> | |
| 42 | + </div> | |
| 43 | + </div> | |
| 44 | + ); | |
| 45 | + } | |
| @@ -11,18 +11,18 @@ import { listReposByOwner } from "@/lib/repos"; | ||
| 11 | 11 | import { getUserByUsername } from "@/lib/users"; |
| 12 | 12 | |
| 13 | 13 | export async function generateMetadata({ params }: PageProps<"/[owner]">) { |
| 14 | - const user = getUserByUsername((await params).owner); | |
| 14 | + const user = await getUserByUsername((await params).owner); | |
| 15 | 15 | return { title: user ? (user.name ? `${user.username} (${user.name})` : user.username) : "Not found" }; |
| 16 | 16 | } |
| 17 | 17 | |
| 18 | 18 | const heading = "text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink"; |
| 19 | 19 | |
| 20 | 20 | export default async function ProfilePage({ params, searchParams }: PageProps<"/[owner]">) { |
| 21 | - const user = getUserByUsername((await params).owner); | |
| 21 | + const user = await getUserByUsername((await params).owner); | |
| 22 | 22 | if (!user) notFound(); |
| 23 | 23 | const q = String((await searchParams).q ?? "").trim().toLowerCase(); |
| 24 | 24 | const viewer = await getViewer(); |
| 25 | - const all = await withActivity(listReposByOwner(user.id, viewer?.id ?? null)); | |
| 25 | + const all = await withActivity(await listReposByOwner(user.id, viewer?.id ?? null)); | |
| 26 | 26 | const repos = q ? all.filter((r) => r.name.toLowerCase().includes(q) || r.description.toLowerCase().includes(q)) : all; |
| 27 | 27 | const isSelf = viewer?.id === user.id; |
| 28 | 28 | const publicCount = all.filter((r) => r.visibility === "public").length; |
| @@ -1,19 +1,22 @@ | ||
| 1 | 1 | "use server"; |
| 2 | 2 | |
| 3 | 3 | import { redirect } from "next/navigation"; |
| 4 | - import { endSession, safeNext, startSession } from "@/lib/auth"; | |
| 5 | - import { authenticateUser, createUser, UserError } from "@/lib/users"; | |
| 4 | + import { endSession, safeNext, signIn } from "@/lib/auth"; | |
| 5 | + import { SIGNUPS_ENABLED } from "@/lib/signups"; | |
| 6 | + import { createUser, UserError } from "@/lib/users"; | |
| 6 | 7 | import type { FormState } from "./types"; |
| 7 | 8 | |
| 8 | 9 | export async function signupAction(_prev: FormState, formData: FormData): Promise<FormState> { |
| 10 | + if (!SIGNUPS_ENABLED) return { error: "Sign-ups are closed." }; | |
| 11 | + const password = String(formData.get("password") ?? ""); | |
| 9 | 12 | try { |
| 10 | 13 | const user = await createUser({ |
| 11 | 14 | username: String(formData.get("username") ?? ""), |
| 12 | 15 | email: String(formData.get("email") ?? ""), |
| 13 | - password: String(formData.get("password") ?? ""), | |
| 16 | + password, | |
| 14 | 17 | name: String(formData.get("name") ?? ""), |
| 15 | 18 | }); |
| 16 | - await startSession(user.id); | |
| 19 | + await signIn(user.email, password); | |
| 17 | 20 | } catch (e) { |
| 18 | 21 | if (e instanceof UserError) return { error: e.message }; |
| 19 | 22 | throw e; |
| @@ -22,9 +25,8 @@ export async function signupAction(_prev: FormState, formData: FormData): Promis | ||
| 22 | 25 | } |
| 23 | 26 | |
| 24 | 27 | export async function loginAction(_prev: FormState, formData: FormData): Promise<FormState> { |
| 25 | - const user = await authenticateUser(String(formData.get("login") ?? ""), String(formData.get("password") ?? "")); | |
| 28 | + const user = await signIn(String(formData.get("login") ?? ""), String(formData.get("password") ?? "")); | |
| 26 | 29 | if (!user) return { error: "Incorrect username or password." }; |
| 27 | - await startSession(user.id); | |
| 28 | 30 | redirect(safeNext(formData.get("next"))); |
| 29 | 31 | } |
| 30 | 32 | |
| @@ -0,0 +1,69 @@ | ||
| 1 | + "use server"; | |
| 2 | + | |
| 3 | + import { revalidatePath } from "next/cache"; | |
| 4 | + import { redirect } from "next/navigation"; | |
| 5 | + import { CiError, dispatchWorkflow, rerunWorkflow } from "@/lib/ci"; | |
| 6 | + import { createRunner, deleteRunner, RunnerError, type RunnerScope } from "@/lib/runners"; | |
| 7 | + import { cancelRun, getRun, isFinished } from "@/lib/runs"; | |
| 8 | + import { ActionError, repoForAction, viewerOrThrow } from "./guard"; | |
| 9 | + import type { FormState } from "./types"; | |
| 10 | + | |
| 11 | + /** A repository's runners (`owner/name`, managed by its owner) or the viewer's own (null). */ | |
| 12 | + async function runnerScope(repo: { owner: string; name: string } | null): Promise<{ scope: RunnerScope; path: string }> { | |
| 13 | + if (repo) { | |
| 14 | + const { repo: r } = await repoForAction(repo.owner, repo.name, "admin"); | |
| 15 | + return { scope: { repoId: r.id }, path: `/${r.owner}/${r.name}/settings/runners` }; | |
| 16 | + } | |
| 17 | + const viewer = await viewerOrThrow(); | |
| 18 | + return { scope: { userId: viewer.id }, path: "/settings/runners" }; | |
| 19 | + } | |
| 20 | + | |
| 21 | + export async function createRunnerAction(repo: { owner: string; name: string } | null, _prev: FormState, formData: FormData) { | |
| 22 | + const { scope, path } = await runnerScope(repo); | |
| 23 | + try { | |
| 24 | + const token = await createRunner(scope, { name: String(formData.get("name") ?? ""), labels: String(formData.get("labels") ?? "") }); | |
| 25 | + revalidatePath(path); | |
| 26 | + return { token }; | |
| 27 | + } catch (e) { | |
| 28 | + if (e instanceof RunnerError) return { error: e.message }; | |
| 29 | + throw e; | |
| 30 | + } | |
| 31 | + } | |
| 32 | + | |
| 33 | + export async function deleteRunnerAction(repo: { owner: string; name: string } | null, runnerId: string) { | |
| 34 | + const { scope, path } = await runnerScope(repo); | |
| 35 | + await deleteRunner(scope, String(runnerId)); | |
| 36 | + revalidatePath(path); | |
| 37 | + } | |
| 38 | + | |
| 39 | + export async function dispatchWorkflowAction(owner: string, name: string, workflowPath: string, _prev: FormState, formData: FormData) { | |
| 40 | + let destination: string; | |
| 41 | + try { | |
| 42 | + const { repo, viewer } = await repoForAction(owner, name, "write"); | |
| 43 | + const number = await dispatchWorkflow(repo, workflowPath, String(formData.get("branch") ?? ""), viewer.id); | |
| 44 | + destination = `/${repo.owner}/${repo.name}/actions/runs/${number}`; | |
| 45 | + } catch (e) { | |
| 46 | + if (e instanceof CiError || e instanceof ActionError) return { error: e.message }; | |
| 47 | + throw e; | |
| 48 | + } | |
| 49 | + redirect(destination); | |
| 50 | + } | |
| 51 | + | |
| 52 | + async function runForAction(owner: string, name: string, number: number) { | |
| 53 | + const { repo, viewer } = await repoForAction(owner, name, "write"); | |
| 54 | + const run = await getRun(repo.id, number); | |
| 55 | + if (!run) throw new ActionError("That run doesn't exist."); | |
| 56 | + return { repo, viewer, run }; | |
| 57 | + } | |
| 58 | + | |
| 59 | + export async function cancelRunAction(owner: string, name: string, number: number) { | |
| 60 | + const { repo, run } = await runForAction(owner, name, number); | |
| 61 | + if (!isFinished(run.status)) await cancelRun(run.id); | |
| 62 | + revalidatePath(`/${repo.owner}/${repo.name}/actions`, "layout"); | |
| 63 | + } | |
| 64 | + | |
| 65 | + export async function rerunAction(owner: string, name: string, number: number) { | |
| 66 | + const { repo, viewer, run } = await runForAction(owner, name, number); | |
| 67 | + const next = await rerunWorkflow(repo, run, viewer.id); | |
| 68 | + redirect(`/${repo.owner}/${repo.name}/actions/runs/${next}`); | |
| 69 | + } | |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | "use server"; |
| 2 | 2 | |
| 3 | 3 | import { redirect } from "next/navigation"; |
| 4 | + import { onRefsUpdated, startWorkflowsInBackground } from "@/lib/ci"; | |
| 4 | 5 | import { git, GitError, objectType, revParse } from "@/lib/git"; |
| 5 | 6 | import { cleanRepoPath, commitFiles, isValidBranchName, type FileChange } from "@/lib/git-write"; |
| 6 | 7 | import { encodePath } from "@/lib/format"; |
| @@ -53,14 +54,20 @@ async function commitFromForm( | ||
| 53 | 54 | const author = { name: viewer.name || viewer.username, email: viewer.email }; |
| 54 | 55 | |
| 55 | 56 | let target = branch; |
| 57 | + let commit: string; | |
| 56 | 58 | if (formData.get("target") === "branch") { |
| 57 | 59 | target = str(formData, "newBranch"); |
| 58 | 60 | if (!(await isValidBranchName(repo.dir, target))) throw new ActionError(`${target || "That"} is not a valid branch name.`); |
| 59 | - await commitFiles(repo.dir, { branch: target, parent: head, expectedHead: null, changes, message, author }); | |
| 61 | + commit = await commitFiles(repo.dir, { branch: target, parent: head, expectedHead: null, changes, message, author }); | |
| 60 | 62 | } else { |
| 61 | - await commitFiles(repo.dir, { branch, parent: head, changes, message, author }); | |
| 63 | + commit = await commitFiles(repo.dir, { branch, parent: head, changes, message, author }); | |
| 62 | 64 | } |
| 63 | - markPushed(repo.id); | |
| 65 | + await markPushed(repo.id); | |
| 66 | + // A commit from the browser starts workflows just like a push of it would. | |
| 67 | + const ref = `refs/heads/${target}`; | |
| 68 | + const before = new Map<string, string>(); | |
| 69 | + if (head && target === branch) before.set(ref, head); | |
| 70 | + startWorkflowsInBackground(onRefsUpdated(repo, before, new Map([[ref, commit]]), viewer.id)); | |
| 64 | 71 | return target; |
| 65 | 72 | } |
| 66 | 73 | |
| @@ -13,8 +13,8 @@ export async function viewerOrThrow() { | ||
| 13 | 13 | /** Loads a repository for a server action and checks the viewer's access level. */ |
| 14 | 14 | export async function repoForAction(owner: string, name: string, need: "read" | "write" | "admin") { |
| 15 | 15 | const viewer = await viewerOrThrow(); |
| 16 | - const repo = findRepo(owner, name); | |
| 17 | - const access: Access = repo ? repoAccess(repo, viewer.id) : null; | |
| 16 | + const repo = await findRepo(owner, name); | |
| 17 | + const access: Access = repo ? await repoAccess(repo, viewer.id) : null; | |
| 18 | 18 | if (!repo || !access) throw new ActionError("Repository not found."); |
| 19 | 19 | if (need === "write" && !canWrite(access)) throw new ActionError("You don't have push access to this repository."); |
| 20 | 20 | if (need === "admin" && access !== "admin") throw new ActionError("Only the owner can change these settings."); |
| @@ -37,7 +37,7 @@ function revalidateIssues(repo: RepoRecord) { | ||
| 37 | 37 | * Validates untrusted issue properties from the browser. Only the fields that |
| 38 | 38 | * are present are checked; anything invalid throws. |
| 39 | 39 | */ |
| 40 | - function validate(repo: RepoRecord, patch: IssuePatch): IssuePatch { | |
| 40 | + async function validate(repo: RepoRecord, patch: IssuePatch): Promise<IssuePatch> { | |
| 41 | 41 | const out: IssuePatch = {}; |
| 42 | 42 | if (patch.title !== undefined) { |
| 43 | 43 | const title = String(patch.title).trim().slice(0, 256); |
| @@ -54,7 +54,7 @@ function validate(repo: RepoRecord, patch: IssuePatch): IssuePatch { | ||
| 54 | 54 | out.priority = patch.priority; |
| 55 | 55 | } |
| 56 | 56 | if (patch.assigneeId !== undefined) { |
| 57 | - if (patch.assigneeId !== null && !listMembers(repo.id, repo.ownerId).some((m) => m.id === patch.assigneeId)) { | |
| 57 | + if (patch.assigneeId !== null && !(await listMembers(repo.id, repo.ownerId)).some((m) => m.id === patch.assigneeId)) { | |
| 58 | 58 | throw new ActionError("Only the owner and collaborators can be assigned."); |
| 59 | 59 | } |
| 60 | 60 | out.assigneeId = patch.assigneeId; |
| @@ -69,7 +69,7 @@ function validate(repo: RepoRecord, patch: IssuePatch): IssuePatch { | ||
| 69 | 69 | } |
| 70 | 70 | if (patch.labelIds !== undefined) { |
| 71 | 71 | if (!Array.isArray(patch.labelIds)) throw new ActionError("Invalid labels."); |
| 72 | - const known = new Set(listLabels(repo.id).map((l) => l.id)); | |
| 72 | + const known = new Set((await listLabels(repo.id)).map((l) => l.id)); | |
| 73 | 73 | const ids = [...new Set(patch.labelIds)]; |
| 74 | 74 | if (!ids.every((id) => known.has(id))) throw new ActionError("Unknown label."); |
| 75 | 75 | out.labelIds = ids; |
| @@ -92,9 +92,9 @@ export async function createIssueAction( | ||
| 92 | 92 | ): Promise<ActionResult & { number?: number }> { |
| 93 | 93 | try { |
| 94 | 94 | const { repo, viewer, access } = await repoForAction(owner, name, "read"); |
| 95 | - const { title, body, ...properties } = validate(repo, { ...input, sortOrder: undefined }); | |
| 95 | + const { title, body, ...properties } = await validate(repo, { ...input, sortOrder: undefined }); | |
| 96 | 96 | if (!title) return { error: "Title can't be blank." }; |
| 97 | - const number = createIssue(repo.id, viewer.id, { | |
| 97 | + const number = await createIssue(repo.id, viewer.id, { | |
| 98 | 98 | title, |
| 99 | 99 | body: body ?? "", |
| 100 | 100 | ...(canWrite(access) ? properties : {}), |
| @@ -110,11 +110,11 @@ export async function createIssueAction( | ||
| 110 | 110 | export async function updateIssueAction(owner: string, name: string, number: number, patch: IssuePatch): Promise<ActionResult> { |
| 111 | 111 | try { |
| 112 | 112 | const { repo, viewer, access } = await repoForAction(owner, name, "read"); |
| 113 | - const issue = getIssue(repo.id, number); | |
| 113 | + const issue = await getIssue(repo.id, number); | |
| 114 | 114 | if (!issue) return { error: "Issue not found." }; |
| 115 | 115 | if (!canWrite(access)) return { error: "Only collaborators can change issues. You can still comment." }; |
| 116 | - const clean = validate(repo, patch); | |
| 117 | - updateIssue(issue, clean, viewer.id); | |
| 116 | + const clean = await validate(repo, patch); | |
| 117 | + await updateIssue(issue, clean, viewer.id); | |
| 118 | 118 | revalidateIssues(repo); |
| 119 | 119 | return {}; |
| 120 | 120 | } catch (e) { |
| @@ -125,21 +125,21 @@ export async function updateIssueAction(owner: string, name: string, number: num | ||
| 125 | 125 | |
| 126 | 126 | export async function commentAction(owner: string, name: string, number: number, formData: FormData) { |
| 127 | 127 | const { repo, viewer } = await repoForAction(owner, name, "read"); |
| 128 | - const issue = getIssue(repo.id, number); | |
| 128 | + const issue = await getIssue(repo.id, number); | |
| 129 | 129 | if (!issue) throw new ActionError("Issue not found"); |
| 130 | 130 | const body = String(formData.get("body") ?? "").trim().slice(0, MAX_BODY); |
| 131 | - if (body) addComment(issue.id, { body, authorId: viewer.id }); | |
| 131 | + if (body) await addComment(issue.id, { body, authorId: viewer.id }); | |
| 132 | 132 | revalidateIssues(repo); |
| 133 | 133 | } |
| 134 | 134 | |
| 135 | 135 | export async function deleteIssueAction(owner: string, name: string, number: number) { |
| 136 | 136 | const { repo, viewer, access } = await repoForAction(owner, name, "read"); |
| 137 | - const issue = getIssue(repo.id, number); | |
| 137 | + const issue = await getIssue(repo.id, number); | |
| 138 | 138 | if (!issue) throw new ActionError("Issue not found."); |
| 139 | 139 | if (issue.authorId !== viewer.id && access !== "admin") { |
| 140 | 140 | throw new ActionError("Only the issue's creator and the repository owner can delete it."); |
| 141 | 141 | } |
| 142 | - deleteIssue(issue.id); | |
| 142 | + await deleteIssue(issue.id); | |
| 143 | 143 | revalidateIssues(repo); |
| 144 | 144 | redirect(`/${repo.owner}/${repo.name}/issues`); |
| 145 | 145 | } |
| @@ -151,7 +151,7 @@ export async function createLabelAction( | ||
| 151 | 151 | ): Promise<ActionResult & { label?: IssueLabel }> { |
| 152 | 152 | try { |
| 153 | 153 | const { repo } = await repoForAction(owner, name, "write"); |
| 154 | - const label = createLabel(repo.id, { name: String(input.name ?? ""), color: String(input.color ?? "") }); | |
| 154 | + const label = await createLabel(repo.id, { name: String(input.name ?? ""), color: String(input.color ?? "") }); | |
| 155 | 155 | revalidateIssues(repo); |
| 156 | 156 | return { label }; |
| 157 | 157 | } catch (e) { |
| @@ -169,8 +169,8 @@ export async function createLabelFormAction(owner: string, name: string, _prev: | ||
| 169 | 169 | return result.error ? { error: result.error } : { success: `Created label "${result.label!.name}".` }; |
| 170 | 170 | } |
| 171 | 171 | |
| 172 | - export async function deleteLabelAction(owner: string, name: string, labelId: number) { | |
| 172 | + export async function deleteLabelAction(owner: string, name: string, labelId: string) { | |
| 173 | 173 | const { repo } = await repoForAction(owner, name, "write"); |
| 174 | - deleteLabel(repo.id, labelId); | |
| 174 | + await deleteLabel(repo.id, String(labelId)); | |
| 175 | 175 | revalidateIssues(repo); |
| 176 | 176 | } |
| @@ -47,7 +47,7 @@ export async function createRepoAction(_prev: FormState, formData: FormData): Pr | ||
| 47 | 47 | export async function updateRepoAction(owner: string, name: string, _prev: FormState, formData: FormData) { |
| 48 | 48 | return attempt(async () => { |
| 49 | 49 | const { repo } = await repoForAction(owner, name, "admin"); |
| 50 | - updateRepo(repo.id, { | |
| 50 | + await updateRepo(repo.id, { | |
| 51 | 51 | description: String(formData.get("description") ?? ""), |
| 52 | 52 | visibility: visibilityOf(formData.get("visibility")), |
| 53 | 53 | }); |
| @@ -61,7 +61,7 @@ export async function updateRepoImageAction(owner: string, name: string, _prev: | ||
| 61 | 61 | const { repo } = await repoForAction(owner, name, "admin"); |
| 62 | 62 | const file = formData.get("image"); |
| 63 | 63 | if (!(file instanceof File)) throw new RepoError("Choose an image to upload."); |
| 64 | - setRepoImage(repo.id, new Uint8Array(await file.arrayBuffer())); | |
| 64 | + await setRepoImage(repo.id, new Uint8Array(await file.arrayBuffer())); | |
| 65 | 65 | // The image shows up in sidebars and lists everywhere, not just on this repository's pages. |
| 66 | 66 | revalidatePath("/", "layout"); |
| 67 | 67 | return { success: "Repository image updated." }; |
| @@ -70,7 +70,7 @@ export async function updateRepoImageAction(owner: string, name: string, _prev: | ||
| 70 | 70 | |
| 71 | 71 | export async function removeRepoImageAction(owner: string, name: string) { |
| 72 | 72 | const { repo } = await repoForAction(owner, name, "admin"); |
| 73 | - removeRepoImage(repo.id); | |
| 73 | + await removeRepoImage(repo.id); | |
| 74 | 74 | revalidatePath("/", "layout"); |
| 75 | 75 | } |
| 76 | 76 | |
| @@ -101,14 +101,14 @@ export async function addCollaboratorAction(owner: string, name: string, _prev: | ||
| 101 | 101 | return attempt(async () => { |
| 102 | 102 | const { repo } = await repoForAction(owner, name, "admin"); |
| 103 | 103 | const username = String(formData.get("username") ?? "").trim(); |
| 104 | - addCollaborator(repo, username); | |
| 104 | + await addCollaborator(repo, username); | |
| 105 | 105 | revalidatePath(`/${repo.owner}/${repo.name}/settings`); |
| 106 | 106 | return { success: `${username} can now push to this repository.` }; |
| 107 | 107 | }); |
| 108 | 108 | } |
| 109 | 109 | |
| 110 | - export async function removeCollaboratorAction(owner: string, name: string, userId: number) { | |
| 110 | + export async function removeCollaboratorAction(owner: string, name: string, userId: string) { | |
| 111 | 111 | const { repo } = await repoForAction(owner, name, "admin"); |
| 112 | - removeCollaborator(repo.id, userId); | |
| 112 | + await removeCollaborator(repo.id, String(userId)); | |
| 113 | 113 | revalidatePath(`/${repo.owner}/${repo.name}/settings`); |
| 114 | 114 | } |
| @@ -1,6 +1,7 @@ | ||
| 1 | 1 | "use server"; |
| 2 | 2 | |
| 3 | 3 | import { revalidatePath } from "next/cache"; |
| 4 | + import { renewSession, signIn } from "@/lib/auth"; | |
| 4 | 5 | import { addSshKey, deleteSshKey, KeyError } from "@/lib/keys"; |
| 5 | 6 | import { canWrite, findRepoById, repoAccess } from "@/lib/repos"; |
| 6 | 7 | import { createToken, deleteToken, TokenError, type TokenPermission } from "@/lib/tokens"; |
| @@ -11,11 +12,13 @@ import type { FormState } from "./types"; | ||
| 11 | 12 | export async function updateProfileAction(_prev: FormState, formData: FormData): Promise<FormState> { |
| 12 | 13 | const viewer = await viewerOrThrow(); |
| 13 | 14 | try { |
| 14 | - updateProfile(viewer.id, { | |
| 15 | + const { emailChanged } = await updateProfile(viewer.id, { | |
| 15 | 16 | name: String(formData.get("name") ?? ""), |
| 16 | 17 | email: String(formData.get("email") ?? ""), |
| 17 | 18 | bio: String(formData.get("bio") ?? ""), |
| 18 | 19 | }); |
| 20 | + // PocketBase revokes a user's tokens when their email changes; keep this browser signed in. | |
| 21 | + if (emailChanged) await renewSession(viewer.id); | |
| 19 | 22 | } catch (e) { |
| 20 | 23 | if (e instanceof UserError) return { error: e.message }; |
| 21 | 24 | throw e; |
| @@ -32,14 +35,16 @@ export async function changePasswordAction(_prev: FormState, formData: FormData) | ||
| 32 | 35 | if (next !== formData.get("confirm")) return { error: "The new passwords don't match." }; |
| 33 | 36 | const problem = validatePassword(next); |
| 34 | 37 | if (problem) return { error: problem }; |
| 38 | + // This signs the user out everywhere, so sign this browser back in. | |
| 35 | 39 | await setPassword(viewer.id, next); |
| 40 | + await signIn(viewer.email, next); | |
| 36 | 41 | return { success: "Password changed." }; |
| 37 | 42 | } |
| 38 | 43 | |
| 39 | 44 | export async function addSshKeyAction(_prev: FormState, formData: FormData): Promise<FormState> { |
| 40 | 45 | const viewer = await viewerOrThrow(); |
| 41 | 46 | try { |
| 42 | - addSshKey(viewer.id, String(formData.get("title") ?? ""), String(formData.get("key") ?? "")); | |
| 47 | + await addSshKey(viewer.id, String(formData.get("title") ?? ""), String(formData.get("key") ?? "")); | |
| 43 | 48 | } catch (e) { |
| 44 | 49 | if (e instanceof KeyError) return { error: e.message }; |
| 45 | 50 | throw e; |
| @@ -48,9 +53,9 @@ export async function addSshKeyAction(_prev: FormState, formData: FormData): Pro | ||
| 48 | 53 | return { success: "SSH key added." }; |
| 49 | 54 | } |
| 50 | 55 | |
| 51 | - export async function deleteSshKeyAction(keyId: number) { | |
| 56 | + export async function deleteSshKeyAction(keyId: string) { | |
| 52 | 57 | const viewer = await viewerOrThrow(); |
| 53 | - deleteSshKey(viewer.id, keyId); | |
| 58 | + await deleteSshKey(viewer.id, String(keyId)); | |
| 54 | 59 | revalidatePath("/settings/keys"); |
| 55 | 60 | } |
| 56 | 61 | |
| @@ -58,19 +63,17 @@ export async function createTokenAction(_prev: FormState, formData: FormData): P | ||
| 58 | 63 | const viewer = await viewerOrThrow(); |
| 59 | 64 | const permission: TokenPermission = formData.get("permission") === "read" ? "read" : "write"; |
| 60 | 65 | const expiry = String(formData.get("expiry") ?? "30"); |
| 61 | - let repoIds: number[] | null = null; | |
| 66 | + let repoIds: string[] | null = null; | |
| 62 | 67 | if (formData.get("scope") === "selected") { |
| 63 | - repoIds = formData | |
| 64 | - .getAll("repos") | |
| 65 | - .map(Number) | |
| 66 | - .filter((id) => { | |
| 67 | - const repo = findRepoById(id); | |
| 68 | - const access = repo ? repoAccess(repo, viewer.id) : null; | |
| 69 | - return access && (permission === "read" || canWrite(access)); | |
| 70 | - }); | |
| 68 | + repoIds = []; | |
| 69 | + for (const id of new Set(formData.getAll("repos").map(String))) { | |
| 70 | + const repo = await findRepoById(id); | |
| 71 | + const access = repo ? await repoAccess(repo, viewer.id) : null; | |
| 72 | + if (access && (permission === "read" || canWrite(access))) repoIds.push(id); | |
| 73 | + } | |
| 71 | 74 | } |
| 72 | 75 | try { |
| 73 | - const token = createToken(viewer.id, { | |
| 76 | + const token = await createToken(viewer.id, { | |
| 74 | 77 | name: String(formData.get("name") ?? ""), |
| 75 | 78 | permission, |
| 76 | 79 | expiresInDays: expiry === "never" ? null : Number(expiry) || 30, |
| @@ -84,8 +87,8 @@ export async function createTokenAction(_prev: FormState, formData: FormData): P | ||
| 84 | 87 | } |
| 85 | 88 | } |
| 86 | 89 | |
| 87 | - export async function deleteTokenAction(tokenId: number) { | |
| 90 | + export async function deleteTokenAction(tokenId: string) { | |
| 88 | 91 | const viewer = await viewerOrThrow(); |
| 89 | - deleteToken(viewer.id, tokenId); | |
| 92 | + await deleteToken(viewer.id, String(tokenId)); | |
| 90 | 93 | revalidatePath("/settings/tokens"); |
| 91 | 94 | } |
| @@ -0,0 +1,12 @@ | ||
| 1 | + import { db } from "@/lib/db"; | |
| 2 | + | |
| 3 | + /** Liveness check for containers and load balancers. Also starts PocketBase if it isn't running yet. */ | |
| 4 | + export async function GET() { | |
| 5 | + try { | |
| 6 | + await db(); | |
| 7 | + return Response.json({ ok: true }); | |
| 8 | + } catch (e) { | |
| 9 | + console.error("[health]", e); | |
| 10 | + return Response.json({ ok: false }, { status: 503 }); | |
| 11 | + } | |
| 12 | + } | |
| @@ -0,0 +1,31 @@ | ||
| 1 | + import { json, originOf, readBody, runnerFromRequest } from "@/lib/runner-api"; | |
| 2 | + import { touchRunner } from "@/lib/runners"; | |
| 3 | + import { claimJob } from "@/lib/runs"; | |
| 4 | + | |
| 5 | + /** How long a claim waits for a job to turn up before answering "nothing yet". */ | |
| 6 | + const MAX_WAIT_MS = 25_000; | |
| 7 | + | |
| 8 | + const sleep = (ms: number, signal: AbortSignal) => | |
| 9 | + new Promise<void>((resolve) => { | |
| 10 | + const timer = setTimeout(resolve, ms); | |
| 11 | + signal.addEventListener("abort", () => (clearTimeout(timer), resolve()), { once: true }); | |
| 12 | + }); | |
| 13 | + | |
| 14 | + /** A runner asking for work: answers with a job as soon as one it can run is queued (long polling). */ | |
| 15 | + export async function POST(request: Request) { | |
| 16 | + const info = await readBody(request); | |
| 17 | + const origin = originOf(request); | |
| 18 | + const deadline = Date.now() + MAX_WAIT_MS; | |
| 19 | + | |
| 20 | + for (;;) { | |
| 21 | + // Checked every round: the runner's labels may be edited, or the runner removed, while it waits. | |
| 22 | + const runner = await runnerFromRequest(request); | |
| 23 | + if (runner instanceof Response) return runner; | |
| 24 | + await touchRunner(runner.id, info); | |
| 25 | + if (request.signal.aborted) return new Response(null, { status: 204 }); | |
| 26 | + const job = await claimJob(runner, origin); | |
| 27 | + if (job) return json({ job }); | |
| 28 | + if (Date.now() >= deadline) return new Response(null, { status: 204 }); | |
| 29 | + await sleep(1000, request.signal); | |
| 30 | + } | |
| 31 | + } | |
| @@ -0,0 +1,28 @@ | ||
| 1 | + import { json, readBody, runnerFromRequest } from "@/lib/runner-api"; | |
| 2 | + import { touchRunner } from "@/lib/runners"; | |
| 3 | + import { updateJob, type StepStatus } from "@/lib/runs"; | |
| 4 | + | |
| 5 | + const STEP_STATUSES = new Set<StepStatus>(["pending", "in_progress", "success", "failure", "cancelled", "skipped"]); | |
| 6 | + const JOB_RESULTS = new Set(["success", "failure", "cancelled"]); | |
| 7 | + | |
| 8 | + /** A runner reporting progress on the job it holds: step states, output, and finally the result. */ | |
| 9 | + export async function POST(request: Request, ctx: RouteContext<"/api/runner/jobs/[id]">) { | |
| 10 | + const runner = await runnerFromRequest(request); | |
| 11 | + if (runner instanceof Response) return runner; | |
| 12 | + await touchRunner(runner.id, {}); | |
| 13 | + const body = await readBody(request); | |
| 14 | + | |
| 15 | + const steps = (Array.isArray(body.steps) ? body.steps : []).flatMap((s) => | |
| 16 | + Number.isInteger(s?.index) && STEP_STATUSES.has(s?.status) | |
| 17 | + ? [{ index: s.index as number, status: s.status as StepStatus, exitCode: Number.isInteger(s.exitCode) ? (s.exitCode as number) : null }] | |
| 18 | + : [], | |
| 19 | + ); | |
| 20 | + const logs = (Array.isArray(body.logs) ? body.logs : []).flatMap((l) => | |
| 21 | + Number.isInteger(l?.step) && typeof l?.text === "string" ? [{ step: l.step as number, text: l.text as string }] : [], | |
| 22 | + ); | |
| 23 | + const status = typeof body.status === "string" && JOB_RESULTS.has(body.status) ? (body.status as "success" | "failure" | "cancelled") : undefined; | |
| 24 | + | |
| 25 | + const result = await updateJob(runner, (await ctx.params).id, { steps, logs, status }); | |
| 26 | + if (!result) return json({ error: "This runner doesn't hold that job (it finished, was cancelled or timed out)." }, 409); | |
| 27 | + return json(result); | |
| 28 | + } | |
| @@ -13,7 +13,7 @@ export const metadata: Metadata = { title: "Explore" }; | ||
| 13 | 13 | export default async function ExplorePage({ searchParams }: PageProps<"/explore">) { |
| 14 | 14 | const q = String((await searchParams).q ?? "").trim(); |
| 15 | 15 | const viewer = await getViewer(); |
| 16 | - const repos = await withActivity(searchRepos(q, viewer?.id ?? null)); | |
| 16 | + const repos = await withActivity(await searchRepos(q, viewer?.id ?? null)); | |
| 17 | 17 | |
| 18 | 18 | return ( |
| 19 | 19 | <main className="w-full"> |
| @@ -6,6 +6,7 @@ import { LoginForm } from "@/components/auth-forms"; | ||
| 6 | 6 | import { BRAND, LogoMark } from "@/components/brand"; |
| 7 | 7 | import { Box } from "@/components/ui"; |
| 8 | 8 | import { getViewer, safeNext } from "@/lib/auth"; |
| 9 | + import { SIGNUPS_ENABLED } from "@/lib/signups"; | |
| 9 | 10 | |
| 10 | 11 | export const metadata: Metadata = { title: "Sign in" }; |
| 11 | 12 | |
| @@ -28,10 +29,12 @@ export default async function LoginPage({ searchParams }: PageProps<"/login">) { | ||
| 28 | 29 | <Box className="mt-8 p-6"> |
| 29 | 30 | <LoginForm next={next} /> |
| 30 | 31 | </Box> |
| 31 | - <p className="mt-6 text-center type-body text-ink2"> | |
| 32 | - New to {BRAND.name}?{" "} | |
| 33 | - <Link render={<NextLink href={`/signup${next !== "/" ? `?next=${encodeURIComponent(next)}` : ""}`} />}>Create an account</Link> | |
| 34 | - </p> | |
| 32 | + {SIGNUPS_ENABLED && ( | |
| 33 | + <p className="mt-6 text-center type-body text-ink2"> | |
| 34 | + New to {BRAND.name}?{" "} | |
| 35 | + <Link render={<NextLink href={`/signup${next !== "/" ? `?next=${encodeURIComponent(next)}` : ""}`} />}>Create an account</Link> | |
| 36 | + </p> | |
| 37 | + )} | |
| 35 | 38 | </div> |
| 36 | 39 | </main> |
| 37 | 40 | ); |
| @@ -24,6 +24,7 @@ import { getViewer } from "@/lib/auth"; | ||
| 24 | 24 | import { shortSha, timeAgo } from "@/lib/format"; |
| 25 | 25 | import { recentActivity, withActivity } from "@/lib/listing"; |
| 26 | 26 | import { listReposForUser, searchRepos } from "@/lib/repos"; |
| 27 | + import { SIGNUPS_ENABLED } from "@/lib/signups"; | |
| 27 | 28 | import { sshCloneUrl } from "@/lib/ssh-config"; |
| 28 | 29 | import type { User } from "@/lib/users"; |
| 29 | 30 | |
| @@ -63,9 +64,15 @@ function Landing({ sshUrl }: { sshUrl: string }) { | ||
| 63 | 64 | {BRAND.name} hosts git repositories for your whole team. Push over SSH or HTTPS, edit in the browser, and keep private work private. |
| 64 | 65 | </p> |
| 65 | 66 | <div className="mt-9 flex flex-wrap justify-center gap-3"> |
| 66 | - <ButtonLink href="/signup" variant="primary" icon={<PlusIcon />}> | |
| 67 | - Create Your Account | |
| 68 | - </ButtonLink> | |
| 67 | + {SIGNUPS_ENABLED ? ( | |
| 68 | + <ButtonLink href="/signup" variant="primary" icon={<PlusIcon />}> | |
| 69 | + Create Your Account | |
| 70 | + </ButtonLink> | |
| 71 | + ) : ( | |
| 72 | + <ButtonLink href="/login" variant="primary" icon={<ArrowRightIcon />}> | |
| 73 | + Sign In | |
| 74 | + </ButtonLink> | |
| 75 | + )} | |
| 69 | 76 | <ButtonLink href="/explore" icon={<CompassIcon />}> |
| 70 | 77 | Explore Repositories |
| 71 | 78 | </ButtonLink> |
| @@ -100,8 +107,8 @@ function Landing({ sshUrl }: { sshUrl: string }) { | ||
| 100 | 107 | <p className="type-label engraved">Get started</p> |
| 101 | 108 | <h2 className={`mt-3 text-[30px] leading-[1.1] ${displayHeading}`}>Plant your first repository</h2> |
| 102 | 109 | <p className="mt-2 type-lead text-ink2">It takes less than a minute.</p> |
| 103 | - <ButtonLink href="/signup" variant="primary" icon={<ArrowRightIcon />} className="mt-7"> | |
| 104 | - Get Started for Free | |
| 110 | + <ButtonLink href={SIGNUPS_ENABLED ? "/signup" : "/login"} variant="primary" icon={<ArrowRightIcon />} className="mt-7"> | |
| 111 | + {SIGNUPS_ENABLED ? "Get Started for Free" : "Sign In"} | |
| 105 | 112 | </ButtonLink> |
| 106 | 113 | </Surface> |
| 107 | 114 | </section> |
| @@ -118,10 +125,10 @@ function greeting() { | ||
| 118 | 125 | const liftRow = "rounded-row transition-row outline-none hover:recipe-row-list-hover focus-visible:recipe-row-list-hover focus-visible:focus-ring-flush"; |
| 119 | 126 | |
| 120 | 127 | async function Dashboard({ viewer }: { viewer: User }) { |
| 121 | - const mine = listReposForUser(viewer.id); | |
| 128 | + const [mine, recent] = await Promise.all([listReposForUser(viewer.id), searchRepos("", viewer.id, 30)]); | |
| 122 | 129 | const [activity, explore] = await Promise.all([ |
| 123 | 130 | recentActivity(mine), |
| 124 | - withActivity(searchRepos("", viewer.id, 30).filter((r) => r.ownerId !== viewer.id && r.visibility === "public").slice(0, 5)), | |
| 131 | + withActivity(recent.filter((r) => r.ownerId !== viewer.id && r.visibility === "public").slice(0, 5)), | |
| 125 | 132 | ]); |
| 126 | 133 | |
| 127 | 134 | return ( |
| @@ -18,7 +18,7 @@ export const metadata: Metadata = { title: "SSH keys" }; | ||
| 18 | 18 | |
| 19 | 19 | export default async function KeysPage() { |
| 20 | 20 | const viewer = await requireViewer(); |
| 21 | - const keys = listSshKeys(viewer.id); | |
| 21 | + const keys = await listSshKeys(viewer.id); | |
| 22 | 22 | const host = (await headers()).get("host") ?? "localhost:3000"; |
| 23 | 23 | const test = sshTestCommand(host); |
| 24 | 24 | |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { SettingsNav } from "@/components/settings-nav"; |
| 2 | - import { KeyIcon, PersonIcon, ShieldLockIcon } from "@/components/icons"; | |
| 2 | + import { KeyIcon, PersonIcon, ServerIcon, ShieldLockIcon } from "@/components/icons"; | |
| 3 | 3 | import { Avatar } from "@/components/ui"; |
| 4 | 4 | import { requireViewer } from "@/lib/auth"; |
| 5 | 5 | |
| @@ -21,6 +21,7 @@ export default async function SettingsLayout({ children }: LayoutProps<"/setting | ||
| 21 | 21 | { href: "/settings/profile", label: "Public profile", icon: <PersonIcon /> }, |
| 22 | 22 | { href: "/settings/keys", label: "SSH keys", icon: <KeyIcon /> }, |
| 23 | 23 | { href: "/settings/tokens", label: "API tokens", icon: <ShieldLockIcon /> }, |
| 24 | + { href: "/settings/runners", label: "Runners", icon: <ServerIcon /> }, | |
| 24 | 25 | ]} |
| 25 | 26 | /> |
| 26 | 27 | <div className="min-w-0">{children}</div> |
| @@ -0,0 +1,33 @@ | ||
| 1 | + import type { Metadata } from "next"; | |
| 2 | + import { createRunnerAction, deleteRunnerAction } from "@/app/actions/ci"; | |
| 3 | + import { NewRunnerForm } from "@/components/ci/new-runner-form"; | |
| 4 | + import { RunnerList } from "@/components/ci/runner-list"; | |
| 5 | + import { PlateSection } from "@/components/settings-plate"; | |
| 6 | + import { PageTitle } from "@/components/ui"; | |
| 7 | + import { requireViewer } from "@/lib/auth"; | |
| 8 | + import { serverOrigin } from "@/lib/request-origin"; | |
| 9 | + import { listRunners } from "@/lib/runners"; | |
| 10 | + | |
| 11 | + export const metadata: Metadata = { title: "Runners" }; | |
| 12 | + | |
| 13 | + export default async function RunnersPage() { | |
| 14 | + const viewer = await requireViewer("/settings/runners"); | |
| 15 | + return ( | |
| 16 | + <div> | |
| 17 | + <PageTitle | |
| 18 | + title="Runners" | |
| 19 | + description="Machines that run workflow jobs for every repository you own. A repository can also have its own runners, under its settings." | |
| 20 | + /> | |
| 21 | + <div className="flex flex-col gap-7"> | |
| 22 | + <RunnerList runners={await listRunners({ userId: viewer.id })} deleteAction={deleteRunnerAction.bind(null, null)} /> | |
| 23 | + <PlateSection title="Register a runner"> | |
| 24 | + <NewRunnerForm | |
| 25 | + action={createRunnerAction.bind(null, null)} | |
| 26 | + origin={await serverOrigin()} | |
| 27 | + scopeText="This runner will pick up jobs from all repositories you own, including ones you create later." | |
| 28 | + /> | |
| 29 | + </PlateSection> | |
| 30 | + </div> | |
| 31 | + </div> | |
| 32 | + ); | |
| 33 | + } | |
| @@ -8,7 +8,7 @@ import { ChoiceCards, CopyIconButton, TextField } from "@/components/settings-fi | ||
| 8 | 8 | import { PlateBody, PlateFooter } from "@/components/settings-plate"; |
| 9 | 9 | import { Alert, Button } from "@/components/ui"; |
| 10 | 10 | |
| 11 | - type RepoOption = { id: number; fullName: string; writable: boolean }; | |
| 11 | + type RepoOption = { id: string; fullName: string; writable: boolean }; | |
| 12 | 12 | |
| 13 | 13 | const expiries = [ |
| 14 | 14 | { value: "7", label: "7 days" }, |
| @@ -32,12 +32,14 @@ function Expiry({ expiresAt, expired }: { expiresAt: number | null; expired: boo | ||
| 32 | 32 | |
| 33 | 33 | export default async function TokensPage() { |
| 34 | 34 | const viewer = await requireViewer(); |
| 35 | - const tokens = listTokens(viewer.id); | |
| 36 | - const repos = listReposForUser(viewer.id).map((r) => ({ | |
| 37 | - id: r.id, | |
| 38 | - fullName: `${r.owner}/${r.name}`, | |
| 39 | - writable: canWrite(repoAccess(r, viewer.id)), | |
| 40 | - })); | |
| 35 | + const tokens = await listTokens(viewer.id); | |
| 36 | + const repos = await Promise.all( | |
| 37 | + (await listReposForUser(viewer.id)).map(async (r) => ({ | |
| 38 | + id: r.id, | |
| 39 | + fullName: `${r.owner}/${r.name}`, | |
| 40 | + writable: canWrite(await repoAccess(r, viewer.id)), | |
| 41 | + })), | |
| 42 | + ); | |
| 41 | 43 | const h = await headers(); |
| 42 | 44 | const origin = `${h.get("x-forwarded-proto") ?? "http"}://${h.get("host") ?? "localhost:3000"}`; |
| 43 | 45 | |
| @@ -1,10 +1,13 @@ | ||
| 1 | 1 | import type { Metadata } from "next"; |
| 2 | + import NextLink from "next/link"; | |
| 2 | 3 | import { redirect } from "next/navigation"; |
| 4 | + import { Link } from "@unlocalhosted/metalui"; | |
| 3 | 5 | import { SignupForm } from "@/components/auth-forms"; |
| 4 | 6 | import { BRAND } from "@/components/brand"; |
| 5 | 7 | import { KeyIcon, PencilIcon, PeopleIcon } from "@/components/icons"; |
| 6 | 8 | import { Box } from "@/components/ui"; |
| 7 | 9 | import { getViewer, safeNext } from "@/lib/auth"; |
| 10 | + import { SIGNUPS_ENABLED } from "@/lib/signups"; | |
| 8 | 11 | |
| 9 | 12 | export const metadata: Metadata = { title: "Join" }; |
| 10 | 13 | |
| @@ -46,7 +49,14 @@ export default async function SignupPage({ searchParams }: PageProps<"/signup">) | ||
| 46 | 49 | </div> |
| 47 | 50 | <Box className="animate-fade-up p-7 [animation-delay:100ms]"> |
| 48 | 51 | <h2 className="mb-5 type-display text-ink">Create your account</h2> |
| 49 | - <SignupForm next={next} /> | |
| 52 | + {SIGNUPS_ENABLED ? ( | |
| 53 | + <SignupForm next={next} /> | |
| 54 | + ) : ( | |
| 55 | + <p className="type-body text-ink2"> | |
| 56 | + Sign-ups are closed on this instance. Ask an administrator for an account, or{" "} | |
| 57 | + <Link render={<NextLink href={`/login${next !== "/" ? `?next=${encodeURIComponent(next)}` : ""}`} />}>sign in</Link>. | |
| 58 | + </p> | |
| 59 | + )} | |
| 50 | 60 | </Box> |
| 51 | 61 | </div> |
| 52 | 62 | </main> |
| @@ -13,6 +13,7 @@ | ||
| 13 | 13 | "@uiw/react-codemirror": "^4.25.12", |
| 14 | 14 | "@unlocalhosted/metalui": "^0.3.3", |
| 15 | 15 | "next": "16.3.7", |
| 16 | + "pocketbase": "^0.28.1", | |
| 16 | 17 | "react": "19.2.8", |
| 17 | 18 | "react-dom": "19.2.8", |
| 18 | 19 | "react-markdown": "^10.1.0", |
| @@ -22,6 +23,7 @@ | ||
| 22 | 23 | "server-only": "^0.0.1", |
| 23 | 24 | "shiki": "^4.4.3", |
| 24 | 25 | "ssh2": "^1.17.0", |
| 26 | + "yaml": "^2.9.1", | |
| 25 | 27 | }, |
| 26 | 28 | "devDependencies": { |
| 27 | 29 | "@tailwindcss/postcss": "^4", |
| @@ -1055,6 +1057,8 @@ | ||
| 1055 | 1057 | |
| 1056 | 1058 | "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], |
| 1057 | 1059 | |
| 1060 | + "pocketbase": ["pocketbase@0.28.1", "", {}, "sha512-/3ihkq+rvfcs0MQgrK4sEElOg6gfenHW9S/O+3BSO+V1yT+4B10+9aT22uTQUPqze9ItwNtwWyY5ZmdgVCTdVA=="], | |
| 1061 | + | |
| 1058 | 1062 | "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], |
| 1059 | 1063 | |
| 1060 | 1064 | "postcss": ["postcss@8.5.28", "", { "dependencies": { "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A=="], |
| @@ -1269,6 +1273,8 @@ | ||
| 1269 | 1273 | |
| 1270 | 1274 | "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], |
| 1271 | 1275 | |
| 1276 | + "yaml": ["yaml@2.9.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw=="], | |
| 1277 | + | |
| 1272 | 1278 | "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], |
| 1273 | 1279 | |
| 1274 | 1280 | "zod": ["zod@4.6.5", "", {}, "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q=="], |
| @@ -0,0 +1,52 @@ | ||
| 1 | + "use client"; | |
| 2 | + | |
| 3 | + import { useActionState } from "react"; | |
| 4 | + import { Popover, Select } from "@unlocalhosted/metalui"; | |
| 5 | + import type { FormState } from "@/app/actions/types"; | |
| 6 | + import { BranchIcon, ChevronDownIcon, PlayIcon } from "@/components/icons"; | |
| 7 | + import { Alert, Button } from "@/components/ui"; | |
| 8 | + | |
| 9 | + /** "Run workflow": a popover choosing the branch to run a `workflow_dispatch` workflow on. */ | |
| 10 | + export function DispatchButton({ | |
| 11 | + action, | |
| 12 | + branches, | |
| 13 | + defaultBranch, | |
| 14 | + }: { | |
| 15 | + action: (prev: FormState, formData: FormData) => Promise<FormState>; | |
| 16 | + branches: string[]; | |
| 17 | + defaultBranch: string; | |
| 18 | + }) { | |
| 19 | + const [state, formAction, pending] = useActionState(action, undefined); | |
| 20 | + return ( | |
| 21 | + <Popover> | |
| 22 | + <Popover.Trigger> | |
| 23 | + <Button type="button" icon={<PlayIcon />}> | |
| 24 | + Run workflow | |
| 25 | + <span aria-hidden className="-mr-1 inline-grid opacity-70"> | |
| 26 | + <ChevronDownIcon width={12} height={12} /> | |
| 27 | + </span> | |
| 28 | + </Button> | |
| 29 | + </Popover.Trigger> | |
| 30 | + <Popover.Content side="bottom" align="end" className="w-[300px]"> | |
| 31 | + <Popover.Title>Run this workflow</Popover.Title> | |
| 32 | + <Popover.Body> | |
| 33 | + <form action={formAction} className="flex flex-col gap-3"> | |
| 34 | + <label className="flex flex-col gap-1.5"> | |
| 35 | + <span className="type-ui text-ink">Branch</span> | |
| 36 | + <Select | |
| 37 | + name="branch" | |
| 38 | + aria-label="Branch" | |
| 39 | + defaultValue={defaultBranch} | |
| 40 | + options={branches.map((b) => ({ value: b, label: b, lead: <BranchIcon /> }))} | |
| 41 | + /> | |
| 42 | + </label> | |
| 43 | + {state?.error && <Alert>{state.error}</Alert>} | |
| 44 | + <Button variant="primary" icon={<PlayIcon />} disabled={pending}> | |
| 45 | + {pending ? "Starting…" : "Run Workflow"} | |
| 46 | + </Button> | |
| 47 | + </form> | |
| 48 | + </Popover.Body> | |
| 49 | + </Popover.Content> | |
| 50 | + </Popover> | |
| 51 | + ); | |
| 52 | + } | |
| @@ -0,0 +1,78 @@ | ||
| 1 | + "use client"; | |
| 2 | + | |
| 3 | + import { useState } from "react"; | |
| 4 | + import { ChevronRightIcon } from "@/components/icons"; | |
| 5 | + import type { StepStatus } from "@/lib/runs"; | |
| 6 | + import { RunStatusIcon } from "./run-status"; | |
| 7 | + | |
| 8 | + export type StepView = { | |
| 9 | + index: number; | |
| 10 | + name: string; | |
| 11 | + status: StepStatus; | |
| 12 | + exitCode: number | null; | |
| 13 | + duration: string | null; | |
| 14 | + /** The output's last lines, already cleaned of terminal escapes. */ | |
| 15 | + lines: string[]; | |
| 16 | + /** How many lines before `lines` were left out. */ | |
| 17 | + omitted: number; | |
| 18 | + }; | |
| 19 | + | |
| 20 | + /** | |
| 21 | + * A job's steps, each a row that opens onto its log. Running and failed steps open by themselves (and | |
| 22 | + * follow along as the page refreshes) until the reader opens or closes them by hand. | |
| 23 | + */ | |
| 24 | + export function JobSteps({ steps, rawHref }: { steps: StepView[]; rawHref: string }) { | |
| 25 | + const [toggled, setToggled] = useState<Map<number, boolean>>(new Map()); | |
| 26 | + return ( | |
| 27 | + <ol className="flex flex-col"> | |
| 28 | + {steps.map((step) => { | |
| 29 | + const auto = step.status === "in_progress" || step.status === "failure"; | |
| 30 | + const open = toggled.get(step.index) ?? auto; | |
| 31 | + const hasLog = step.lines.length > 0 || step.omitted > 0; | |
| 32 | + return ( | |
| 33 | + <li key={step.index}> | |
| 34 | + <button | |
| 35 | + type="button" | |
| 36 | + aria-expanded={open} | |
| 37 | + onClick={() => setToggled((t) => new Map(t).set(step.index, !open))} | |
| 38 | + className="flex h-10 w-full items-center gap-2.5 rounded-row px-3 text-left transition-[background,box-shadow] duration-150 outline-none hover:recipe-row-list-hover focus-visible:focus-ring" | |
| 39 | + > | |
| 40 | + <ChevronRightIcon width={14} height={14} className={`shrink-0 text-ink3 transition-transform duration-150 ${open ? "rotate-90" : ""}`} /> | |
| 41 | + <RunStatusIcon status={step.status} size={15} /> | |
| 42 | + <span className={`min-w-0 flex-1 truncate type-ui ${step.status === "skipped" || step.status === "pending" ? "text-ink3" : "text-ink"}`}> | |
| 43 | + {step.name} | |
| 44 | + </span> | |
| 45 | + {step.duration && <span className="shrink-0 type-readout text-ink3">{step.duration}</span>} | |
| 46 | + </button> | |
| 47 | + {open && ( | |
| 48 | + <div className="mx-3 mt-0.5 mb-2 overflow-hidden rounded-row well"> | |
| 49 | + {hasLog ? ( | |
| 50 | + <pre className="max-h-[560px] overflow-auto py-2 font-mono text-[12px] leading-5 text-ink"> | |
| 51 | + {step.omitted > 0 && ( | |
| 52 | + <div className="px-3 pb-1 text-ink3"> | |
| 53 | + {step.omitted.toLocaleString("en")} earlier lines not shown ·{" "} | |
| 54 | + <a href={rawHref} className="underline underline-offset-2 hover:text-ink"> | |
| 55 | + view the full log | |
| 56 | + </a> | |
| 57 | + </div> | |
| 58 | + )} | |
| 59 | + {step.lines.map((line, i) => ( | |
| 60 | + <div key={i} className="flex hover:bg-[color-mix(in_srgb,var(--mu-ink)_4%,transparent)]"> | |
| 61 | + <span className="w-12 shrink-0 pr-3 text-right text-ink3 select-none">{step.omitted + i + 1}</span> | |
| 62 | + <span className="min-w-0 pr-3 break-all whitespace-pre-wrap">{line || " "}</span> | |
| 63 | + </div> | |
| 64 | + ))} | |
| 65 | + </pre> | |
| 66 | + ) : ( | |
| 67 | + <p className="px-3 py-2.5 type-meta text-ink3"> | |
| 68 | + {step.status === "pending" ? "Not started yet." : step.status === "skipped" ? "This step was skipped." : "No output."} | |
| 69 | + </p> | |
| 70 | + )} | |
| 71 | + </div> | |
| 72 | + )} | |
| 73 | + </li> | |
| 74 | + ); | |
| 75 | + })} | |
| 76 | + </ol> | |
| 77 | + ); | |
| 78 | + } | |
| @@ -0,0 +1,17 @@ | ||
| 1 | + "use client"; | |
| 2 | + | |
| 3 | + import { useRouter } from "next/navigation"; | |
| 4 | + import { useEffect } from "react"; | |
| 5 | + | |
| 6 | + /** Re-renders the page every few seconds while something on it is still running (and the tab is visible). */ | |
| 7 | + export function LiveRefresh({ active, interval = 2000 }: { active: boolean; interval?: number }) { | |
| 8 | + const router = useRouter(); | |
| 9 | + useEffect(() => { | |
| 10 | + if (!active) return; | |
| 11 | + const timer = setInterval(() => { | |
| 12 | + if (document.visibilityState === "visible") router.refresh(); | |
| 13 | + }, interval); | |
| 14 | + return () => clearInterval(timer); | |
| 15 | + }, [active, interval, router]); | |
| 16 | + return null; | |
| 17 | + } | |
| @@ -0,0 +1,91 @@ | ||
| 1 | + "use client"; | |
| 2 | + | |
| 3 | + import { useActionState } from "react"; | |
| 4 | + import type { FormState } from "@/app/actions/types"; | |
| 5 | + import { PlusIcon, ServerIcon, TagIcon } from "@/components/icons"; | |
| 6 | + import { CopyIconButton, TextField } from "@/components/settings-fields"; | |
| 7 | + import { PlateBody, PlateFooter } from "@/components/settings-plate"; | |
| 8 | + import { Alert, Button } from "@/components/ui"; | |
| 9 | + | |
| 10 | + function Command({ children }: { children: string }) { | |
| 11 | + return ( | |
| 12 | + <div className="flex items-start gap-2 rounded-row well py-1.5 pr-1.5 pl-3.5"> | |
| 13 | + <pre className="min-w-0 flex-1 overflow-x-auto py-1.5 font-mono text-[12px] leading-5 text-ink">{children}</pre> | |
| 14 | + <CopyIconButton text={children} label="Copy command" toast="Copied command" /> | |
| 15 | + </div> | |
| 16 | + ); | |
| 17 | + } | |
| 18 | + | |
| 19 | + /** Registers a runner, then shows its token once with the commands that start it. */ | |
| 20 | + export function NewRunnerForm({ | |
| 21 | + action, | |
| 22 | + origin, | |
| 23 | + scopeText, | |
| 24 | + }: { | |
| 25 | + action: (prev: FormState, formData: FormData) => Promise<FormState>; | |
| 26 | + origin: string; | |
| 27 | + scopeText: string; | |
| 28 | + }) { | |
| 29 | + const [state, formAction, pending] = useActionState(action, undefined); | |
| 30 | + | |
| 31 | + if (state?.token) { | |
| 32 | + return ( | |
| 33 | + <> | |
| 34 | + <PlateBody className="max-w-3xl"> | |
| 35 | + <Alert kind="success">Runner registered. Copy its token now — you won’t be able to see it again.</Alert> | |
| 36 | + <div className="flex items-center gap-2 rounded-row recipe-well-field py-1.5 pr-1.5 pl-3.5"> | |
| 37 | + <code className="flex-1 font-mono text-[12.5px] leading-5 break-all text-ink">{state.token}</code> | |
| 38 | + <CopyIconButton text={state.token} label="Copy token" toast="Copied token" /> | |
| 39 | + </div> | |
| 40 | + <div className="flex flex-col gap-2"> | |
| 41 | + <p className="type-title text-ink">Start it on your machine</p> | |
| 42 | + <p className="type-body text-ink2"> | |
| 43 | + The runner is one file and needs Node.js 18+ and git. Download it, then start it with the token. Jobs run as the user | |
| 44 | + that starts it, so use an account (or a VM or container) set aside for it. | |
| 45 | + </p> | |
| 46 | + <Command>{`curl -fsSLO ${origin}/vectrum-runner.mjs`}</Command> | |
| 47 | + <Command>{`node vectrum-runner.mjs --url ${origin} --token ${state.token}`}</Command> | |
| 48 | + <p className="type-body text-ink2"> | |
| 49 | + It shows up as online here within a few seconds. Jobs with <code className="font-mono text-[12px]">runs-on: self-hosted</code>{" "} | |
| 50 | + run on any runner; use labels to pick particular ones. | |
| 51 | + </p> | |
| 52 | + </div> | |
| 53 | + </PlateBody> | |
| 54 | + <PlateFooter> | |
| 55 | + <Button type="button" onClick={() => window.location.reload()}> | |
| 56 | + Done | |
| 57 | + </Button> | |
| 58 | + </PlateFooter> | |
| 59 | + </> | |
| 60 | + ); | |
| 61 | + } | |
| 62 | + | |
| 63 | + return ( | |
| 64 | + <form action={formAction}> | |
| 65 | + <PlateBody className="max-w-2xl gap-6"> | |
| 66 | + <p className="type-body text-ink2">{scopeText}</p> | |
| 67 | + <TextField label="Name" icon={<ServerIcon />} name="name" required maxLength={100} description="e.g. the machine it runs on." /> | |
| 68 | + <TextField | |
| 69 | + label="Labels" | |
| 70 | + icon={<TagIcon />} | |
| 71 | + name="labels" | |
| 72 | + optional | |
| 73 | + placeholder="docker, gpu" | |
| 74 | + description={ | |
| 75 | + <> | |
| 76 | + Jobs pick runners by label with <code className="font-mono text-[12px]">runs-on</code>. Every runner also has{" "} | |
| 77 | + <code className="font-mono text-[12px]">self-hosted</code> and its OS and architecture (e.g.{" "} | |
| 78 | + <code className="font-mono text-[12px]">linux</code>, <code className="font-mono text-[12px]">x64</code>). | |
| 79 | + </> | |
| 80 | + } | |
| 81 | + /> | |
| 82 | + {state?.error && <Alert>{state.error}</Alert>} | |
| 83 | + </PlateBody> | |
| 84 | + <PlateFooter> | |
| 85 | + <Button variant="primary" icon={<PlusIcon />} disabled={pending}> | |
| 86 | + {pending ? "Registering…" : "Register Runner"} | |
| 87 | + </Button> | |
| 88 | + </PlateFooter> | |
| 89 | + </form> | |
| 90 | + ); | |
| 91 | + } | |
| @@ -0,0 +1,102 @@ | ||
| 1 | + import type { LedKind } from "@unlocalhosted/metalui"; | |
| 2 | + import type { CommitStatus, JobStatus, RunStatus, StepStatus } from "@/lib/runs"; | |
| 3 | + | |
| 4 | + // Run, job and step states, drawn like the issue status glyphs (MetalUI's 24 grid, 1.7 stroke) and | |
| 5 | + // coloured like its LEDs: green passed, red failed, amber working or waiting, unlit (ink3) the rest. | |
| 6 | + | |
| 7 | + export type AnyStatus = RunStatus | JobStatus | StepStatus | CommitStatus; | |
| 8 | + | |
| 9 | + const AMBER = "text-[#c98a18]"; | |
| 10 | + | |
| 11 | + const COLORS: Record<AnyStatus, string> = { | |
| 12 | + success: "text-green-deep", | |
| 13 | + failure: "text-red", | |
| 14 | + in_progress: AMBER, | |
| 15 | + queued: AMBER, | |
| 16 | + pending: "text-ink3", | |
| 17 | + waiting: "text-ink3", | |
| 18 | + cancelled: "text-ink3", | |
| 19 | + skipped: "text-ink3", | |
| 20 | + }; | |
| 21 | + | |
| 22 | + const LABELS: Record<AnyStatus, string> = { | |
| 23 | + success: "Success", | |
| 24 | + failure: "Failure", | |
| 25 | + in_progress: "In progress", | |
| 26 | + queued: "Queued", | |
| 27 | + pending: "Pending", | |
| 28 | + waiting: "Waiting", | |
| 29 | + cancelled: "Cancelled", | |
| 30 | + skipped: "Skipped", | |
| 31 | + }; | |
| 32 | + | |
| 33 | + export const statusText = (status: AnyStatus) => LABELS[status]; | |
| 34 | + | |
| 35 | + /** The LED for a status beside its words (StatusBadge). */ | |
| 36 | + export function statusLed(status: AnyStatus): LedKind { | |
| 37 | + if (status === "success") return "live"; | |
| 38 | + if (status === "failure") return "failed"; | |
| 39 | + if (status === "in_progress" || status === "queued") return "waiting"; | |
| 40 | + return "off"; | |
| 41 | + } | |
| 42 | + | |
| 43 | + const R = 9.15; | |
| 44 | + | |
| 45 | + export function RunStatusIcon({ status, size = 16, className = "" }: { status: AnyStatus; size?: number; className?: string }) { | |
| 46 | + return ( | |
| 47 | + <svg | |
| 48 | + viewBox="0 0 24 24" | |
| 49 | + width={size} | |
| 50 | + height={size} | |
| 51 | + className={`shrink-0 ${COLORS[status]} ${className}`} | |
| 52 | + role="img" | |
| 53 | + aria-label={LABELS[status]} | |
| 54 | + > | |
| 55 | + <title>{LABELS[status]}</title> | |
| 56 | + {status === "success" && ( | |
| 57 | + <> | |
| 58 | + <circle cx="12" cy="12" r="10" fill="currentColor" /> | |
| 59 | + <path d="m7.6 12.3 3.1 3.1 5.7-6.2" fill="none" stroke="var(--canvas)" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" /> | |
| 60 | + </> | |
| 61 | + )} | |
| 62 | + {status === "failure" && ( | |
| 63 | + <> | |
| 64 | + <circle cx="12" cy="12" r="10" fill="currentColor" /> | |
| 65 | + <path d="m8.7 8.7 6.6 6.6m0-6.6-6.6 6.6" fill="none" stroke="var(--canvas)" strokeWidth="1.9" strokeLinecap="round" /> | |
| 66 | + </> | |
| 67 | + )} | |
| 68 | + {status === "in_progress" && ( | |
| 69 | + <> | |
| 70 | + <circle cx="12" cy="12" r={R} fill="none" stroke="currentColor" strokeWidth="1.7" opacity="0.3" /> | |
| 71 | + <path | |
| 72 | + d={`M12 ${12 - R}a${R} ${R} 0 0 1 ${R} ${R}`} | |
| 73 | + fill="none" | |
| 74 | + stroke="currentColor" | |
| 75 | + strokeWidth="1.9" | |
| 76 | + strokeLinecap="round" | |
| 77 | + className="origin-center animate-spin" | |
| 78 | + /> | |
| 79 | + <circle cx="12" cy="12" r="3" fill="currentColor" /> | |
| 80 | + </> | |
| 81 | + )} | |
| 82 | + {(status === "queued" || status === "waiting" || status === "pending") && ( | |
| 83 | + <> | |
| 84 | + <circle cx="12" cy="12" r={R} fill="none" stroke="currentColor" strokeWidth="1.7" strokeDasharray="2.6 2.19" /> | |
| 85 | + {status === "queued" && <circle cx="12" cy="12" r="3" fill="currentColor" />} | |
| 86 | + </> | |
| 87 | + )} | |
| 88 | + {status === "cancelled" && ( | |
| 89 | + <> | |
| 90 | + <circle cx="12" cy="12" r={R} fill="none" stroke="currentColor" strokeWidth="1.7" /> | |
| 91 | + <path d="m5.6 18.4 12.8-12.8" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" /> | |
| 92 | + </> | |
| 93 | + )} | |
| 94 | + {status === "skipped" && ( | |
| 95 | + <> | |
| 96 | + <circle cx="12" cy="12" r={R} fill="none" stroke="currentColor" strokeWidth="1.7" opacity="0.6" /> | |
| 97 | + <path d="M8 12h8" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" /> | |
| 98 | + </> | |
| 99 | + )} | |
| 100 | + </svg> | |
| 101 | + ); | |
| 102 | + } | |
| @@ -0,0 +1,16 @@ | ||
| 1 | + import { formatDuration, shortSha } from "@/lib/format"; | |
| 2 | + import type { Run } from "@/lib/runs"; | |
| 3 | + | |
| 4 | + /** How long something ran (or has been running), or null if it hasn't started. */ | |
| 5 | + export function runDuration(item: { startedAt: number | null; finishedAt: number | null }) { | |
| 6 | + if (!item.startedAt) return null; | |
| 7 | + return formatDuration((item.finishedAt ?? Date.now()) - item.startedAt); | |
| 8 | + } | |
| 9 | + | |
| 10 | + /** What started a run: "Commit 1a2b3c4 pushed by ana", "Manually run by ana". */ | |
| 11 | + export function triggerText(run: Run) { | |
| 12 | + const by = run.actor ? ` by ${run.actor}` : ""; | |
| 13 | + return run.event === "workflow_dispatch" ? `Manually run${by}` : `Commit ${shortSha(run.sha)} pushed${by}`; | |
| 14 | + } | |
| 15 | + | |
| 16 | + export const refName = (ref: string) => ref.replace(/^refs\/(heads|tags)\//, ""); | |
| @@ -0,0 +1,88 @@ | ||
| 1 | + import { Label as MuLabel, StatusBadge } from "@unlocalhosted/metalui"; | |
| 2 | + import { ConfirmButton } from "@/components/confirm-button"; | |
| 3 | + import { ServerIcon } from "@/components/icons"; | |
| 4 | + import { SettingsSection } from "@/components/metal"; | |
| 5 | + import { ListRow, PlateSection, RowGlyph } from "@/components/settings-plate"; | |
| 6 | + import { Blankslate, RefChip } from "@/components/ui"; | |
| 7 | + import { timeAgo } from "@/lib/format"; | |
| 8 | + import { RUNNER_TOKEN_PREFIX, type Runner } from "@/lib/runners"; | |
| 9 | + | |
| 10 | + /** Registered runners: online, busy or offline, with their labels and machine, each removable. */ | |
| 11 | + export function RunnerList({ runners, deleteAction }: { runners: Runner[]; deleteAction: (runnerId: string) => Promise<void> }) { | |
| 12 | + if (runners.length === 0) { | |
| 13 | + return ( | |
| 14 | + <PlateSection title="Runners"> | |
| 15 | + <Blankslate icon={<ServerIcon />} title="No runners yet"> | |
| 16 | + Register a runner below, then start it on the machine that should run your jobs. | |
| 17 | + </Blankslate> | |
| 18 | + </PlateSection> | |
| 19 | + ); | |
| 20 | + } | |
| 21 | + return ( | |
| 22 | + <SettingsSection title={`Runners · ${runners.length}`}> | |
| 23 | + {runners.map((r) => ( | |
| 24 | + <ListRow | |
| 25 | + key={r.id} | |
| 26 | + lead={ | |
| 27 | + <RowGlyph> | |
| 28 | + <ServerIcon /> | |
| 29 | + </RowGlyph> | |
| 30 | + } | |
| 31 | + title={ | |
| 32 | + <> | |
| 33 | + <span className="truncate">{r.name}</span> | |
| 34 | + {r.busy ? ( | |
| 35 | + <StatusBadge led="waiting">Busy</StatusBadge> | |
| 36 | + ) : r.online ? ( | |
| 37 | + <StatusBadge led="live">Idle</StatusBadge> | |
| 38 | + ) : ( | |
| 39 | + <StatusBadge led="off">Offline</StatusBadge> | |
| 40 | + )} | |
| 41 | + <RefChip> | |
| 42 | + {RUNNER_TOKEN_PREFIX}…{r.hint} | |
| 43 | + </RefChip> | |
| 44 | + </> | |
| 45 | + } | |
| 46 | + meta={ | |
| 47 | + <> | |
| 48 | + <span className="flex flex-wrap gap-1"> | |
| 49 | + {[...r.systemLabels, ...r.labels].map((l) => ( | |
| 50 | + <RefChip key={l} className="text-ink2"> | |
| 51 | + {l} | |
| 52 | + </RefChip> | |
| 53 | + ))} | |
| 54 | + </span> | |
| 55 | + <MuLabel variant="engraved"> | |
| 56 | + {r.busy ? ( | |
| 57 | + <> | |
| 58 | + Running <b>{r.busy.jobName}</b> in <b>{r.busy.repo}</b> #{r.busy.runNumber} | |
| 59 | + </> | |
| 60 | + ) : r.lastSeenAt ? ( | |
| 61 | + <> | |
| 62 | + {r.hostname && ( | |
| 63 | + <> | |
| 64 | + <b>{r.hostname}</b> ·{" "} | |
| 65 | + </> | |
| 66 | + )} | |
| 67 | + Last seen <b>{timeAgo(r.lastSeenAt)}</b> | |
| 68 | + {r.version && <> · v{r.version}</>} | |
| 69 | + </> | |
| 70 | + ) : ( | |
| 71 | + "Never connected" | |
| 72 | + )} | |
| 73 | + </MuLabel> | |
| 74 | + </> | |
| 75 | + } | |
| 76 | + > | |
| 77 | + <ConfirmButton | |
| 78 | + action={deleteAction.bind(null, r.id)} | |
| 79 | + message={`Remove the runner “${r.name}”? It stops picking up jobs, and its token stops working.`} | |
| 80 | + variant="default" | |
| 81 | + > | |
| 82 | + Remove | |
| 83 | + </ConfirmButton> | |
| 84 | + </ListRow> | |
| 85 | + ))} | |
| 86 | + </SettingsSection> | |
| 87 | + ); | |
| 88 | + } | |
| @@ -1,5 +1,7 @@ | ||
| 1 | + import { cache } from "react"; | |
| 1 | 2 | import Link from "next/link"; |
| 2 | 3 | import { listReposForUser } from "@/lib/repos"; |
| 4 | + import { SIGNUPS_ENABLED } from "@/lib/signups"; | |
| 3 | 5 | import type { Theme } from "@/lib/theme"; |
| 4 | 6 | import type { User } from "@/lib/users"; |
| 5 | 7 | import { BRAND, Logo, LogoMark } from "./brand"; |
| @@ -9,18 +11,20 @@ import { SearchBox } from "./search-box"; | ||
| 9 | 11 | import { AppSidebar, MobileNav } from "./sidebar-nav"; |
| 10 | 12 | import { ButtonLink } from "./ui"; |
| 11 | 13 | |
| 12 | - const sidebarRepos = (viewer: User) => | |
| 13 | - listReposForUser(viewer.id).map((r) => ({ owner: r.owner, name: r.name, imageVersion: r.imageVersion })); | |
| 14 | + // The sidebar and the search box both list them; one query per request. | |
| 15 | + const sidebarRepos = cache(async (viewerId: string) => | |
| 16 | + (await listReposForUser(viewerId)).map((r) => ({ owner: r.owner, name: r.name, imageVersion: r.imageVersion })), | |
| 17 | + ); | |
| 14 | 18 | |
| 15 | 19 | const viewerOf = (viewer: User) => ({ username: viewer.username, name: viewer.name }); |
| 16 | 20 | |
| 17 | 21 | /** Left rail for signed-in users: account menu, places and repository shortcuts. */ |
| 18 | - export function SiteSidebar({ viewer, theme }: { viewer: User; theme: Theme }) { | |
| 19 | - return <AppSidebar viewer={viewerOf(viewer)} repos={sidebarRepos(viewer)} theme={theme} />; | |
| 22 | + export async function SiteSidebar({ viewer, theme }: { viewer: User; theme: Theme }) { | |
| 23 | + return <AppSidebar viewer={viewerOf(viewer)} repos={await sidebarRepos(viewer.id)} theme={theme} />; | |
| 20 | 24 | } |
| 21 | 25 | |
| 22 | 26 | /** Thin bar above the sheet for signed-in users. On small screens it also carries the navigation. */ |
| 23 | - export function TopBar({ viewer }: { viewer: User }) { | |
| 27 | + export async function TopBar({ viewer }: { viewer: User }) { | |
| 24 | 28 | return ( |
| 25 | 29 | <header className="flex h-14 shrink-0 items-center gap-2 px-3 lg:pl-1"> |
| 26 | 30 | <div className="flex items-center gap-2 lg:hidden"> |
| @@ -31,7 +35,7 @@ export function TopBar({ viewer }: { viewer: User }) { | ||
| 31 | 35 | </div> |
| 32 | 36 | |
| 33 | 37 | <div className="ml-auto flex items-center gap-2.5"> |
| 34 | - <SearchBox repos={sidebarRepos(viewer)} signedIn /> | |
| 38 | + <SearchBox repos={await sidebarRepos(viewer.id)} signedIn /> | |
| 35 | 39 | <ButtonLink href="/new" variant="primary" icon={<PlusIcon />}> |
| 36 | 40 | New |
| 37 | 41 | </ButtonLink> |
| @@ -53,9 +57,11 @@ export function SiteHeader() { | ||
| 53 | 57 | <ButtonLink href="/login" variant="invisible"> |
| 54 | 58 | Sign In |
| 55 | 59 | </ButtonLink> |
| 56 | - <ButtonLink href="/signup" variant="primary"> | |
| 57 | - Create Account | |
| 58 | - </ButtonLink> | |
| 60 | + {SIGNUPS_ENABLED && ( | |
| 61 | + <ButtonLink href="/signup" variant="primary"> | |
| 62 | + Create Account | |
| 63 | + </ButtonLink> | |
| 64 | + )} | |
| 59 | 65 | </div> |
| 60 | 66 | </header> |
| 61 | 67 | ); |
| @@ -274,3 +274,17 @@ export const BoardIcon = icon( | ||
| 274 | 274 | </>, |
| 275 | 275 | ); |
| 276 | 276 | export const SlidersIcon = icon(<path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4" />); |
| 277 | + export const PlayIcon = icon( | |
| 278 | + <> | |
| 279 | + <circle cx="12" cy="12" r="9.15" /> | |
| 280 | + <path d="m10 8.5 5.5 3.5-5.5 3.5Z" /> | |
| 281 | + </>, | |
| 282 | + ); | |
| 283 | + export const ServerIcon = icon( | |
| 284 | + <> | |
| 285 | + <rect x="3" y="3.5" width="18" height="7" rx="2" /> | |
| 286 | + <rect x="3" y="13.5" width="18" height="7" rx="2" /> | |
| 287 | + <path d="M7 7h.01M7 17h.01M11 7h6M11 17h6" /> | |
| 288 | + </>, | |
| 289 | + ); | |
| 290 | + export const DownloadIcon = icon(<path d="M12 4v11m-4.5-4.5L12 15l4.5-4.5M5 20h14" />); | |
| @@ -28,8 +28,8 @@ export function IssueComposer({ | ||
| 28 | 28 | const [body, setBody] = useState(""); |
| 29 | 29 | const [status, setStatus] = useState<Status>(defaults?.status ?? "todo"); |
| 30 | 30 | const [priority, setPriority] = useState(defaults?.priority ?? 0); |
| 31 | - const [assigneeId, setAssigneeId] = useState<number | null>(defaults?.assigneeId ?? null); | |
| 32 | - const [labelIds, setLabelIds] = useState<number[]>(defaults?.labelIds ?? []); | |
| 31 | + const [assigneeId, setAssigneeId] = useState<string | null>(defaults?.assigneeId ?? null); | |
| 32 | + const [labelIds, setLabelIds] = useState<string[]>(defaults?.labelIds ?? []); | |
| 33 | 33 | const [estimate, setEstimate] = useState<number | null>(defaults?.estimate ?? null); |
| 34 | 34 | const [dueDate, setDueDate] = useState<string | null>(defaults?.dueDate ?? null); |
| 35 | 35 | const [createMore, setCreateMore] = useState(false); |
| @@ -107,7 +107,7 @@ export function IssueTracker({ issues, initialView }: { issues: IssueSummary[]; | ||
| 107 | 107 | (!q || i.title.toLowerCase().includes(q) || `#${i.number}` === q || String(i.number) === q) && |
| 108 | 108 | (view.assignee.length === 0 || view.assignee.includes(i.assigneeId === null ? "none" : String(i.assigneeId))) && |
| 109 | 109 | (view.priority.length === 0 || view.priority.includes(String(i.priority))) && |
| 110 | - (view.label.length === 0 || i.labels.some((l) => view.label.includes(String(l.id)))), | |
| 110 | + (view.label.length === 0 || i.labels.some((l) => view.label.includes(l.id))), | |
| 111 | 111 | ) |
| 112 | 112 | .sort(COMPARATORS[view.order]); |
| 113 | 113 | |
| @@ -393,7 +393,7 @@ export function IssueTracker({ issues, initialView }: { issues: IssueSummary[]; | ||
| 393 | 393 | </Tooltip> |
| 394 | 394 | ); |
| 395 | 395 | |
| 396 | - const mine = view.assignee.length === 1 && view.assignee[0] === String(viewerId); | |
| 396 | + const mine = view.assignee.length === 1 && view.assignee[0] === viewerId; | |
| 397 | 397 | |
| 398 | 398 | return ( |
| 399 | 399 | <div> |
| @@ -494,7 +494,7 @@ export function IssueTracker({ issues, initialView }: { issues: IssueSummary[]; | ||
| 494 | 494 | options={[ |
| 495 | 495 | { value: "none", label: "No assignee", icon: <PersonIcon width={14} height={14} /> }, |
| 496 | 496 | ...members.map((m) => ({ |
| 497 | - value: String(m.id), | |
| 497 | + value: m.id, | |
| 498 | 498 | label: m.id === viewerId ? `${m.username} (you)` : m.username, |
| 499 | 499 | icon: <Avatar name={m.username} size={16} />, |
| 500 | 500 | })), |
| @@ -512,10 +512,10 @@ export function IssueTracker({ issues, initialView }: { issues: IssueSummary[]; | ||
| 512 | 512 | icon={<TagIcon width={14} height={14} />} |
| 513 | 513 | value={view.label} |
| 514 | 514 | onChange={(v) => set("label", v)} |
| 515 | - options={labels.map((l) => ({ value: String(l.id), label: l.name, icon: <LabelDot color={l.color} /> }))} | |
| 515 | + options={labels.map((l) => ({ value: l.id, label: l.name, icon: <LabelDot color={l.color} /> }))} | |
| 516 | 516 | /> |
| 517 | 517 | {viewerId !== null && members.some((m) => m.id === viewerId) && ( |
| 518 | - <Toggle pressed={mine} onPressedChange={() => set("assignee", mine ? [] : [String(viewerId)])}> | |
| 518 | + <Toggle pressed={mine} onPressedChange={() => set("assignee", mine ? [] : [viewerId])}> | |
| 519 | 519 | My issues |
| 520 | 520 | </Toggle> |
| 521 | 521 | )} |
| @@ -704,7 +704,7 @@ function groupIssues(visible: IssueSummary[], view: TrackerView, members: Member | ||
| 704 | 704 | label: m.username, |
| 705 | 705 | icon: <Avatar name={m.username} size={16} />, |
| 706 | 706 | patch: { assigneeId: m.id }, |
| 707 | - issues: by((i) => i.assigneeId, m.id as number | null), | |
| 707 | + issues: by((i) => i.assigneeId, m.id as string | null), | |
| 708 | 708 | })), |
| 709 | 709 | ]; |
| 710 | 710 | // People who were assigned before losing access still get a (read-only) column. |
| @@ -719,7 +719,7 @@ function groupIssues(visible: IssueSummary[], view: TrackerView, members: Member | ||
| 719 | 719 | label: username, |
| 720 | 720 | icon: <Avatar name={username} size={16} />, |
| 721 | 721 | patch: null, |
| 722 | - issues: by((i) => i.assigneeId, id as number | null), | |
| 722 | + issues: by((i) => i.assigneeId, id as string | null), | |
| 723 | 723 | }); |
| 724 | 724 | } |
| 725 | 725 | groups.push({ |
| @@ -731,7 +731,7 @@ function groupIssues(visible: IssueSummary[], view: TrackerView, members: Member | ||
| 731 | 731 | </span> |
| 732 | 732 | ), |
| 733 | 733 | patch: { assigneeId: null }, |
| 734 | - issues: by((i) => i.assigneeId, null as number | null), | |
| 734 | + issues: by((i) => i.assigneeId, null as string | null), | |
| 735 | 735 | }); |
| 736 | 736 | return groups; |
| 737 | 737 | } |
| @@ -76,7 +76,7 @@ function Unassigned() { | ||
| 76 | 76 | ); |
| 77 | 77 | } |
| 78 | 78 | |
| 79 | - export function AssigneePicker({ value, onChange, variant = "chip", ...rest }: Control<number | null>) { | |
| 79 | + export function AssigneePicker({ value, onChange, variant = "chip", ...rest }: Control<string | null>) { | |
| 80 | 80 | const { members, viewerId } = useTracker(); |
| 81 | 81 | const assignee = members.find((m) => m.id === value); |
| 82 | 82 | // The viewer first, like Linear's "assign to me". |
| @@ -88,13 +88,13 @@ export function AssigneePicker({ value, onChange, variant = "chip", ...rest }: C | ||
| 88 | 88 | options={[ |
| 89 | 89 | { value: "", label: "No assignee", icon: <PersonIcon width={14} height={14} /> }, |
| 90 | 90 | ...sorted.map((m) => ({ |
| 91 | - value: String(m.id), | |
| 91 | + value: m.id, | |
| 92 | 92 | label: m.id === viewerId ? `${m.username} (you)` : m.username, |
| 93 | 93 | icon: <Avatar name={m.username} size={16} />, |
| 94 | 94 | })), |
| 95 | 95 | ]} |
| 96 | - value={value === null ? "" : String(value)} | |
| 97 | - onSelect={(v) => onChange(v ? Number(v) : null)} | |
| 96 | + value={value ?? ""} | |
| 97 | + onSelect={(v) => onChange(v || null)} | |
| 98 | 98 | triggerClassName={TRIGGER[variant]} |
| 99 | 99 | {...rest} |
| 100 | 100 | > |
| @@ -108,7 +108,7 @@ export function AssigneePicker({ value, onChange, variant = "chip", ...rest }: C | ||
| 108 | 108 | ); |
| 109 | 109 | } |
| 110 | 110 | |
| 111 | - export function LabelsPicker({ value, onChange, variant = "chip", ...rest }: Control<number[]>) { | |
| 111 | + export function LabelsPicker({ value, onChange, variant = "chip", ...rest }: Control<string[]>) { | |
| 112 | 112 | const { labels, createLabel, canTriage } = useTracker(); |
| 113 | 113 | const chosen = labels.filter((l) => value.includes(l.id)); |
| 114 | 114 | return ( |
| @@ -116,9 +116,9 @@ export function LabelsPicker({ value, onChange, variant = "chip", ...rest }: Con | ||
| 116 | 116 | multiple |
| 117 | 117 | label="Labels" |
| 118 | 118 | placeholder="Add labels…" |
| 119 | - options={labels.map((l) => ({ value: String(l.id), label: l.name, icon: <LabelDot color={l.color} /> }))} | |
| 120 | - value={value.map(String)} | |
| 121 | - onSelect={(v) => onChange(v.map(Number))} | |
| 119 | + options={labels.map((l) => ({ value: l.id, label: l.name, icon: <LabelDot color={l.color} /> }))} | |
| 120 | + value={value} | |
| 121 | + onSelect={(v) => onChange(v)} | |
| 122 | 122 | onCreate={ |
| 123 | 123 | canTriage |
| 124 | 124 | ? async (name) => { |
| @@ -13,7 +13,7 @@ type Tracker = { | ||
| 13 | 13 | base: string; |
| 14 | 14 | members: Member[]; |
| 15 | 15 | labels: IssueLabel[]; |
| 16 | - viewerId: number | null; | |
| 16 | + viewerId: string | null; | |
| 17 | 17 | /** Push access: may set priority, assignee, labels, estimates and reorder. */ |
| 18 | 18 | canTriage: boolean; |
| 19 | 19 | /** Today as YYYY-MM-DD, from the server, for overdue highlighting. */ |
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | import Link from "next/link"; |
| 4 | 4 | import { usePathname } from "next/navigation"; |
| 5 | 5 | import { useState } from "react"; |
| 6 | - import { BranchIcon, CodeIcon, GearIcon, HistoryIcon, IssueOpenedIcon, PackageIcon } from "./icons"; | |
| 6 | + import { BranchIcon, CodeIcon, GearIcon, HistoryIcon, IssueOpenedIcon, PackageIcon, PlayIcon } from "./icons"; | |
| 7 | 7 | import { SlidingIndicator, thumbClass } from "./sliding-indicator"; |
| 8 | 8 | |
| 9 | 9 | /** |
| @@ -24,8 +24,9 @@ export function RepoTabs({ | ||
| 24 | 24 | const pathname = usePathname(); |
| 25 | 25 | const section = pathname.slice(base.length).split("/")[1] ?? ""; |
| 26 | 26 | const tabs = [ |
| 27 | - { href: base, label: "Code", icon: CodeIcon, active: !["issues", "branches", "commits", "commit", "packages", "settings"].includes(section) }, | |
| 27 | + { href: base, label: "Code", icon: CodeIcon, active: !["issues", "actions", "branches", "commits", "commit", "packages", "settings"].includes(section) }, | |
| 28 | 28 | { href: `${base}/issues`, label: "Issues", icon: IssueOpenedIcon, count: openIssues, active: section === "issues" }, |
| 29 | + { href: `${base}/actions`, label: "Actions", icon: PlayIcon, active: section === "actions" }, | |
| 29 | 30 | { href: `${base}/commits`, label: "Commits", icon: HistoryIcon, active: section === "commits" || section === "commit" }, |
| 30 | 31 | { href: `${base}/branches`, label: "Branches", icon: BranchIcon, active: section === "branches" }, |
| 31 | 32 | { href: `${base}/packages`, label: "Packages", icon: PackageIcon, count: images, active: section === "packages" }, |
| @@ -0,0 +1,21 @@ | ||
| 1 | + services: | |
| 2 | + vectrum: | |
| 3 | + build: . | |
| 4 | + image: vectrum | |
| 5 | + restart: unless-stopped | |
| 6 | + ports: | |
| 7 | + - "3000:3000" # web UI and git over HTTPS (put a TLS reverse proxy in front) | |
| 8 | + - "2222:2222" # git over SSH; clone URLs show SSH_PORT (2222), so keep both sides the same | |
| 9 | + environment: | |
| 10 | + # Close registration: the sign-up page and button go away, existing users can still sign in. | |
| 11 | + # Create accounts with: docker compose exec -it vectrum node scripts/create-user.mjs <username> <email> | |
| 12 | + SIGNUPS_DISABLED: "0" | |
| 13 | + # Host name shown in SSH clone URLs (defaults to the host the web UI was reached on). | |
| 14 | + # SSH_HOST: vectrum.example.com | |
| 15 | + # Set to 1 to turn off the built-in SSH server (and remove the 2222 port above). | |
| 16 | + # SSH_DISABLED: "1" | |
| 17 | + volumes: | |
| 18 | + - vectrum-data:/data # repositories, the PocketBase database and the SSH host key | |
| 19 | + | |
| 20 | + volumes: | |
| 21 | + vectrum-data: | |
| @@ -1,52 +1,62 @@ | ||
| 1 | 1 | import "server-only"; |
| 2 | - import { createHash, randomBytes } from "node:crypto"; | |
| 3 | 2 | import { cache } from "react"; |
| 4 | 3 | import { cookies, headers } from "next/headers"; |
| 5 | 4 | import { redirect } from "next/navigation"; |
| 6 | - import { db } from "./db"; | |
| 7 | - import { getUserById } from "./users"; | |
| 5 | + import { ClientResponseError, getTokenPayload, isTokenExpired } from "pocketbase"; | |
| 6 | + import { client, db } from "./db"; | |
| 7 | + import { authenticateUser, toUser } from "./users"; | |
| 8 | + | |
| 9 | + // Sessions are PocketBase auth tokens, kept in an httpOnly cookie. They expire after 30 days (the | |
| 10 | + // users collection's token duration) and stop working when the password or email changes. | |
| 8 | 11 | |
| 9 | 12 | const SESSION_COOKIE = "session"; |
| 10 | - const SESSION_DAYS = 30; | |
| 11 | - | |
| 12 | - const sha256 = (s: string) => createHash("sha256").update(s).digest("hex"); | |
| 13 | - | |
| 14 | - export async function startSession(userId: number) { | |
| 15 | - const token = randomBytes(32).toString("base64url"); | |
| 16 | - const now = Date.now(); | |
| 17 | - const expires = now + SESSION_DAYS * 86400e3; | |
| 18 | - db.prepare("INSERT INTO sessions (id, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)").run( | |
| 19 | - sha256(token), | |
| 20 | - userId, | |
| 21 | - now, | |
| 22 | - expires, | |
| 23 | - ); | |
| 13 | + const SESSION_SECONDS = 30 * 86400; | |
| 14 | + | |
| 15 | + async function setSessionCookie(token: string) { | |
| 24 | 16 | const h = await headers(); |
| 25 | 17 | (await cookies()).set(SESSION_COOKIE, token, { |
| 26 | 18 | httpOnly: true, |
| 27 | 19 | sameSite: "lax", |
| 28 | 20 | secure: h.get("x-forwarded-proto") === "https", |
| 29 | 21 | path: "/", |
| 30 | - expires: new Date(expires), | |
| 22 | + expires: new Date(getTokenPayload(token).exp * 1000), | |
| 31 | 23 | }); |
| 32 | 24 | } |
| 33 | 25 | |
| 26 | + /** Checks a username or email and password and starts a session. Returns the user, or null. */ | |
| 27 | + export async function signIn(login: string, password: string) { | |
| 28 | + const auth = await authenticateUser(login, password); | |
| 29 | + if (!auth) return null; | |
| 30 | + await setSessionCookie(auth.token); | |
| 31 | + return auth.user; | |
| 32 | + } | |
| 33 | + | |
| 34 | + /** A new session for a signed-in user whose old token stopped working (after an email change). */ | |
| 35 | + export async function renewSession(userId: string) { | |
| 36 | + const impersonated = await (await db()).collection("users").impersonate(userId, SESSION_SECONDS); | |
| 37 | + await setSessionCookie(impersonated.authStore.token); | |
| 38 | + } | |
| 39 | + | |
| 34 | 40 | export async function endSession() { |
| 35 | - const jar = await cookies(); | |
| 36 | - const token = jar.get(SESSION_COOKIE)?.value; | |
| 37 | - if (token) db.prepare("DELETE FROM sessions WHERE id = ?").run(sha256(token)); | |
| 38 | - jar.delete(SESSION_COOKIE); | |
| 41 | + (await cookies()).delete(SESSION_COOKIE); | |
| 39 | 42 | } |
| 40 | 43 | |
| 41 | 44 | /** The signed-in user for this request, or null. */ |
| 42 | 45 | export const getViewer = cache(async () => { |
| 43 | 46 | const token = (await cookies()).get(SESSION_COOKIE)?.value; |
| 44 | - if (!token) return null; | |
| 45 | - const session = db.prepare("SELECT user_id, expires_at FROM sessions WHERE id = ?").get(sha256(token)) as | |
| 46 | - | { user_id: number; expires_at: number } | |
| 47 | - | undefined; | |
| 48 | - if (!session || session.expires_at < Date.now()) return null; | |
| 49 | - return getUserById(session.user_id); | |
| 47 | + if (!token || isTokenExpired(token)) return null; | |
| 48 | + const { id, type } = getTokenPayload(token); | |
| 49 | + if (type !== "auth" || typeof id !== "string") return null; | |
| 50 | + await db(); // makes sure PocketBase is running | |
| 51 | + // Reading the record with the user's own token is what checks the token. | |
| 52 | + const pb = client(); | |
| 53 | + pb.authStore.save(token); | |
| 54 | + try { | |
| 55 | + return toUser(await pb.collection("users").getOne(id)); | |
| 56 | + } catch (e) { | |
| 57 | + if (e instanceof ClientResponseError && [401, 403, 404].includes(e.status)) return null; | |
| 58 | + throw e; | |
| 59 | + } | |
| 50 | 60 | }); |
| 51 | 61 | |
| 52 | 62 | /** Returns the signed-in user, or redirects to the login page. */ |
| @@ -0,0 +1,106 @@ | ||
| 1 | + import { getCommit, git, listTree, readBlob, revParse } from "./git"; | |
| 2 | + import type { RepoRecord } from "./repos"; | |
| 3 | + import { createRun, type Run, type RunEvent } from "./runs"; | |
| 4 | + import { isWorkflowFile, matchesFilter, matchesPaths, parseWorkflow, WORKFLOW_DIR, WorkflowError, type Workflow } from "./workflows"; | |
| 5 | + | |
| 6 | + // Starting workflow runs: on pushes (over SSH, HTTPS or from the web editor), by hand, and again. | |
| 7 | + // Like lib/git.ts this is used by the SSH server, so it must not import anything from next/*. | |
| 8 | + | |
| 9 | + export class CiError extends Error {} | |
| 10 | + | |
| 11 | + type WorkflowFile = { path: string; workflow: Workflow } | { path: string; error: string; name: string }; | |
| 12 | + | |
| 13 | + /** The workflow files at a commit, parsed; files that don't parse carry their error instead. */ | |
| 14 | + export async function readWorkflows(dir: string, sha: string): Promise<WorkflowFile[]> { | |
| 15 | + const entries = await listTree(dir, sha, WORKFLOW_DIR).catch(() => []); | |
| 16 | + const files: WorkflowFile[] = []; | |
| 17 | + for (const entry of entries) { | |
| 18 | + if (entry.type !== "blob" || !isWorkflowFile(entry.name)) continue; | |
| 19 | + files.push(await readWorkflow(dir, sha, entry.path)); | |
| 20 | + } | |
| 21 | + return files; | |
| 22 | + } | |
| 23 | + | |
| 24 | + async function readWorkflow(dir: string, sha: string, path: string): Promise<WorkflowFile> { | |
| 25 | + const name = path.split("/").pop()!.replace(/\.ya?ml$/i, ""); | |
| 26 | + const blob = await readBlob(dir, sha, path).catch(() => null); | |
| 27 | + if (!blob) return { path, name, error: `${path} doesn't exist at this commit.` }; | |
| 28 | + if (blob.tooLarge || blob.binary) return { path, name, error: "The workflow file is too large or not text." }; | |
| 29 | + try { | |
| 30 | + return { path, workflow: parseWorkflow(blob.content.toString("utf8"), path) }; | |
| 31 | + } catch (e) { | |
| 32 | + if (e instanceof WorkflowError) return { path, name, error: e.message }; | |
| 33 | + throw e; | |
| 34 | + } | |
| 35 | + } | |
| 36 | + | |
| 37 | + async function start(repo: RepoRecord, file: WorkflowFile, run: { event: RunEvent; ref: string; sha: string; actorId: string | null }) { | |
| 38 | + const commit = await getCommit(repo.dir, run.sha); | |
| 39 | + return createRun({ | |
| 40 | + repoId: repo.id, | |
| 41 | + workflowPath: file.path, | |
| 42 | + workflowName: "workflow" in file ? file.workflow.name : file.name, | |
| 43 | + ...run, | |
| 44 | + title: commit?.subject || run.sha.slice(0, 7), | |
| 45 | + jobs: "workflow" in file ? file.workflow.jobs : { error: file.error }, | |
| 46 | + }); | |
| 47 | + } | |
| 48 | + | |
| 49 | + /** Files changed between two commits, or null when that isn't known (a new branch). */ | |
| 50 | + async function changedFiles(dir: string, before: string | null, after: string) { | |
| 51 | + if (!before) return null; | |
| 52 | + const out = await git(dir, ["diff", "--name-only", "-z", before, after]).catch(() => null); | |
| 53 | + return out === null ? null : out.split("\0").filter(Boolean); | |
| 54 | + } | |
| 55 | + | |
| 56 | + /** Starts the workflows that run on a push of `ref` (refs/heads/… or refs/tags/…). */ | |
| 57 | + async function onPush(repo: RepoRecord, ref: string, before: string | null, after: string, actorId: string | null) { | |
| 58 | + const sha = await revParse(repo.dir, after); | |
| 59 | + if (!sha) return; | |
| 60 | + const tag = ref.startsWith("refs/tags/"); | |
| 61 | + const name = ref.replace(/^refs\/(heads|tags)\//, ""); | |
| 62 | + let changed: string[] | null | undefined; | |
| 63 | + | |
| 64 | + for (const file of await readWorkflows(repo.dir, sha)) { | |
| 65 | + // A broken file can't say when it runs, so it fails on every push, where its author will see it. | |
| 66 | + if ("workflow" in file) { | |
| 67 | + const push = file.workflow.push; | |
| 68 | + if (!push || !matchesFilter(tag ? push.tags : push.branches, name)) continue; | |
| 69 | + if (!tag) { | |
| 70 | + changed ??= await changedFiles(repo.dir, before, sha); | |
| 71 | + if (!matchesPaths(push.paths, changed)) continue; | |
| 72 | + } | |
| 73 | + } | |
| 74 | + await start(repo, file, { event: "push", ref, sha, actorId }); | |
| 75 | + } | |
| 76 | + } | |
| 77 | + | |
| 78 | + /** Starts workflows for whatever a push changed, given the refs before and after it. */ | |
| 79 | + export async function onRefsUpdated(repo: RepoRecord, before: Map<string, string>, after: Map<string, string>, actorId: string | null) { | |
| 80 | + for (const [ref, sha] of after) { | |
| 81 | + const old = before.get(ref) ?? null; | |
| 82 | + if (old === sha) continue; | |
| 83 | + await onPush(repo, ref, old, sha, actorId); | |
| 84 | + } | |
| 85 | + } | |
| 86 | + | |
| 87 | + /** For callers that shouldn't wait on (or fail because of) workflows starting. */ | |
| 88 | + export function startWorkflowsInBackground(work: Promise<unknown>) { | |
| 89 | + work.catch((e) => console.error("[ci] could not start workflows:", e)); | |
| 90 | + } | |
| 91 | + | |
| 92 | + /** Starts a workflow by hand on a branch, if it has `on: workflow_dispatch`. Returns the run number. */ | |
| 93 | + export async function dispatchWorkflow(repo: RepoRecord, path: string, branch: string, actorId: string) { | |
| 94 | + const sha = await revParse(repo.dir, `refs/heads/${branch}`); | |
| 95 | + if (!sha) throw new CiError(`There is no branch named ${branch}.`); | |
| 96 | + const file = await readWorkflow(repo.dir, sha, path); | |
| 97 | + if ("error" in file) throw new CiError(file.error); | |
| 98 | + if (!file.workflow.dispatch) throw new CiError("This workflow can't be run by hand. Add workflow_dispatch to its on: triggers."); | |
| 99 | + return start(repo, file, { event: "workflow_dispatch", ref: `refs/heads/${branch}`, sha, actorId }); | |
| 100 | + } | |
| 101 | + | |
| 102 | + /** Runs a run's workflow again, as it was at the run's commit. Returns the new run's number. */ | |
| 103 | + export async function rerunWorkflow(repo: RepoRecord, run: Run, actorId: string) { | |
| 104 | + const file = await readWorkflow(repo.dir, run.sha, run.workflowPath); | |
| 105 | + return start(repo, file, { event: run.event, ref: run.ref, sha: run.sha, actorId }); | |
| 106 | + } | |
| @@ -1,238 +1,165 @@ | ||
| 1 | 1 | import "server-only"; |
| 2 | - import { mkdirSync } from "node:fs"; | |
| 2 | + import { execFile, spawn } from "node:child_process"; | |
| 3 | + import { randomBytes } from "node:crypto"; | |
| 4 | + import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; | |
| 3 | 5 | import path from "node:path"; |
| 4 | - import { DatabaseSync } from "node:sqlite"; | |
| 6 | + import { promisify } from "node:util"; | |
| 7 | + import PocketBase, { ClientResponseError, isTokenExpired, type RecordModel } from "pocketbase"; | |
| 5 | 8 | import { DATA_ROOT } from "./paths"; |
| 6 | 9 | |
| 7 | - const SCHEMA = ` | |
| 8 | - CREATE TABLE IF NOT EXISTS users ( | |
| 9 | - id INTEGER PRIMARY KEY, | |
| 10 | - username TEXT NOT NULL UNIQUE COLLATE NOCASE, | |
| 11 | - name TEXT NOT NULL DEFAULT '', | |
| 12 | - email TEXT NOT NULL UNIQUE COLLATE NOCASE, | |
| 13 | - password_hash TEXT NOT NULL, | |
| 14 | - bio TEXT NOT NULL DEFAULT '', | |
| 15 | - created_at INTEGER NOT NULL | |
| 16 | - ); | |
| 17 | - | |
| 18 | - CREATE TABLE IF NOT EXISTS sessions ( | |
| 19 | - id TEXT PRIMARY KEY, -- sha256 of the cookie token | |
| 20 | - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 21 | - created_at INTEGER NOT NULL, | |
| 22 | - expires_at INTEGER NOT NULL | |
| 23 | - ); | |
| 24 | - | |
| 25 | - CREATE TABLE IF NOT EXISTS ssh_keys ( | |
| 26 | - id INTEGER PRIMARY KEY, | |
| 27 | - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 28 | - title TEXT NOT NULL, | |
| 29 | - key_type TEXT NOT NULL, | |
| 30 | - public_key TEXT NOT NULL, -- "ssh-ed25519 AAAA..." without comment | |
| 31 | - fingerprint TEXT NOT NULL UNIQUE, -- SHA256:... | |
| 32 | - created_at INTEGER NOT NULL, | |
| 33 | - last_used_at INTEGER | |
| 34 | - ); | |
| 35 | - | |
| 36 | - CREATE TABLE IF NOT EXISTS api_tokens ( | |
| 37 | - id INTEGER PRIMARY KEY, | |
| 38 | - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 39 | - name TEXT NOT NULL, | |
| 40 | - token_hash TEXT NOT NULL UNIQUE, -- sha256 of the token | |
| 41 | - hint TEXT NOT NULL, -- last 4 chars, shown in the UI | |
| 42 | - permission TEXT NOT NULL CHECK (permission IN ('read', 'write')), | |
| 43 | - all_repos INTEGER NOT NULL DEFAULT 1, | |
| 44 | - expires_at INTEGER, | |
| 45 | - created_at INTEGER NOT NULL, | |
| 46 | - last_used_at INTEGER | |
| 47 | - ); | |
| 48 | - | |
| 49 | - CREATE TABLE IF NOT EXISTS repos ( | |
| 50 | - id INTEGER PRIMARY KEY, | |
| 51 | - owner_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 52 | - name TEXT NOT NULL COLLATE NOCASE, | |
| 53 | - description TEXT NOT NULL DEFAULT '', | |
| 54 | - visibility TEXT NOT NULL DEFAULT 'public' CHECK (visibility IN ('public', 'private')), | |
| 55 | - created_at INTEGER NOT NULL, | |
| 56 | - pushed_at INTEGER, | |
| 57 | - UNIQUE (owner_id, name) | |
| 58 | - ); | |
| 59 | - | |
| 60 | - -- An uploaded repository image. Without a row, the owner's avatar is shown. | |
| 61 | - CREATE TABLE IF NOT EXISTS repo_images ( | |
| 62 | - repo_id INTEGER PRIMARY KEY REFERENCES repos(id) ON DELETE CASCADE, | |
| 63 | - content_type TEXT NOT NULL, | |
| 64 | - data BLOB NOT NULL, | |
| 65 | - updated_at INTEGER NOT NULL | |
| 66 | - ); | |
| 67 | - | |
| 68 | - CREATE TABLE IF NOT EXISTS api_token_repos ( | |
| 69 | - token_id INTEGER NOT NULL REFERENCES api_tokens(id) ON DELETE CASCADE, | |
| 70 | - repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 71 | - PRIMARY KEY (token_id, repo_id) | |
| 72 | - ); | |
| 73 | - | |
| 74 | - CREATE TABLE IF NOT EXISTS collaborators ( | |
| 75 | - repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 76 | - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 77 | - created_at INTEGER NOT NULL, | |
| 78 | - PRIMARY KEY (repo_id, user_id) | |
| 79 | - ); | |
| 80 | - | |
| 81 | - CREATE TABLE IF NOT EXISTS issues ( | |
| 82 | - id INTEGER PRIMARY KEY, | |
| 83 | - repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 84 | - number INTEGER NOT NULL, | |
| 85 | - title TEXT NOT NULL, | |
| 86 | - body TEXT NOT NULL DEFAULT '', | |
| 87 | - author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 88 | - state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed')), | |
| 89 | - created_at INTEGER NOT NULL, | |
| 90 | - updated_at INTEGER NOT NULL, | |
| 91 | - UNIQUE (repo_id, number) | |
| 92 | - ); | |
| 93 | - | |
| 94 | - CREATE TABLE IF NOT EXISTS labels ( | |
| 95 | - id INTEGER PRIMARY KEY, | |
| 96 | - repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 97 | - name TEXT NOT NULL COLLATE NOCASE, | |
| 98 | - color TEXT NOT NULL, -- #rrggbb | |
| 99 | - UNIQUE (repo_id, name) | |
| 100 | - ); | |
| 101 | - | |
| 102 | - CREATE TABLE IF NOT EXISTS issue_labels ( | |
| 103 | - issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 104 | - label_id INTEGER NOT NULL REFERENCES labels(id) ON DELETE CASCADE, | |
| 105 | - PRIMARY KEY (issue_id, label_id) | |
| 106 | - ); | |
| 107 | - | |
| 108 | - -- Property changes, shown in an issue's activity feed. Values are stored as | |
| 109 | - -- they read at the time (status ids, priority numbers, usernames, label names). | |
| 110 | - CREATE TABLE IF NOT EXISTS issue_events ( | |
| 111 | - id INTEGER PRIMARY KEY, | |
| 112 | - issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 113 | - actor_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 114 | - field TEXT NOT NULL, | |
| 115 | - old_value TEXT, | |
| 116 | - new_value TEXT, | |
| 117 | - created_at INTEGER NOT NULL | |
| 118 | - ); | |
| 119 | - | |
| 120 | - CREATE TABLE IF NOT EXISTS issue_comments ( | |
| 121 | - id INTEGER PRIMARY KEY, | |
| 122 | - issue_id INTEGER NOT NULL REFERENCES issues(id) ON DELETE CASCADE, | |
| 123 | - author_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 124 | - body TEXT NOT NULL, | |
| 125 | - created_at INTEGER NOT NULL | |
| 126 | - ); | |
| 127 | - | |
| 128 | - -- Container registry. Blob contents live under registry/blobs, shared by all repositories. | |
| 129 | - CREATE TABLE IF NOT EXISTS registry_blobs ( | |
| 130 | - digest TEXT PRIMARY KEY, -- sha256:… | |
| 131 | - size INTEGER NOT NULL, | |
| 132 | - created_at INTEGER NOT NULL | |
| 133 | - ); | |
| 134 | - | |
| 135 | - -- The blobs a repository may serve: pushed to it or mounted from another repository. | |
| 136 | - -- created_at is refreshed on use; unreferenced links are pruned once it's old. | |
| 137 | - CREATE TABLE IF NOT EXISTS registry_repo_blobs ( | |
| 138 | - repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 139 | - digest TEXT NOT NULL REFERENCES registry_blobs(digest) ON DELETE CASCADE, | |
| 140 | - created_at INTEGER NOT NULL, | |
| 141 | - PRIMARY KEY (repo_id, digest) | |
| 142 | - ); | |
| 143 | - | |
| 144 | - CREATE TABLE IF NOT EXISTS registry_uploads ( | |
| 145 | - id TEXT PRIMARY KEY, -- file name under registry/uploads | |
| 146 | - repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 147 | - size INTEGER NOT NULL, | |
| 148 | - created_at INTEGER NOT NULL, | |
| 149 | - updated_at INTEGER NOT NULL | |
| 150 | - ); | |
| 151 | - | |
| 152 | - -- image is the name below owner/repo ('' for the repository's own image). | |
| 153 | - CREATE TABLE IF NOT EXISTS registry_manifests ( | |
| 154 | - repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | |
| 155 | - image TEXT NOT NULL, | |
| 156 | - digest TEXT NOT NULL, | |
| 157 | - media_type TEXT NOT NULL, | |
| 158 | - content BLOB NOT NULL, | |
| 159 | - size INTEGER NOT NULL, -- config and layers, compressed | |
| 160 | - subject TEXT, -- digest of the manifest this one is attached to | |
| 161 | - artifact_type TEXT, | |
| 162 | - created_at INTEGER NOT NULL, | |
| 163 | - PRIMARY KEY (repo_id, image, digest) | |
| 164 | - ); | |
| 165 | - | |
| 166 | - -- What a manifest references: its config and layer blobs, or an index's manifests. | |
| 167 | - CREATE TABLE IF NOT EXISTS registry_manifest_refs ( | |
| 168 | - repo_id INTEGER NOT NULL, | |
| 169 | - image TEXT NOT NULL, | |
| 170 | - manifest_digest TEXT NOT NULL, | |
| 171 | - digest TEXT NOT NULL, | |
| 172 | - PRIMARY KEY (repo_id, image, manifest_digest, digest), | |
| 173 | - FOREIGN KEY (repo_id, image, manifest_digest) REFERENCES registry_manifests(repo_id, image, digest) ON DELETE CASCADE | |
| 174 | - ); | |
| 175 | - CREATE INDEX IF NOT EXISTS registry_manifest_refs_digest ON registry_manifest_refs (repo_id, digest); | |
| 176 | - | |
| 177 | - CREATE TABLE IF NOT EXISTS registry_tags ( | |
| 178 | - repo_id INTEGER NOT NULL, | |
| 179 | - image TEXT NOT NULL, | |
| 180 | - tag TEXT NOT NULL, | |
| 181 | - digest TEXT NOT NULL, | |
| 182 | - updated_at INTEGER NOT NULL, | |
| 183 | - PRIMARY KEY (repo_id, image, tag), | |
| 184 | - FOREIGN KEY (repo_id, image, digest) REFERENCES registry_manifests(repo_id, image, digest) ON DELETE CASCADE | |
| 185 | - ); | |
| 186 | - `; | |
| 187 | - | |
| 188 | - function open() { | |
| 189 | - mkdirSync(DATA_ROOT, { recursive: true }); | |
| 190 | - const db = new DatabaseSync(path.join(DATA_ROOT, "app.db")); | |
| 191 | - db.exec("PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON; PRAGMA busy_timeout = 5000;"); | |
| 192 | - return db; | |
| 10 | + export type { RecordModel }; | |
| 11 | + | |
| 12 | + /* | |
| 13 | + * The data lives in PocketBase. Unless PB_URL points at a server of your own, Vectrum runs one | |
| 14 | + * itself: bin/pocketbase (installed by scripts/install-pocketbase.mjs) with its data in | |
| 15 | + * data/pb_data and the schema from pb_migrations/. The server talks to it as a superuser. | |
| 16 | + */ | |
| 17 | + | |
| 18 | + const MANAGED = !process.env.PB_URL; | |
| 19 | + export const PB_URL = (process.env.PB_URL ?? `http://127.0.0.1:${process.env.PB_PORT ?? 8090}`).replace(/\/+$/, ""); | |
| 20 | + const BINARY = process.env.PB_BINARY ?? path.join(process.cwd(), "bin", process.platform === "win32" ? "pocketbase.exe" : "pocketbase"); | |
| 21 | + const PB_DATA = path.join(DATA_ROOT, "pb_data"); | |
| 22 | + const MIGRATIONS = path.join(process.cwd(), "pb_migrations"); | |
| 23 | + | |
| 24 | + type Credentials = { email: string; password: string }; | |
| 25 | + | |
| 26 | + /** From the environment, or (for the server Vectrum runs) generated once and kept in the data directory. */ | |
| 27 | + function superuserCredentials(): Credentials { | |
| 28 | + const { PB_SUPERUSER_EMAIL: email, PB_SUPERUSER_PASSWORD: password } = process.env; | |
| 29 | + if (email && password) return { email, password }; | |
| 30 | + if (!MANAGED) throw new Error("Set PB_SUPERUSER_EMAIL and PB_SUPERUSER_PASSWORD to use the PocketBase server at PB_URL."); | |
| 31 | + const file = path.join(DATA_ROOT, "pocketbase-superuser.json"); | |
| 32 | + if (!existsSync(file)) { | |
| 33 | + mkdirSync(DATA_ROOT, { recursive: true }); | |
| 34 | + const generated = { email: "superuser@vectrum.local", password: randomBytes(24).toString("base64url") }; | |
| 35 | + writeFileSync(file, JSON.stringify(generated, null, 2), { mode: 0o600 }); | |
| 36 | + } | |
| 37 | + return JSON.parse(readFileSync(file, "utf8")) as Credentials; | |
| 38 | + } | |
| 39 | + | |
| 40 | + const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); | |
| 41 | + | |
| 42 | + async function healthy() { | |
| 43 | + try { | |
| 44 | + return (await fetch(`${PB_URL}/api/health`, { signal: AbortSignal.timeout(1000) })).ok; | |
| 45 | + } catch { | |
| 46 | + return false; | |
| 47 | + } | |
| 48 | + } | |
| 49 | + | |
| 50 | + /** Starts PocketBase unless it already runs (e.g. left over from before a restart). */ | |
| 51 | + async function ensureServer(credentials: Credentials) { | |
| 52 | + if (await healthy()) return; | |
| 53 | + if (!MANAGED) throw new Error(`PocketBase isn't reachable at ${PB_URL}.`); | |
| 54 | + if (!existsSync(BINARY)) throw new Error(`PocketBase isn't installed at ${BINARY}. Run \`bun run pocketbase:install\`.`); | |
| 55 | + | |
| 56 | + const dirs = [`--dir=${PB_DATA}`, `--migrationsDir=${MIGRATIONS}`]; | |
| 57 | + await promisify(execFile)(BINARY, ["superuser", "upsert", credentials.email, credentials.password, ...dirs]); | |
| 58 | + const child = spawn(BINARY, ["serve", `--http=${new URL(PB_URL).host}`, ...dirs], { stdio: ["ignore", "ignore", "inherit"] }); | |
| 59 | + process.once("exit", () => child.kill()); | |
| 60 | + child.once("exit", (code) => console.error(`[pocketbase] exited with code ${code}`)); | |
| 61 | + | |
| 62 | + for (const deadline = Date.now() + 20_000; Date.now() < deadline; ) { | |
| 63 | + if (await healthy()) return console.log(`[pocketbase] listening on ${PB_URL}`); | |
| 64 | + if (child.exitCode !== null) break; | |
| 65 | + await sleep(100); | |
| 66 | + } | |
| 67 | + throw new Error("PocketBase didn't start. See its output above."); | |
| 68 | + } | |
| 69 | + | |
| 70 | + type Connection = { pb: PocketBase; credentials: Credentials; reauth?: Promise<unknown> }; | |
| 71 | + | |
| 72 | + async function connect(): Promise<Connection> { | |
| 73 | + const credentials = superuserCredentials(); | |
| 74 | + await ensureServer(credentials); | |
| 75 | + const pb = client(); | |
| 76 | + await pb.collection("_superusers").authWithPassword(credentials.email, credentials.password); | |
| 77 | + return { pb, credentials }; | |
| 78 | + } | |
| 79 | + | |
| 80 | + /** A client without credentials. Requests run concurrently, so the SDK mustn't cancel duplicates. */ | |
| 81 | + export function client() { | |
| 82 | + const pb = new PocketBase(PB_URL); | |
| 83 | + pb.autoCancellation(false); | |
| 84 | + return pb; | |
| 85 | + } | |
| 86 | + | |
| 87 | + // One connection and one set of locks for the whole process: Next.js may load this module more than | |
| 88 | + // once (route handlers, pages, instrumentation), and reloads it in development. | |
| 89 | + const g = globalThis as unknown as { __pocketbase?: Promise<Connection>; __dbLocks?: Map<string, Promise<unknown>> }; | |
| 90 | + | |
| 91 | + /** The superuser client. */ | |
| 92 | + export async function db() { | |
| 93 | + const conn = await (g.__pocketbase ??= connect().catch((e) => { | |
| 94 | + g.__pocketbase = undefined; | |
| 95 | + throw e; | |
| 96 | + })); | |
| 97 | + if (isTokenExpired(conn.pb.authStore.token, 60)) { | |
| 98 | + await (conn.reauth ??= conn.pb | |
| 99 | + .collection("_superusers") | |
| 100 | + .authWithPassword(conn.credentials.email, conn.credentials.password) | |
| 101 | + .finally(() => (conn.reauth = undefined))); | |
| 102 | + } | |
| 103 | + return conn.pb; | |
| 193 | 104 | } |
| 194 | 105 | |
| 195 | - // Reuse one connection across hot reloads in development. The schema is | |
| 196 | - // re-applied on every load (it's idempotent) so new tables reach a reused connection. | |
| 197 | - const g = globalThis as unknown as { __db?: DatabaseSync }; | |
| 198 | - export const db = (g.__db ??= open()); | |
| 199 | - db.exec(SCHEMA); | |
| 200 | - | |
| 201 | - // Columns added to existing tables after their first release. CREATE TABLE IF NOT | |
| 202 | - // EXISTS leaves old tables alone, so missing columns are added here. | |
| 203 | - const ADDED_COLUMNS: { table: string; column: string; definition: string; backfill?: string }[] = [ | |
| 204 | - { | |
| 205 | - table: "issues", | |
| 206 | - column: "status", | |
| 207 | - definition: `TEXT NOT NULL DEFAULT 'todo' | |
| 208 | - CHECK (status IN ('backlog', 'todo', 'in_progress', 'in_review', 'done', 'canceled'))`, | |
| 209 | - // `state` stays in sync with `status`: done and canceled issues are closed. | |
| 210 | - backfill: "UPDATE issues SET status = CASE state WHEN 'closed' THEN 'done' ELSE 'todo' END", | |
| 211 | - }, | |
| 212 | - // 0 = no priority, 1 = urgent … 4 = low | |
| 213 | - { table: "issues", column: "priority", definition: "INTEGER NOT NULL DEFAULT 0 CHECK (priority BETWEEN 0 AND 4)" }, | |
| 214 | - { table: "issues", column: "assignee_id", definition: "INTEGER REFERENCES users(id) ON DELETE SET NULL" }, | |
| 215 | - { table: "issues", column: "estimate", definition: "INTEGER" }, | |
| 216 | - { table: "issues", column: "due_date", definition: "TEXT" }, // YYYY-MM-DD | |
| 217 | - // Manual order on the board and in lists; new issues go to the top. | |
| 218 | - { table: "issues", column: "sort_order", definition: "REAL NOT NULL DEFAULT 0", backfill: "UPDATE issues SET sort_order = -created_at" }, | |
| 219 | - ]; | |
| 220 | - | |
| 221 | - for (const { table, column, definition, backfill } of ADDED_COLUMNS) { | |
| 222 | - const columns = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]; | |
| 223 | - if (columns.some((c) => c.name === column)) continue; | |
| 224 | - db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); | |
| 225 | - if (backfill) db.exec(backfill); | |
| 106 | + /** A record id, for records created in batches that reference each other. */ | |
| 107 | + export function newId() { | |
| 108 | + const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; | |
| 109 | + return Array.from(randomBytes(15), (b) => alphabet[b % alphabet.length]).join(""); | |
| 226 | 110 | } |
| 227 | 111 | |
| 228 | - export function transaction<T>(fn: () => T): T { | |
| 229 | - db.exec("BEGIN IMMEDIATE"); | |
| 112 | + /** The first record matching a filter, or null. */ | |
| 113 | + export async function findFirst<T = RecordModel>(collection: string, filter: string, options: { sort?: string; expand?: string; fields?: string } = {}) { | |
| 114 | + const { items } = await (await db()).collection(collection).getList<T>(1, 1, { filter, skipTotal: true, ...options }); | |
| 115 | + return items[0] ?? null; | |
| 116 | + } | |
| 117 | + | |
| 118 | + /** A record by id, or null. */ | |
| 119 | + export async function findById<T = RecordModel>(collection: string, id: string, options: { expand?: string } = {}) { | |
| 120 | + if (!id) return null; | |
| 230 | 121 | try { |
| 231 | - const result = fn(); | |
| 232 | - db.exec("COMMIT"); | |
| 233 | - return result; | |
| 122 | + return await (await db()).collection(collection).getOne<T>(id, options); | |
| 234 | 123 | } catch (e) { |
| 235 | - db.exec("ROLLBACK"); | |
| 124 | + if (e instanceof ClientResponseError && e.status === 404) return null; | |
| 236 | 125 | throw e; |
| 237 | 126 | } |
| 238 | 127 | } |
| 128 | + | |
| 129 | + /** How many records match a filter. */ | |
| 130 | + export async function count(collection: string, filter: string) { | |
| 131 | + return (await (await db()).collection(collection).getList(1, 1, { filter, fields: "id" })).totalItems; | |
| 132 | + } | |
| 133 | + | |
| 134 | + /** Builds a filter expression with safely quoted parameters. */ | |
| 135 | + export function filter(expression: string, params: Record<string, unknown> = {}) { | |
| 136 | + return client().filter(expression, params); | |
| 137 | + } | |
| 138 | + | |
| 139 | + /** Whether a request failed because a value broke a unique index. */ | |
| 140 | + export function isUniqueViolation(e: unknown) { | |
| 141 | + if (!(e instanceof ClientResponseError) || e.status !== 400) return false; | |
| 142 | + const data = (e.response?.data ?? {}) as Record<string, { code?: string }>; | |
| 143 | + return Object.values(data).some((v) => v?.code === "validation_not_unique"); | |
| 144 | + } | |
| 145 | + | |
| 146 | + const locks = (g.__dbLocks ??= new Map()); | |
| 147 | + | |
| 148 | + /** | |
| 149 | + * Runs `fn` after every earlier call with the same key has finished. PocketBase has no | |
| 150 | + * transactions over its API, so read-modify-write sequences (numbering issues, the job queue) | |
| 151 | + * are serialized here. That holds because one Node.js process serves all requests. | |
| 152 | + */ | |
| 153 | + export function withLock<T>(key: string, fn: () => Promise<T>): Promise<T> { | |
| 154 | + const previous = locks.get(key) ?? Promise.resolve(); | |
| 155 | + const result = previous.catch(() => {}).then(fn); | |
| 156 | + const settled = result.catch(() => {}); | |
| 157 | + locks.set(key, settled); | |
| 158 | + settled.then(() => { | |
| 159 | + if (locks.get(key) === settled) locks.delete(key); | |
| 160 | + }); | |
| 161 | + return result; | |
| 162 | + } | |
| 163 | + | |
| 164 | + /** 0 and "" stand for "not set" in PocketBase number, text and relation fields. */ | |
| 165 | + export const orNull = <T extends string | number>(value: T | null | undefined): T | null => (value ? value : null); | |
| @@ -36,3 +36,12 @@ export function shortSha(sha: string) { | ||
| 36 | 36 | export function encodePath(p: string) { |
| 37 | 37 | return p.split("/").map(encodeURIComponent).join("/"); |
| 38 | 38 | } |
| 39 | + | |
| 40 | + /** A run's length: "45s", "3m 12s", "1h 4m". */ | |
| 41 | + export function formatDuration(ms: number) { | |
| 42 | + const s = Math.max(0, Math.round(ms / 1000)); | |
| 43 | + if (s < 60) return `${s}s`; | |
| 44 | + const m = Math.floor(s / 60); | |
| 45 | + if (m < 60) return `${m}m ${s % 60}s`; | |
| 46 | + return `${Math.floor(m / 60)}h ${m % 60}m`; | |
| 47 | + } | |
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.
Large diffs are not rendered by default.