commit 5313b56774bf440385fa927f8909b71aabc58111 Author: Lukasz Raczylo Date: Fri Oct 2 04:28:52 2026 +0100 feat: add GitHub repository dashboard with Prometheus metrics ghtracker authenticates as a GitHub App and scans a configured list of repositories on a schedule. It serves a dashboard, a JSON state endpoint, a health probe and Prometheus metrics. The dashboard lists only items that need attention: failing workflows, pull requests that wait too long or fail their checks, stale issues and projects that failed to scan. It has search, dynamic filters and a button that rescans on demand. The Vue app is embedded in the Go binary. The client protects the GitHub rate limit with conditional requests, a request reserve and Retry-After handling. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..54de9f6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +ghtracker +*.pem +config.yaml +deploy +node_modules +dist +coverage diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..cb8dda9 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,30 @@ +name: Pull request checks + +on: + pull_request: + +permissions: + contents: read + pull-requests: write + security-events: write + +jobs: + go: + uses: lukaszraczylo/shared-actions/.github/workflows/go-pr.yaml@main + with: + go-version: '>=1.24' + secrets: inherit + + ui: + name: UI checks + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v6 + with: + node-version: 22 + - run: npx --yes pnpm@11.21.0 install --frozen-lockfile + - run: npx --yes pnpm@11.21.0 lint + - run: npx --yes pnpm@11.21.0 typecheck + - run: npx --yes pnpm@11.21.0 test diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..cc0463f --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,23 @@ +name: Test, build, release + +on: + workflow_dispatch: + push: + branches: + - main + paths-ignore: + - '**.md' + - '.github/workflows/ci.yaml' + +permissions: + id-token: write + contents: write + packages: write + +jobs: + release: + uses: lukaszraczylo/shared-actions/.github/workflows/go-release.yaml@main + with: + go-version: '>=1.24' + docker-enabled: true + secrets: inherit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..42ed810 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/ghtracker +config.yaml +*.pem +node_modules +coverage +dist/* +!dist/.gitkeep +release-out diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..32ffbc8 --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,88 @@ +version: 2 + +# GoReleaser cleans its output directory on `--clean`. The UI build lives in dist/, +# so release output goes elsewhere. +dist: release-out + +builds: + - id: ghtracker + main: . + binary: ghtracker + env: + - CGO_ENABLED=0 + goos: [linux, darwin, windows] + goarch: [amd64, arm64] + flags: [-trimpath] + ldflags: + - -s -w + - -X main.version={{ .Version }} + +archives: + - id: ghtracker + formats: [tar.gz] + name_template: 'ghtracker-{{ .Os }}-{{ .Arch }}' + format_overrides: + - goos: windows + formats: [zip] + files: + - LICENSE + - README.md + - config.example.yaml + +checksum: + name_template: 'ghtracker-checksums.txt' + algorithm: sha256 + +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' + - '^ci:' + - '^chore:' + - '^style:' + - '^Merge' + +release: + github: + owner: lukaszraczylo + name: ghtracker + name_template: 'v{{ .Version }}' + prerelease: auto + +dockers_v2: + - images: + - ghcr.io/lukaszraczylo/ghtracker + tags: + - '{{ .Version }}' + - latest + platforms: + - linux/amd64 + - linux/arm64 + dockerfile: Dockerfile.goreleaser + labels: + org.opencontainers.image.source: https://github.com/lukaszraczylo/ghtracker + org.opencontainers.image.description: Dashboard and Prometheus metrics for GitHub repositories + org.opencontainers.image.licenses: MIT + org.opencontainers.image.version: '{{ .Version }}' + +signs: + - cmd: cosign + signature: '${artifact}.sigstore.json' + args: + - sign-blob + - '--bundle=${signature}' + - '${artifact}' + - '--yes' + artifacts: checksum + output: true + +docker_signs: + - cmd: cosign + artifacts: manifests + output: true + args: + - sign + - '${artifact}@${digest}' + - '--yes' diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..186aaf2 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@fortawesome:registry=https://registry.npmjs.org/ diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..fd90118 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1 @@ +{ "semi": false, "singleQuote": true, "printWidth": 100 } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..669fb60 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM node:22-alpine AS ui +WORKDIR /app +RUN corepack enable +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./ +RUN pnpm install --frozen-lockfile +COPY index.html vite.config.ts tsconfig.json ./ +COPY scripts ./scripts +COPY src ./src +RUN pnpm build + +FROM golang:1.27 AS build +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +COPY --from=ui /app/dist ./dist +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /ghtracker . + +FROM gcr.io/distroless/static-debian12:nonroot +COPY --from=build /ghtracker /ghtracker +EXPOSE 8080 +ENTRYPOINT ["/ghtracker", "-config", "/config/config.yaml"] diff --git a/Dockerfile.goreleaser b/Dockerfile.goreleaser new file mode 100644 index 0000000..1ef2476 --- /dev/null +++ b/Dockerfile.goreleaser @@ -0,0 +1,5 @@ +FROM gcr.io/distroless/static-debian12:nonroot +ARG TARGETPLATFORM +COPY ${TARGETPLATFORM}/ghtracker /ghtracker +EXPOSE 8080 +ENTRYPOINT ["/ghtracker", "-config", "/config/config.yaml"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..745270f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Lukasz Raczylo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..e4f7941 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +.PHONY: ui build test lint dev + +ui: + pnpm install --frozen-lockfile + pnpm build + +build: ui + go build -o ghtracker . + +test: + pnpm test + go test -race ./... + +lint: + pnpm lint + pnpm typecheck + golangci-lint run ./... + +# Run the Go server (config.yaml) and the Vite dev server with hot reload; open http://localhost:5173. +dev: + go run . -config config.yaml & pnpm dev diff --git a/README.md b/README.md new file mode 100644 index 0000000..c44959c --- /dev/null +++ b/README.md @@ -0,0 +1,180 @@ +# ghtracker + +Dashboard and Prometheus metrics for a list of GitHub repositories. The program authenticates as a GitHub App and refreshes every 6 hours by default. + +## Install + +Each release publishes binaries for Linux, macOS and Windows on the [releases page](https://github.com/lukaszraczylo/ghtracker/releases), and a multi-architecture container image: + +```sh +docker pull ghcr.io/lukaszraczylo/ghtracker:latest +``` + +Release archives and the image checksums are signed with cosign. + +## What it shows + +The dashboard lists only what needs attention, grouped by project: + +- Failing workflows, including scheduled and other background ones, with a link to the run. +- Pull requests that wait too long or fail their checks, with links and age. +- Stale issues, as one line per project. +- Projects that failed to refresh, or loaded only in part. + +A side panel lists every project with its latest release (or newest tag), a link to the release, and counts of workflows, open pull requests and issues. Healthy items do not appear anywhere else. + +### Search and filters + +- Type in the search box, or press `/`, to match projects, versions, item titles and details. Every word must match. Press `Esc` to clear. +- Filter by severity, by kind (workflows, pull requests, issues, data problems) and by owner. Each chip shows how many items it would leave, given the other filters, and chips that would match nothing are disabled. +- Select a segment of the health strip or a project name to focus one project. +- The URL holds the filters (`?q=bump&kind=pr&owner=lukaszraczylo`), so you can bookmark or share a filtered view. + +## Setup + +1. Create a GitHub App with read-only repository permissions: Metadata, Issues, Pull requests, Actions, Checks, Commit statuses and Contents. +2. Install the App on the repositories you want to observe. +3. Generate a private key for the App. +4. Copy `config.example.yaml` to `config.yaml`, then set `github.app_id` and the key path. You can set `GHTRACKER_PRIVATE_KEY` to the PEM contents instead of a file. +5. Run `make build`, then `./ghtracker -config config.yaml`, and open http://localhost:8080. + +## Alert rules + +| Condition | Severity | +| ---------------------------------------------------------------------------------------- | -------- | +| Latest completed default-branch run of a workflow failed (includes background workflows) | crit | +| Non-draft PR open longer than `pr_crit_after` | crit | +| Repo refresh failed (the dashboard keeps the last good data) | crit | +| Non-draft PR open longer than `pr_warn_after` | warn | +| PR checks failing | warn | +| Workflow queued or running longer than `workflow_stuck_after` | warn | +| Open issues without activity for `issue_stale_after` (one alert per repo) | warn | +| A section failed to load, for example a missing App permission | warn | +| Data older than `data_stale_after` | warn | + +Alerts are evaluated when you read them, so PR ages keep advancing between refreshes. Set a threshold to a negative value to disable it. Draft PRs and archived repos produce no work alerts. + +## Endpoints + +- `/` dashboard. +- `/metrics` Prometheus metrics. +- `/api/state` full state as JSON. +- `/healthz` returns 503 until the first refresh completes. +- `POST /refresh` runs an extra refresh and backs the Refresh button. It is on by default, limited to once per 5 minutes, and refuses cross-site requests. Set `manual_refresh: false` to disable it. + +## Metrics + +All metrics use the `ghtracker_` prefix. Gauges are computed at scrape time. + +| Metric | Labels | +| -------------------------------------------------------------------------------------------------------------- | -------------------------- | +| `repo_up`, `repo_health` (0 ok, 1 warn, 2 crit) | repo | +| `repo_open_issues`, `repo_open_prs`, `repo_open_prs_failing_checks`, `repo_stale_issues` | repo | +| `repo_oldest_pr_age_seconds`, `repo_last_refresh_timestamp_seconds` | repo | +| `pr_age_seconds`, `pr_checks_failing` | repo, number | +| `workflow_failing`, `workflow_running`, `workflow_last_run_timestamp_seconds` | repo, workflow, background | +| `release_info`, `release_published_timestamp_seconds` | repo, version, url | +| `alerts` | severity | +| `refreshes_total`, `repo_refresh_errors_total`, `last_refresh_duration_seconds`, `github_rate_limit_remaining` | none | + +Example alert rules: + +```yaml +- alert: GhtrackerWorkflowFailing + expr: ghtracker_workflow_failing == 1 + for: 10m +- alert: GhtrackerPRWaiting + expr: ghtracker_pr_age_seconds > 3 * 86400 +- alert: GhtrackerRepoDown + expr: ghtracker_repo_up == 0 + for: 30m +- alert: GhtrackerStale + expr: time() - ghtracker_last_refresh_timestamp_seconds > 13 * 3600 +- alert: GhtrackerRateLimitLow + expr: ghtracker_github_rate_limit_remaining < 500 +``` + +## GitHub rate limits + +The program stays well below the limits of an installation token (at least 5,000 requests per hour): + +- One refresh costs about 6 requests per repo, plus 2 per open PR and 1 per active workflow. The default interval is 6 hours. +- Conditional requests with `ETag` make unchanged data free: a 304 reply does not count against the primary limit. +- Concurrency defaults to 2 repos at a time. Pagination stops at `max_items`. +- Requests stop while fewer than 100 remain in the window, until the window resets. +- On a primary or secondary limit response, the client honours `Retry-After` or the reset time, and the current refresh stops. It does not retry. Repos keep their last good data. +- Transient 5xx errors retry twice with a short back-off. Other errors do not retry. +- `refresh_interval` has a one-minute minimum. + +## Limits of the workflow view + +The program lists default-branch runs only. Runs of a workflow that never ran on the default branch do not appear. Pull request runs appear as PR checks. + +## Docker + +Run the published image, or build your own with the commands below. + +```sh +docker run --rm -p 8080:8080 \ + -v "$PWD/config.yaml:/config/config.yaml:ro" \ + -e GHTRACKER_PRIVATE_KEY="$(cat app.pem)" \ + ghcr.io/lukaszraczylo/ghtracker:latest +``` + +To build the image yourself: + +```sh +docker build -t ghtracker . +docker run --rm -p 8080:8080 \ + -v "$PWD/config.yaml:/config/config.yaml:ro" \ + -e GHTRACKER_PRIVATE_KEY="$(cat app.pem)" \ + ghtracker +``` + +The image is one static binary, with the UI embedded, on `distroless/static:nonroot`. The config must set `listen: ":8080"` so the port is reachable from outside the container. Build and push the image yourself, then set it in `deploy/kubernetes/deployment.yaml`. + +## Kubernetes + +`deploy/kubernetes/` holds a Kustomize base: + +| File | Purpose | +| -------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| `externalsecret.yaml` | Reads the App private key from your secret store through an External Secrets `ClusterSecretStore`. | +| `configmap.yaml` | The config file, with the App ID, installation ID and repo list. | +| `deployment.yaml` | One replica, non-root, read-only root filesystem, `GHTRACKER_PRIVATE_KEY` from the secret. | +| `service.yaml`, `ingressroute.yaml` | Service on port 80 and a Traefik route. Replace the host. | +| `servicemonitor.yaml`, `prometheusrule.yaml` | Scrape every 5 minutes and five starter alerts. | + +```sh +kustomize build deploy/kubernetes | kubectl diff -f - +``` + +Commit the manifests to the GitOps repo and let ArgoCD sync them. Do not use `kubectl apply`. + +Keep the replica count at 1. Each replica refreshes on its own and spends GitHub API budget. The dashboard has no authentication, so keep the route on an internal entrypoint or add an auth middleware. + +## Frontend + +The UI is a Vue 3 single-page app in `src/`: Vite, Pinia, Tailwind CSS 4, shadcn-vue components and Font Awesome icons. `pnpm build` writes it to `dist/`, and the Go binary embeds that directory with `go:embed`, so the result is one file. + +The server returns `503 UI is not built` at `/` until you run `pnpm build`, because `go build` embeds whatever `dist/` holds. `make build` runs both steps. + +The UI reads one JSON document from `/api/state` every 60 seconds. The Go side evaluates alert rules, and the UI only groups and displays them. Ages advance in the browser between polls. + +## Releases + +Every push to `main` runs the shared release workflow from [`lukaszraczylo/shared-actions`](https://github.com/lukaszraczylo/shared-actions). It runs the tests, calculates the version from the commit messages with `semver-generator`, then runs GoReleaser. The result is a GitHub release with binaries and checksums, and a container image on `ghcr.io`. + +`semver.yaml` sets the rules: `fix`, `perf`, `refactor` and other maintenance types bump the patch number, `feat` bumps the minor number, and a `Semver-Major:` trailer line is the only way to start a major release. + +`workflow-prepare.sh` builds the UI before the Go build, because the binary embeds `dist/`. + +## Development + +```sh +make dev # Go server on :8080 plus Vite with hot reload on :5173 +make test # vitest and go test -race +make lint # eslint, vue-tsc and golangci-lint +``` + +The Dockerfile builds the UI in a Node stage, then builds the Go binary with the result. diff --git a/components.json b/components.json new file mode 100644 index 0000000..6e7207f --- /dev/null +++ b/components.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://shadcn-vue.com/schema.json", + "style": "new-york", + "typescript": true, + "tailwind": { + "config": "", + "css": "src/style.css", + "baseColor": "neutral", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "composables": "@/composables", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib" + }, + "iconLibrary": "lucide" +} diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..633c6e1 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,23 @@ +listen: ':8080' +refresh_interval: 6h # minimum 1m; each cycle costs roughly 6 + 2 per open PR requests per repo +manual_refresh: true # the Refresh button and POST /refresh, limited to once per 5m; false disables them + +github: + app_id: 123456 + private_key_file: /secrets/ghtracker.pem # or set $GHTRACKER_PRIVATE_KEY to the PEM contents + installation_id: 0 # 0 = discover per repo; needed when repos span several owners + api_url: https://api.github.com + +thresholds: # negative disables a check + pr_warn_after: 48h # non-draft PR open this long = warning + pr_crit_after: 168h # critical + issue_stale_after: 720h # no activity this long = warning (aggregated per repo) + workflow_stuck_after: 6h # queued / in progress this long = warning + +max_items: 100 # per repo, issues and PRs +concurrency: 2 + +repos: + - lukaszraczylo/kubemirror + - lukaszraczylo/semver-generator + - lukaszraczylo/traefikoidc diff --git a/deploy/kubernetes/configmap.yaml b/deploy/kubernetes/configmap.yaml new file mode 100644 index 0000000..e1bf8d8 --- /dev/null +++ b/deploy/kubernetes/configmap.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: ghtracker +data: + config.yaml: | + listen: ":8080" + refresh_interval: 6h + github: + app_id: 123456 # your GitHub App ID + installation_id: 0 + thresholds: + pr_warn_after: 48h + pr_crit_after: 168h + issue_stale_after: 720h + workflow_stuck_after: 6h + repos: + - lukaszraczylo/kubemirror + - lukaszraczylo/semver-generator + - lukaszraczylo/traefikoidc diff --git a/deploy/kubernetes/deployment.yaml b/deploy/kubernetes/deployment.yaml new file mode 100644 index 0000000..16964a3 --- /dev/null +++ b/deploy/kubernetes/deployment.yaml @@ -0,0 +1,52 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: ghtracker +spec: + replicas: 1 # one replica: each replica refreshes on its own and spends GitHub API budget + strategy: + type: Recreate + selector: + matchLabels: + app.kubernetes.io/name: ghtracker + template: + metadata: + labels: + app.kubernetes.io/name: ghtracker + spec: + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: ghtracker + image: ghcr.io/lukaszraczylo/ghtracker:latest # replace with the image you push + args: ['-config', '/config/config.yaml'] + envFrom: + - secretRef: + name: ghtracker-github-app + ports: + - name: http + containerPort: 8080 + readinessProbe: + httpGet: { path: /healthz, port: http } + periodSeconds: 10 + livenessProbe: + httpGet: { path: /metrics, port: http } + periodSeconds: 30 + resources: + requests: { cpu: 10m, memory: 32Mi } + limits: { memory: 128Mi } + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + volumeMounts: + - name: config + mountPath: /config + readOnly: true + volumes: + - name: config + configMap: + name: ghtracker diff --git a/deploy/kubernetes/externalsecret.yaml b/deploy/kubernetes/externalsecret.yaml new file mode 100644 index 0000000..f65699c --- /dev/null +++ b/deploy/kubernetes/externalsecret.yaml @@ -0,0 +1,16 @@ +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: ghtracker-github-app +spec: + refreshInterval: 24h + secretStoreRef: + name: secrets # your ClusterSecretStore + kind: ClusterSecretStore + target: + name: ghtracker-github-app + creationPolicy: Owner + data: + - secretKey: GHTRACKER_PRIVATE_KEY + remoteRef: + key: ghtracker/app_private_key # where you keep the App private key diff --git a/deploy/kubernetes/ingressroute.yaml b/deploy/kubernetes/ingressroute.yaml new file mode 100644 index 0000000..0f8e648 --- /dev/null +++ b/deploy/kubernetes/ingressroute.yaml @@ -0,0 +1,16 @@ +# Replace the host. The dashboard has no authentication: keep it on an internal entrypoint +# or add a Traefik auth middleware before exposing it. +apiVersion: traefik.io/v1alpha1 +kind: IngressRoute +metadata: + name: ghtracker +spec: + entryPoints: + - websecure + routes: + - match: Host(`ghtracker.example.com`) + kind: Rule + services: + - name: ghtracker + port: 80 + tls: {} diff --git a/deploy/kubernetes/kustomization.yaml b/deploy/kubernetes/kustomization.yaml new file mode 100644 index 0000000..53892c7 --- /dev/null +++ b/deploy/kubernetes/kustomization.yaml @@ -0,0 +1,12 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: ghtracker +resources: + - namespace.yaml + - externalsecret.yaml + - configmap.yaml + - deployment.yaml + - service.yaml + - servicemonitor.yaml + - prometheusrule.yaml + - ingressroute.yaml diff --git a/deploy/kubernetes/namespace.yaml b/deploy/kubernetes/namespace.yaml new file mode 100644 index 0000000..f46e2ba --- /dev/null +++ b/deploy/kubernetes/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: ghtracker diff --git a/deploy/kubernetes/prometheusrule.yaml b/deploy/kubernetes/prometheusrule.yaml new file mode 100644 index 0000000..b98adee --- /dev/null +++ b/deploy/kubernetes/prometheusrule.yaml @@ -0,0 +1,35 @@ +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: ghtracker +spec: + groups: + - name: ghtracker + rules: + - alert: GhtrackerWorkflowFailing + expr: ghtracker_workflow_failing == 1 + for: 10m + labels: { severity: warning } + annotations: + summary: 'Workflow {{ $labels.workflow }} failing in {{ $labels.repo }}' + - alert: GhtrackerPullRequestWaiting + expr: ghtracker_pr_age_seconds > 3 * 86400 + labels: { severity: warning } + annotations: + summary: 'PR #{{ $labels.number }} in {{ $labels.repo }} waiting over 3 days' + - alert: GhtrackerRepoDown + expr: ghtracker_repo_up == 0 + for: 30m + labels: { severity: warning } + annotations: + summary: 'ghtracker cannot refresh {{ $labels.repo }}' + - alert: GhtrackerDataStale + expr: time() - ghtracker_last_refresh_timestamp_seconds > 13 * 3600 + labels: { severity: warning } + annotations: + summary: 'ghtracker has not refreshed for over 13 hours' + - alert: GhtrackerRateLimitLow + expr: ghtracker_github_rate_limit_remaining < 500 + labels: { severity: warning } + annotations: + summary: 'GitHub rate limit budget below 500' diff --git a/deploy/kubernetes/service.yaml b/deploy/kubernetes/service.yaml new file mode 100644 index 0000000..1c526db --- /dev/null +++ b/deploy/kubernetes/service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: ghtracker + labels: + app.kubernetes.io/name: ghtracker +spec: + selector: + app.kubernetes.io/name: ghtracker + ports: + - name: http + port: 80 + targetPort: http diff --git a/deploy/kubernetes/servicemonitor.yaml b/deploy/kubernetes/servicemonitor.yaml new file mode 100644 index 0000000..dc4940e --- /dev/null +++ b/deploy/kubernetes/servicemonitor.yaml @@ -0,0 +1,12 @@ +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: ghtracker +spec: + selector: + matchLabels: + app.kubernetes.io/name: ghtracker + endpoints: + - port: http + path: /metrics + interval: 5m # series are computed from cached data; scraping faster adds nothing diff --git a/dist/.gitkeep b/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..cec0e4d --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,30 @@ +import js from '@eslint/js' +import pluginVue from 'eslint-plugin-vue' +import globals from 'globals' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist', 'node_modules', 'coverage', 'internal', 'deploy'] }, + js.configs.recommended, + ...tseslint.configs.recommended, + ...pluginVue.configs['flat/recommended'], + { + files: ['**/*.vue'], + languageOptions: { parserOptions: { parser: tseslint.parser } }, + }, + { + files: ['**/*.{ts,vue,js}'], + languageOptions: { globals: { ...globals.browser, ...globals.node } }, + rules: { + 'vue/multi-word-component-names': 'off', + // Prettier owns formatting; shadcn-vue components also omit prop defaults on purpose. + 'vue/max-attributes-per-line': 'off', + 'vue/singleline-html-element-content-newline': 'off', + 'vue/multiline-html-element-content-newline': 'off', + 'vue/html-self-closing': 'off', + 'vue/html-indent': 'off', + 'vue/html-closing-bracket-newline': 'off', + 'vue/require-default-prop': 'off', + }, + }, +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..28c56c8 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module github.com/lukaszraczylo/ghtracker + +go 1.27.1 + +require ( + github.com/prometheus/client_golang v1.24.1 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.70.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect + golang.org/x/sys v0.47.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7614188 --- /dev/null +++ b/go.sum @@ -0,0 +1,38 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= +github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU= +github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY= +github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= +go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/index.html b/index.html new file mode 100644 index 0000000..039c94f --- /dev/null +++ b/index.html @@ -0,0 +1,12 @@ + + + + + + ghtracker + + +
+ + + diff --git a/internal/collector/collector.go b/internal/collector/collector.go new file mode 100644 index 0000000..394fb0e --- /dev/null +++ b/internal/collector/collector.go @@ -0,0 +1,220 @@ +// Package collector refreshes repository data on a schedule and evaluates health from it. +package collector + +import ( + "context" + "errors" + "log/slog" + "slices" + "sync" + "sync/atomic" + "time" + + "github.com/lukaszraczylo/ghtracker/internal/config" + "github.com/lukaszraczylo/ghtracker/internal/model" +) + +const repoFetchTimeout = 5 * time.Minute + +// Fetcher loads the data for one "owner/name" repository. +type Fetcher interface { + FetchRepo(ctx context.Context, full string) (model.Repo, error) +} + +// View is a consistent, health-evaluated read of the collector state. +type View struct { + Now time.Time + LastRefresh time.Time + Repos []model.Repo + Alerts []model.Alert + Thresholds config.Thresholds + Interval time.Duration + Crit int + Warn int + Loaded bool + Refreshing bool +} + +// Stats are monotonic counters and timings of the refresh loop. +type Stats struct { + LastRefresh time.Time + LastDuration time.Duration + RefreshTotal uint64 + RepoErrorsTotal uint64 + Loaded bool + RefreshInFlight bool +} + +type Collector struct { + lastRefresh time.Time + fetch Fetcher + repos map[string]model.Repo + now func() time.Time + log *slog.Logger + kick chan struct{} + names []string + th config.Thresholds + interval time.Duration + refreshes atomic.Uint64 + repoErrs atomic.Uint64 + lastDur time.Duration + concurrency int + mu sync.RWMutex + limited atomic.Bool + refreshing atomic.Bool + loaded bool +} + +func New(f Fetcher, cfg *config.Config, log *slog.Logger) *Collector { + return &Collector{ + fetch: f, + names: cfg.Repos, + th: cfg.Thresholds, + interval: cfg.RefreshInterval, + concurrency: cfg.Concurrency, + now: time.Now, + log: log, + kick: make(chan struct{}, 1), + repos: make(map[string]model.Repo, len(cfg.Repos)), + } +} + +// Run refreshes immediately, then on every interval or kick, until ctx ends. +func (c *Collector) Run(ctx context.Context) { + t := time.NewTicker(c.interval) + defer t.Stop() + c.Refresh(ctx) + for { + select { + case <-ctx.Done(): + return + case <-t.C: + c.Refresh(ctx) + case <-c.kick: + c.Refresh(ctx) + } + } +} + +// RequestRefresh asks Run for an extra refresh; it reports false when one is already running or queued. +func (c *Collector) RequestRefresh() bool { + if c.refreshing.Load() { + return false + } + select { + case c.kick <- struct{}{}: + return true + default: + return false + } +} + +// Refresh fetches every repo once; a failing repo keeps its last good data and is marked down. +func (c *Collector) Refresh(ctx context.Context) { + if !c.refreshing.CompareAndSwap(false, true) { + return + } + defer c.refreshing.Store(false) + + start := c.now() + c.limited.Store(false) + results := make([]model.Repo, len(c.names)) + sem := make(chan struct{}, c.concurrency) + var wg sync.WaitGroup + for i, name := range c.names { + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + results[i] = c.refreshOne(ctx, name, start) + }() + } + wg.Wait() + + c.mu.Lock() + for _, r := range results { + c.repos[r.FullName] = r + } + c.lastRefresh = start + c.lastDur = c.now().Sub(start) + c.loaded = true + c.mu.Unlock() + c.refreshes.Add(1) + c.log.Info("refresh complete", "repos", len(c.names), "duration", c.lastDur.Round(time.Millisecond)) +} + +func (c *Collector) refreshOne(ctx context.Context, name string, at time.Time) model.Repo { + ctx, cancel := context.WithTimeout(ctx, repoFetchTimeout) + defer cancel() + var r model.Repo + err := model.ErrRateLimited + // Once any repo hits the limit, remaining repos are skipped so no further requests go out. + if !c.limited.Load() { + r, err = c.fetch.FetchRepo(ctx, name) + } + if errors.Is(err, model.ErrRateLimited) { + c.limited.Store(true) + } + if err == nil { + r.FullName, r.Up, r.RefreshedAt = name, true, at + return r + } + c.repoErrs.Add(1) + c.log.Error("repo refresh failed", "repo", name, "err", err) + c.mu.RLock() + prev, ok := c.repos[name] + c.mu.RUnlock() + if !ok { + prev = model.Repo{FullName: name, URL: "https://github.com/" + name} + } + prev.Up, prev.Err = false, err.Error() + return prev +} + +// Stats returns refresh-loop counters. +func (c *Collector) Stats() Stats { + c.mu.RLock() + defer c.mu.RUnlock() + return Stats{ + LastRefresh: c.lastRefresh, + LastDuration: c.lastDur, + RefreshTotal: c.refreshes.Load(), + RepoErrorsTotal: c.repoErrs.Load(), + Loaded: c.loaded, + RefreshInFlight: c.refreshing.Load(), + } +} + +// View evaluates all repos at now. Repos not yet fetched appear with no data and are not alerted on. +func (c *Collector) View() View { + now := c.now() + c.mu.RLock() + v := View{ + Now: now, LastRefresh: c.lastRefresh, Interval: c.interval, Thresholds: c.th, + Loaded: c.loaded, Refreshing: c.refreshing.Load(), + } + v.Repos = make([]model.Repo, 0, len(c.names)) + for _, n := range c.names { + if r, ok := c.repos[n]; ok { + v.Repos = append(v.Repos, r) + } + } + c.mu.RUnlock() + + for i := range v.Repos { + v.Alerts = append(v.Alerts, Evaluate(&v.Repos[i], now, c.th)...) + } + sortAlerts(v.Alerts) + for _, a := range v.Alerts { + switch a.Severity { + case model.Crit: + v.Crit++ + case model.Warn: + v.Warn++ + } + } + // Worst repos first so problems surface at the top of the dashboard. + slices.SortStableFunc(v.Repos, func(a, b model.Repo) int { return int(b.Health) - int(a.Health) }) + return v +} diff --git a/internal/collector/collector_test.go b/internal/collector/collector_test.go new file mode 100644 index 0000000..d86711b --- /dev/null +++ b/internal/collector/collector_test.go @@ -0,0 +1,314 @@ +package collector + +import ( + "context" + "errors" + "io" + "log/slog" + "sync" + "testing" + "time" + + "github.com/lukaszraczylo/ghtracker/internal/config" + "github.com/lukaszraczylo/ghtracker/internal/model" +) + +var t0 = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) + +func th() config.Thresholds { + return config.Thresholds{ + PRWarnAfter: 48 * time.Hour, PRCritAfter: 7 * 24 * time.Hour, + IssueStaleAfter: 30 * 24 * time.Hour, WorkflowStuck: 6 * time.Hour, WorkflowDormant: 30 * 24 * time.Hour, DataStaleAfter: 13 * time.Hour, + } +} + +func kinds(a []model.Alert) map[string]model.Severity { + m := map[string]model.Severity{} + for _, x := range a { + m[x.Kind] = max(m[x.Kind], x.Severity) + } + return m +} + +func TestEvaluate(t *testing.T) { + fresh := func() model.Repo { + return model.Repo{FullName: "o/r", URL: "https://github.com/o/r", Up: true, RefreshedAt: t0} + } + failedRun := &model.Run{Status: "completed", Conclusion: "failure", URL: "run"} + tests := map[string]struct { + mutate func(*model.Repo) + want map[string]model.Severity + health model.Severity + }{ + "healthy": {func(*model.Repo) {}, map[string]model.Severity{}, model.OK}, + "refresh failed": {func(r *model.Repo) { r.Err = "boom"; r.Up = false }, map[string]model.Severity{KindRefreshFailed: model.Crit}, model.Crit}, + "data stale": {func(r *model.Repo) { r.RefreshedAt = t0.Add(-14 * time.Hour) }, map[string]model.Severity{KindDataStale: model.Warn}, model.Warn}, + "partial data": {func(r *model.Repo) { r.Warnings = []string{"workflows: 403"} }, map[string]model.Severity{KindPartialData: model.Warn}, model.Warn}, + "workflow failed": {func(r *model.Repo) { + r.Workflows = []model.Workflow{{Name: "CI", Latest: *failedRun, LastDone: failedRun}} + }, map[string]model.Severity{KindWorkflowFail: model.Crit}, model.Crit}, + "workflow rerun in progress after failure still critical": {func(r *model.Repo) { + r.Workflows = []model.Workflow{{Name: "CI", Latest: model.Run{Status: "in_progress", CreatedAt: t0}, LastDone: failedRun}} + }, map[string]model.Severity{KindWorkflowFail: model.Crit}, model.Crit}, + "dormant workflow failure muted": {func(r *model.Repo) { + old := &model.Run{Status: "completed", Conclusion: "failure", CreatedAt: t0.Add(-40 * 24 * time.Hour)} + r.Workflows = []model.Workflow{{Name: "Old", Latest: *old, LastDone: old}} + }, map[string]model.Severity{}, model.OK}, + "workflow success": {func(r *model.Repo) { + ok := &model.Run{Status: "completed", Conclusion: "success"} + r.Workflows = []model.Workflow{{Name: "CI", Latest: *ok, LastDone: ok}} + }, map[string]model.Severity{}, model.OK}, + "cancelled is not failure": {func(r *model.Repo) { + c := &model.Run{Status: "completed", Conclusion: "cancelled"} + r.Workflows = []model.Workflow{{Name: "CI", Latest: *c, LastDone: c}} + }, map[string]model.Severity{}, model.OK}, + "workflow stuck": {func(r *model.Repo) { + r.Workflows = []model.Workflow{{Name: "CI", Latest: model.Run{Status: "queued", CreatedAt: t0.Add(-7 * time.Hour)}}} + }, map[string]model.Severity{KindWorkflowStuck: model.Warn}, model.Warn}, + "pr fresh": {func(r *model.Repo) { + r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-47 * time.Hour)}} + }, map[string]model.Severity{}, model.OK}, + "pr waiting warn": {func(r *model.Repo) { + r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-49 * time.Hour)}} + }, map[string]model.Severity{KindPRWaiting: model.Warn}, model.Warn}, + "pr waiting crit": {func(r *model.Repo) { + r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-8 * 24 * time.Hour)}} + }, map[string]model.Severity{KindPRWaiting: model.Crit}, model.Crit}, + "draft pr ignored": {func(r *model.Repo) { + r.PRs = []model.PullRequest{{Number: 1, Draft: true, CreatedAt: t0.Add(-30 * 24 * time.Hour), Checks: model.CheckSummary{State: model.ChecksFail}}} + }, map[string]model.Severity{}, model.OK}, + "pr checks failing": {func(r *model.Repo) { + r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0, Checks: model.CheckSummary{State: model.ChecksFail, Failing: []string{"build"}}}} + }, map[string]model.Severity{KindPRChecks: model.Warn}, model.Warn}, + "issues stale aggregated": {func(r *model.Repo) { + old := t0.Add(-40 * 24 * time.Hour) + r.Issues = []model.Issue{{Number: 1, UpdatedAt: old}, {Number: 2, UpdatedAt: old}, {Number: 3, UpdatedAt: t0}} + }, map[string]model.Severity{KindIssuesStale: model.Warn}, model.Warn}, + "archived repo skips work alerts": {func(r *model.Repo) { + r.Archived = true + r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-30 * 24 * time.Hour)}} + }, map[string]model.Severity{}, model.OK}, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + r := fresh() + tt.mutate(&r) + alerts := Evaluate(&r, t0, th()) + got := kinds(alerts) + if len(got) != len(tt.want) { + t.Fatalf("alerts = %+v, want kinds %v", alerts, tt.want) + } + for k, sev := range tt.want { + if got[k] != sev { + t.Fatalf("kind %s severity = %v, want %v", k, got[k], sev) + } + } + if r.Health != tt.health { + t.Fatalf("health = %v, want %v", r.Health, tt.health) + } + }) + } +} + +func TestEvaluateDisabledThresholds(t *testing.T) { + r := model.Repo{FullName: "o/r", RefreshedAt: t0, PRs: []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-100 * 24 * time.Hour)}}, + Issues: []model.Issue{{UpdatedAt: t0.Add(-100 * 24 * time.Hour)}}} + off := config.Thresholds{PRWarnAfter: -1, PRCritAfter: -1, IssueStaleAfter: -1, WorkflowStuck: -1, DataStaleAfter: -1} + if a := Evaluate(&r, t0, off); len(a) != 0 { + t.Fatalf("disabled checks produced %+v", a) + } +} + +func TestAgeAdvancesWithoutRefresh(t *testing.T) { + r := model.Repo{FullName: "o/r", RefreshedAt: t0, PRs: []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-40 * time.Hour)}}} + cfg := th() + cfg.DataStaleAfter = -1 + if a := Evaluate(&r, t0, cfg); len(a) != 0 { + t.Fatalf("unexpected %+v", a) + } + if a := Evaluate(&r, t0.Add(10*time.Hour), cfg); len(a) != 1 || a[0].Severity != model.Warn { + t.Fatalf("PR must cross the warn threshold as time passes: %+v", a) + } +} + +func TestAge(t *testing.T) { + cases := map[time.Duration]string{-time.Minute: "0m", 5 * time.Minute: "5m", 90 * time.Minute: "1h", 47 * time.Hour: "47h", 49 * time.Hour: "2d", 10 * 24 * time.Hour: "10d"} + for d, want := range cases { + if got := Age(d); got != want { + t.Errorf("Age(%s) = %s, want %s", d, got, want) + } + } +} + +type fakeFetcher struct { + fn func(name string) (model.Repo, error) + calls []string + mu sync.Mutex +} + +func (f *fakeFetcher) FetchRepo(_ context.Context, name string) (model.Repo, error) { + f.mu.Lock() + f.calls = append(f.calls, name) + f.mu.Unlock() + return f.fn(name) +} + +func newCollector(f Fetcher, repos ...string) *Collector { + cfg := &config.Config{Repos: repos, RefreshInterval: time.Hour, Concurrency: 1, Thresholds: th()} + c := New(f, cfg, slog.New(slog.NewTextHandler(io.Discard, nil))) + c.now = func() time.Time { return t0 } + return c +} + +func TestRefreshKeepsLastGoodDataOnFailure(t *testing.T) { + fail := false + f := &fakeFetcher{fn: func(name string) (model.Repo, error) { + if fail { + return model.Repo{}, errors.New("boom") + } + return model.Repo{FullName: name, URL: "u", Issues: []model.Issue{{Number: 1, UpdatedAt: t0}}}, nil + }} + c := newCollector(f, "o/a") + c.Refresh(context.Background()) + if v := c.View(); !v.Loaded || len(v.Repos) != 1 || !v.Repos[0].Up || len(v.Repos[0].Issues) != 1 { + t.Fatalf("first view: %+v", v) + } + fail = true + c.Refresh(context.Background()) + v := c.View() + r := v.Repos[0] + if r.Up || r.Err != "boom" || len(r.Issues) != 1 { + t.Fatalf("must keep old data and flag error: %+v", r) + } + if v.Crit != 1 || r.Health != model.Crit { + t.Fatalf("crit=%d health=%v", v.Crit, r.Health) + } + if s := c.Stats(); s.RepoErrorsTotal != 1 || s.RefreshTotal != 2 { + t.Fatalf("stats %+v", s) + } +} + +func TestFirstRefreshFailureStillListsRepo(t *testing.T) { + c := newCollector(&fakeFetcher{fn: func(string) (model.Repo, error) { return model.Repo{}, errors.New("nope") }}, "o/a") + c.Refresh(context.Background()) + v := c.View() + if len(v.Repos) != 1 || v.Repos[0].Up || v.Repos[0].URL != "https://github.com/o/a" { + t.Fatalf("%+v", v.Repos) + } +} + +func TestRateLimitStopsRemainingRepos(t *testing.T) { + f := &fakeFetcher{fn: func(name string) (model.Repo, error) { + if name == "o/b" { + return model.Repo{}, model.ErrRateLimited + } + return model.Repo{FullName: name}, nil + }} + c := newCollector(f, "o/a", "o/b", "o/c", "o/d") + c.Refresh(context.Background()) + if len(f.calls) != 2 { + t.Fatalf("calls after limit = %v, want only a and b", f.calls) + } + v := c.View() + if len(v.Repos) != 4 { + t.Fatalf("all repos must stay listed: %d", len(v.Repos)) + } + // Next cycle starts with a clean limit flag. + f.fn = func(name string) (model.Repo, error) { return model.Repo{FullName: name}, nil } + f.calls = nil + c.Refresh(context.Background()) + if len(f.calls) != 4 { + t.Fatalf("limit flag must reset, calls=%v", f.calls) + } +} + +func TestViewSortsWorstFirstAndAlertsBySeverity(t *testing.T) { + f := &fakeFetcher{fn: func(name string) (model.Repo, error) { + r := model.Repo{FullName: name} + switch name { + case "o/crit": + c := &model.Run{Status: "completed", Conclusion: "failure"} + r.Workflows = []model.Workflow{{Name: "CI", Latest: *c, LastDone: c}} + case "o/warn": + r.PRs = []model.PullRequest{{Number: 1, CreatedAt: t0.Add(-72 * time.Hour)}} + } + return r, nil + }} + c := newCollector(f, "o/ok", "o/warn", "o/crit") + c.Refresh(context.Background()) + v := c.View() + if v.Repos[0].FullName != "o/crit" || v.Repos[1].FullName != "o/warn" || v.Repos[2].FullName != "o/ok" { + t.Fatalf("order: %s %s %s", v.Repos[0].FullName, v.Repos[1].FullName, v.Repos[2].FullName) + } + if len(v.Alerts) != 2 || v.Alerts[0].Severity != model.Crit || v.Crit != 1 || v.Warn != 1 { + t.Fatalf("alerts: %+v", v.Alerts) + } +} + +func TestRequestRefreshCoalesces(t *testing.T) { + c := newCollector(&fakeFetcher{fn: func(n string) (model.Repo, error) { return model.Repo{FullName: n}, nil }}, "o/a") + if !c.RequestRefresh() { + t.Fatal("first request must queue") + } + if c.RequestRefresh() { + t.Fatal("second request must coalesce while one is queued") + } +} + +func TestRunRefreshesOnStartAndStopsOnCancel(t *testing.T) { + c := newCollector(&fakeFetcher{fn: func(n string) (model.Repo, error) { return model.Repo{FullName: n}, nil }}, "o/a") + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { c.Run(ctx); close(done) }() + deadline := time.After(2 * time.Second) + for !c.Stats().Loaded { + select { + case <-deadline: + t.Fatal("no initial refresh") + case <-time.After(5 * time.Millisecond): + } + } + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Run did not stop") + } +} + +func TestSummarizeChecks(t *testing.T) { + tests := map[string]struct { + want string + in model.CheckSummary + }{ + "few distinct": {in: model.CheckSummary{Failed: 2, Failing: []string{"build", "lint"}}, want: "2 checks failing: build, lint"}, + "duplicates collapse and extras count": { + in: model.CheckSummary{Failed: 6, Failing: []string{"pr-checks / Tests", "pr-checks / Tests", "pr-checks / Lint", "pr-checks / Scan", "pr-checks / Gosec"}}, + want: "6 checks failing: Gosec, Lint +2 more", + }, + "mixed prefixes stay intact": {in: model.CheckSummary{Failed: 2, Failing: []string{"a / x", "b / y"}}, want: "2 checks failing: a / x, b / y"}, + "no names": {in: model.CheckSummary{Failed: 1}, want: "1 checks failing"}, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + if got := summarizeChecks(tt.in); got != tt.want { + t.Fatalf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestSummarizeWarningsMergesSharedReason(t *testing.T) { + got := summarizeWarnings([]string{ + "issues: github: HTTP 403: Resource not accessible by integration", + "workflows: github: HTTP 403: Resource not accessible by integration", + "releases: github: HTTP 500: boom", + }) + want := []string{ + "issues, workflows: github: HTTP 403: Resource not accessible by integration", + "releases: github: HTTP 500: boom", + } + if len(got) != 2 || got[0] != want[0] || got[1] != want[1] { + t.Fatalf("got %q", got) + } +} diff --git a/internal/collector/health.go b/internal/collector/health.go new file mode 100644 index 0000000..6218e3b --- /dev/null +++ b/internal/collector/health.go @@ -0,0 +1,193 @@ +package collector + +import ( + "fmt" + "slices" + "strings" + "time" + + "github.com/lukaszraczylo/ghtracker/internal/config" + "github.com/lukaszraczylo/ghtracker/internal/model" +) + +// Alert kinds; they double as stable identifiers for dashboards. +const ( + KindRefreshFailed = "refresh_failed" + KindDataStale = "data_stale" + KindPartialData = "partial_data" + KindWorkflowFail = "workflow_failed" + KindWorkflowStuck = "workflow_stuck" + KindPRWaiting = "pr_waiting" + KindPRChecks = "pr_checks_failing" + KindIssuesStale = "issues_stale" +) + +// Evaluate derives alerts for one repo at time now and stores the worst severity in r.Health. +// It runs on every read so age-based alerts advance between refreshes. +func Evaluate(r *model.Repo, now time.Time, th config.Thresholds) []model.Alert { + var alerts []model.Alert + add := func(sev model.Severity, kind, subject, detail, url string, since time.Time) { + alerts = append(alerts, model.Alert{Severity: sev, Repo: r.FullName, Kind: kind, Subject: subject, Detail: detail, URL: url, Since: since}) + r.Health = max(r.Health, sev) + } + r.Health = model.OK + + if r.Err != "" { + add(model.Crit, KindRefreshFailed, "Refresh failed", r.Err, r.URL, r.RefreshedAt) + } + if th.DataStaleAfter > 0 && !r.RefreshedAt.IsZero() && now.Sub(r.RefreshedAt) > th.DataStaleAfter { + add(model.Warn, KindDataStale, "Data is stale", "last refreshed "+Age(now.Sub(r.RefreshedAt))+" ago", r.URL, r.RefreshedAt) + } + for _, detail := range summarizeWarnings(r.Warnings) { + add(model.Warn, KindPartialData, "Incomplete data", detail, r.URL, r.RefreshedAt) + } + if r.Archived { + return alerts + } + + for _, wf := range r.Workflows { + if wf.LastDone != nil && wf.LastDone.Failed() && !Dormant(wf, now, th) { + add(model.Crit, KindWorkflowFail, workflowSubject(wf), fmt.Sprintf("%s on %s (%s)", strings.ReplaceAll(wf.LastDone.Conclusion, "_", " "), wf.LastDone.Branch, wf.LastDone.Event), wf.LastDone.URL, wf.LastDone.CreatedAt) + } + if wf.Latest.Running() && th.WorkflowStuck > 0 && now.Sub(wf.Latest.CreatedAt) > th.WorkflowStuck { + add(model.Warn, KindWorkflowStuck, workflowSubject(wf), + fmt.Sprintf("%s for %s", strings.ReplaceAll(wf.Latest.Status, "_", " "), Age(now.Sub(wf.Latest.CreatedAt))), + wf.Latest.URL, wf.Latest.CreatedAt) + } + } + + for _, pr := range r.PRs { + if pr.Draft { + continue + } + age := now.Sub(pr.CreatedAt) + subject := fmt.Sprintf("#%d %s", pr.Number, pr.Title) + switch { + case th.PRCritAfter > 0 && age >= th.PRCritAfter: + add(model.Crit, KindPRWaiting, subject, "open "+Age(age), pr.URL, pr.CreatedAt) + case th.PRWarnAfter > 0 && age >= th.PRWarnAfter: + add(model.Warn, KindPRWaiting, subject, "open "+Age(age), pr.URL, pr.CreatedAt) + } + if pr.Checks.State == model.ChecksFail { + add(model.Warn, KindPRChecks, subject, summarizeChecks(pr.Checks), pr.URL, pr.UpdatedAt) + } + } + + if th.IssueStaleAfter > 0 { + var stale int + var oldest time.Time + for _, is := range r.Issues { + if now.Sub(is.UpdatedAt) >= th.IssueStaleAfter { + stale++ + if oldest.IsZero() || is.UpdatedAt.Before(oldest) { + oldest = is.UpdatedAt + } + } + } + if stale > 0 { + add(model.Warn, KindIssuesStale, fmt.Sprintf("%d stale issues", stale), + "no activity for "+Age(th.IssueStaleAfter)+" or more", + r.URL+"/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-asc", oldest) + } + } + return alerts +} + +func workflowSubject(wf model.Workflow) string { + if wf.Background { + return wf.Name + " (background)" + } + return wf.Name +} + +func sortAlerts(a []model.Alert) { + slices.SortStableFunc(a, func(x, y model.Alert) int { + if x.Severity != y.Severity { + return int(y.Severity) - int(x.Severity) + } + if c := x.Since.Compare(y.Since); c != 0 { + return c + } + return strings.Compare(x.Repo+x.Subject, y.Repo+y.Subject) + }) +} + +// Age renders a duration at a glance, e.g. "3d", "5h", "12m". +func Age(d time.Duration) string { + switch { + case d < 0: + return "0m" + case d >= 48*time.Hour: + return fmt.Sprintf("%dd", int(d.Hours()/24)) + case d >= time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + default: + return fmt.Sprintf("%dm", int(d.Minutes())) + } +} + +// Dormant reports whether the workflow has not run for longer than the dormant threshold, +// so an old failure no longer signals a current problem. +func Dormant(wf model.Workflow, now time.Time, th config.Thresholds) bool { + return th.WorkflowDormant > 0 && !wf.Latest.CreatedAt.IsZero() && now.Sub(wf.Latest.CreatedAt) > th.WorkflowDormant +} + +const maxNamedChecks = 2 + +// summarizeChecks renders failing checks as a count plus the first few distinct names. +func summarizeChecks(c model.CheckSummary) string { + names := slices.Compact(slices.Sorted(slices.Values(c.Failing))) + if prefix, ok := sharedPrefix(names); ok { + for i, n := range names { + names[i] = strings.TrimPrefix(n, prefix) + } + } + shown := names[:min(len(names), maxNamedChecks)] + out := fmt.Sprintf("%d checks failing", c.Failed) + if len(shown) > 0 { + out += ": " + strings.Join(shown, ", ") + } + if extra := len(names) - len(shown); extra > 0 { + out += fmt.Sprintf(" +%d more", extra) + } + return out +} + +// sharedPrefix returns the common "group / " prefix of check names such as "pr-checks / Tests". +func sharedPrefix(names []string) (string, bool) { + if len(names) < 2 { + return "", false + } + group, _, ok := strings.Cut(names[0], " / ") + if !ok { + return "", false + } + prefix := group + " / " + for _, n := range names { + if !strings.HasPrefix(n, prefix) { + return "", false + } + } + return prefix, true +} + +// summarizeWarnings merges "section: reason" warnings that share a reason into one line. +func summarizeWarnings(warnings []string) []string { + var reasons []string + sections := map[string][]string{} + for _, w := range warnings { + section, reason, ok := strings.Cut(w, ": ") + if !ok { + section, reason = "data", w + } + if _, seen := sections[reason]; !seen { + reasons = append(reasons, reason) + } + sections[reason] = append(sections[reason], section) + } + out := make([]string, 0, len(reasons)) + for _, reason := range reasons { + out = append(out, strings.Join(sections[reason], ", ")+": "+reason) + } + return out +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..b709dc5 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,163 @@ +// Package config loads and validates the YAML configuration. +package config + +import ( + "errors" + "fmt" + "os" + "strings" + "time" + + "gopkg.in/yaml.v3" +) + +const ( + EnvPrivateKey = "GHTRACKER_PRIVATE_KEY" + + DefaultListen = ":8080" + DefaultRefresh = 6 * time.Hour + DefaultAPIURL = "https://api.github.com" + DefaultPRWarnAfter = 48 * time.Hour + DefaultPRCritAfter = 7 * 24 * time.Hour + DefaultIssueStale = 30 * 24 * time.Hour + DefaultWorkflowStuck = 6 * time.Hour + DefaultWorkflowDormant = 30 * 24 * time.Hour + DefaultMaxItems = 100 + DefaultConcurrency = 2 + MinRefresh = time.Minute + dataStaleGrace = 30 * time.Minute + defaultDataStaleFactor = 2 +) + +type Config struct { + // ManualRefresh enables POST /refresh and the rescan button; it defaults to on. + ManualRefresh *bool `yaml:"manual_refresh"` + Listen string `yaml:"listen"` + Repos []string `yaml:"repos"` + GitHub GitHub `yaml:"github"` + Thresholds Thresholds `yaml:"thresholds"` + RefreshInterval time.Duration `yaml:"refresh_interval"` + MaxItems int `yaml:"max_items"` + Concurrency int `yaml:"concurrency"` +} + +type GitHub struct { + PrivateKeyFile string `yaml:"private_key_file"` + APIURL string `yaml:"api_url"` + // PrivateKeyPEM comes from the environment, never from the file. + PrivateKeyPEM string `yaml:"-"` + AppID int64 `yaml:"app_id"` + InstallationID int64 `yaml:"installation_id"` +} + +// Thresholds are durations; zero selects the default and a negative value disables the check. +type Thresholds struct { + PRWarnAfter time.Duration `yaml:"pr_warn_after"` + PRCritAfter time.Duration `yaml:"pr_crit_after"` + IssueStaleAfter time.Duration `yaml:"issue_stale_after"` + WorkflowStuck time.Duration `yaml:"workflow_stuck_after"` + // WorkflowDormant mutes failures of workflows that have not run for this long. + WorkflowDormant time.Duration `yaml:"workflow_dormant_after"` + DataStaleAfter time.Duration `yaml:"data_stale_after"` +} + +func Load(path string) (*Config, error) { + raw, err := os.ReadFile(path) // #nosec G304 -- operator-supplied -config flag + if err != nil { + return nil, fmt.Errorf("read config: %w", err) + } + var c Config + dec := yaml.NewDecoder(strings.NewReader(string(raw))) + dec.KnownFields(true) + if err := dec.Decode(&c); err != nil { + return nil, fmt.Errorf("parse config: %w", err) + } + return &c, c.finalize(os.Getenv, os.ReadFile) +} + +func (c *Config) finalize(getenv func(string) string, readFile func(string) ([]byte, error)) error { + if c.ManualRefresh == nil { + on := true + c.ManualRefresh = &on + } + if c.Listen == "" { + c.Listen = DefaultListen + } + if c.RefreshInterval == 0 { + c.RefreshInterval = DefaultRefresh + } + if c.RefreshInterval < MinRefresh { + return fmt.Errorf("refresh_interval %s is below the %s minimum", c.RefreshInterval, MinRefresh) + } + if c.MaxItems <= 0 { + c.MaxItems = DefaultMaxItems + } + if c.Concurrency <= 0 { + c.Concurrency = DefaultConcurrency + } + if c.GitHub.APIURL == "" { + c.GitHub.APIURL = DefaultAPIURL + } + c.GitHub.APIURL = strings.TrimRight(c.GitHub.APIURL, "/") + + t := &c.Thresholds + setDefault(&t.PRWarnAfter, DefaultPRWarnAfter) + setDefault(&t.PRCritAfter, DefaultPRCritAfter) + setDefault(&t.IssueStaleAfter, DefaultIssueStale) + setDefault(&t.WorkflowStuck, DefaultWorkflowStuck) + setDefault(&t.WorkflowDormant, DefaultWorkflowDormant) + setDefault(&t.DataStaleAfter, defaultDataStaleFactor*c.RefreshInterval+dataStaleGrace) + if t.PRWarnAfter > 0 && t.PRCritAfter > 0 && t.PRCritAfter < t.PRWarnAfter { + return errors.New("thresholds.pr_crit_after must not be below pr_warn_after") + } + + if c.GitHub.AppID <= 0 { + return errors.New("github.app_id is required") + } + c.GitHub.PrivateKeyPEM = getenv(EnvPrivateKey) + if c.GitHub.PrivateKeyPEM == "" { + if c.GitHub.PrivateKeyFile == "" { + return fmt.Errorf("github.private_key_file or $%s is required", EnvPrivateKey) + } + pem, err := readFile(c.GitHub.PrivateKeyFile) + if err != nil { + return fmt.Errorf("read private key: %w", err) + } + c.GitHub.PrivateKeyPEM = string(pem) + } + + repos, err := normalizeRepos(c.Repos) + if err != nil { + return err + } + c.Repos = repos + return nil +} + +func setDefault(d *time.Duration, def time.Duration) { + if *d == 0 { + *d = def + } +} + +func normalizeRepos(in []string) ([]string, error) { + if len(in) == 0 { + return nil, errors.New("repos must list at least one owner/name") + } + seen := make(map[string]bool, len(in)) + out := make([]string, 0, len(in)) + for _, r := range in { + r = strings.TrimSpace(r) + owner, name, ok := strings.Cut(r, "/") + if !ok || owner == "" || name == "" || strings.Contains(name, "/") { + return nil, fmt.Errorf("invalid repo %q: want owner/name", r) + } + key := strings.ToLower(r) + if seen[key] { + continue + } + seen[key] = true + out = append(out, r) + } + return out, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..b5e493f --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,128 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" +) + +func noFile(string) ([]byte, error) { return nil, errors.New("no file") } +func keyFile(string) ([]byte, error) { return []byte("PEM"), nil } +func env(m map[string]string) func(string) string { return func(k string) string { return m[k] } } + +func valid() *Config { + return &Config{GitHub: GitHub{AppID: 1, PrivateKeyFile: "k.pem"}, Repos: []string{"a/b"}} +} + +func TestFinalizeDefaults(t *testing.T) { + c := valid() + if err := c.finalize(env(nil), keyFile); err != nil { + t.Fatal(err) + } + if c.RefreshInterval != 6*time.Hour || c.Listen != ":8080" || c.Concurrency != 2 || c.MaxItems != 100 { + t.Fatalf("defaults wrong: %+v", c) + } + if c.ManualRefresh == nil || !*c.ManualRefresh { + t.Fatal("manual refresh must default to on") + } + if c.GitHub.APIURL != DefaultAPIURL || c.GitHub.PrivateKeyPEM != "PEM" { + t.Fatalf("github defaults wrong: %+v", c.GitHub) + } + if want := 12*time.Hour + 30*time.Minute; c.Thresholds.DataStaleAfter != want { + t.Fatalf("data stale = %s, want %s", c.Thresholds.DataStaleAfter, want) + } +} + +func TestFinalizeEnvKeyWins(t *testing.T) { + c := valid() + c.GitHub.PrivateKeyFile = "" + if err := c.finalize(env(map[string]string{EnvPrivateKey: "ENVPEM"}), noFile); err != nil { + t.Fatal(err) + } + if c.GitHub.PrivateKeyPEM != "ENVPEM" { + t.Fatalf("got %q", c.GitHub.PrivateKeyPEM) + } +} + +func TestFinalizeErrors(t *testing.T) { + tests := map[string]func(*Config){ + "no app id": func(c *Config) { c.GitHub.AppID = 0 }, + "no key": func(c *Config) { c.GitHub.PrivateKeyFile = "" }, + "unreadable key": func(c *Config) { c.GitHub.PrivateKeyFile = "x" }, + "no repos": func(c *Config) { c.Repos = nil }, + "bad repo": func(c *Config) { c.Repos = []string{"justname"} }, + "bad repo slashes": func(c *Config) { c.Repos = []string{"a/b/c"} }, + "refresh too small": func(c *Config) { c.RefreshInterval = time.Second }, + "crit below warn": func(c *Config) { + c.Thresholds.PRWarnAfter, c.Thresholds.PRCritAfter = 10*time.Hour, time.Hour + }, + } + for name, mutate := range tests { + t.Run(name, func(t *testing.T) { + c := valid() + mutate(c) + if err := c.finalize(env(nil), noFile); err == nil { + t.Fatal("expected error") + } + }) + } +} + +func TestNormalizeReposDedupes(t *testing.T) { + got, err := normalizeRepos([]string{"A/b", " a/B ", "c/d"}) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0] != "A/b" || got[1] != "c/d" { + t.Fatalf("got %v", got) + } +} + +func TestNegativeThresholdDisables(t *testing.T) { + c := valid() + c.Thresholds.PRCritAfter = -1 + if err := c.finalize(env(nil), keyFile); err != nil { + t.Fatal(err) + } + if c.Thresholds.PRCritAfter != -1 { + t.Fatalf("negative must be kept, got %s", c.Thresholds.PRCritAfter) + } +} + +func TestLoadRejectsUnknownKeysAndReadsFile(t *testing.T) { + dir := t.TempDir() + write := func(name, body string) string { + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + return p + } + key := write("k.pem", "PEM") + good := write("ok.yaml", "refresh_interval: 2h\ngithub:\n app_id: 5\n private_key_file: "+key+"\nrepos: [a/b]\n") + c, err := Load(good) + if err != nil || c.RefreshInterval != 2*time.Hour || c.GitHub.PrivateKeyPEM != "PEM" { + t.Fatalf("c=%+v err=%v", c, err) + } + typo := write("typo.yaml", "refresh_intervall: 2h\ngithub:\n app_id: 5\n private_key_file: "+key+"\nrepos: [a/b]\n") + if _, err := Load(typo); err == nil { + t.Fatal("unknown key must be rejected") + } + if _, err := Load(filepath.Join(dir, "missing.yaml")); err == nil { + t.Fatal("missing file must error") + } +} + +func TestManualRefreshCanBeDisabled(t *testing.T) { + off := false + c := valid() + c.ManualRefresh = &off + if err := c.finalize(env(nil), keyFile); err != nil { + t.Fatal(err) + } + if *c.ManualRefresh { + t.Fatal("explicit false must stay off") + } +} diff --git a/internal/gh/auth.go b/internal/gh/auth.go new file mode 100644 index 0000000..452feb7 --- /dev/null +++ b/internal/gh/auth.go @@ -0,0 +1,181 @@ +package gh + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" +) + +const ( + jwtLifetime = 9 * time.Minute // GitHub caps app JWTs at 10 minutes + jwtClockSkew = 60 * time.Second + tokenSafety = 5 * time.Minute + maxAuthBody = 1 << 20 + installLookup = "/repos/%s/installation" +) + +// AppAuth mints GitHub App installation tokens and caches them until shortly before expiry. +type AppAuth struct { + key *rsa.PrivateKey + hc *http.Client + now func() time.Time + tokens map[int64]cachedToken + repoInstalls map[string]int64 + base string + appID int64 + fixedInstall int64 + mu sync.Mutex +} + +type cachedToken struct { + expires time.Time + value string +} + +func NewAppAuth(appID int64, keyPEM, baseURL string, installationID int64, hc *http.Client) (*AppAuth, error) { + key, err := ParsePrivateKey([]byte(keyPEM)) + if err != nil { + return nil, err + } + return &AppAuth{ + appID: appID, + key: key, + base: strings.TrimRight(baseURL, "/"), + hc: hc, + fixedInstall: installationID, + now: time.Now, + tokens: map[int64]cachedToken{}, + repoInstalls: map[string]int64{}, + }, nil +} + +// ParsePrivateKey accepts PKCS#1 and PKCS#8 PEM RSA keys. +func ParsePrivateKey(data []byte) (*rsa.PrivateKey, error) { + block, _ := pem.Decode(data) + if block == nil { + return nil, errors.New("private key: no PEM block found") + } + if k, err := x509.ParsePKCS1PrivateKey(block.Bytes); err == nil { + return k, nil + } + k, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("private key: unsupported format: %w", err) + } + rk, ok := k.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("private key: not an RSA key") + } + return rk, nil +} + +func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +func (a *AppAuth) appJWT() (string, error) { + now := a.now() + header, _ := json.Marshal(map[string]string{"alg": "RS256", "typ": "JWT"}) + claims, _ := json.Marshal(map[string]any{ + "iat": now.Add(-jwtClockSkew).Unix(), + "exp": now.Add(jwtLifetime).Unix(), + "iss": fmt.Sprint(a.appID), + }) + signing := b64(header) + "." + b64(claims) + sum := sha256.Sum256([]byte(signing)) + sig, err := rsa.SignPKCS1v15(rand.Reader, a.key, crypto.SHA256, sum[:]) + if err != nil { + return "", fmt.Errorf("sign app jwt: %w", err) + } + return signing + "." + b64(sig), nil +} + +// Token returns an installation token valid for repo ("owner/name"). +func (a *AppAuth) Token(ctx context.Context, repo string) (string, error) { + id, err := a.installationID(ctx, repo) + if err != nil { + return "", err + } + a.mu.Lock() + t, ok := a.tokens[id] + a.mu.Unlock() + if ok && a.now().Before(t.expires.Add(-tokenSafety)) { + return t.value, nil + } + return a.refresh(ctx, id) +} + +func (a *AppAuth) installationID(ctx context.Context, repo string) (int64, error) { + if a.fixedInstall != 0 { + return a.fixedInstall, nil + } + a.mu.Lock() + id, ok := a.repoInstalls[repo] + a.mu.Unlock() + if ok { + return id, nil + } + var out struct { + ID int64 `json:"id"` + } + if err := a.appRequest(ctx, http.MethodGet, fmt.Sprintf(installLookup, repo), &out); err != nil { + return 0, fmt.Errorf("find installation for %s: %w", repo, err) + } + a.mu.Lock() + a.repoInstalls[repo] = out.ID + a.mu.Unlock() + return out.ID, nil +} + +func (a *AppAuth) refresh(ctx context.Context, id int64) (string, error) { + var out struct { + ExpiresAt time.Time `json:"expires_at"` + Token string `json:"token"` + } + if err := a.appRequest(ctx, http.MethodPost, fmt.Sprintf("/app/installations/%d/access_tokens", id), &out); err != nil { + return "", fmt.Errorf("create installation token: %w", err) + } + a.mu.Lock() + a.tokens[id] = cachedToken{value: out.Token, expires: out.ExpiresAt} + a.mu.Unlock() + return out.Token, nil +} + +func (a *AppAuth) appRequest(ctx context.Context, method, path string, out any) error { + jwt, err := a.appJWT() + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, method, a.base+path, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+jwt) + req.Header.Set("Accept", acceptHeader) + req.Header.Set("X-GitHub-Api-Version", apiVersion) + req.Header.Set("User-Agent", userAgent) + resp, err := a.hc.Do(req) + if err != nil { + return err + } + defer func() { _ = resp.Body.Close() }() + body, err := io.ReadAll(io.LimitReader(resp.Body, maxAuthBody)) + if err != nil { + return err + } + if resp.StatusCode/100 != 2 { + return &APIError{Status: resp.StatusCode, Message: apiMessage(body)} + } + return json.Unmarshal(body, out) +} diff --git a/internal/gh/client.go b/internal/gh/client.go new file mode 100644 index 0000000..d7e9b27 --- /dev/null +++ b/internal/gh/client.go @@ -0,0 +1,291 @@ +// Package gh is a small read-only GitHub REST client authenticated as a GitHub App. +package gh + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/lukaszraczylo/ghtracker/internal/model" +) + +const ( + acceptHeader = "application/vnd.github+json" + apiVersion = "2022-11-28" + userAgent = "ghtracker" + perPage = 100 + maxBody = 16 << 20 + maxRetries = 2 + // rateReserve is the request budget left untouched so the app never exhausts its hourly quota. + rateReserve = 100 + // secondaryBackoff applies when GitHub signals abuse limits without a Retry-After. + secondaryBackoff = time.Minute +) + +// APIError is a non-2xx GitHub response. +type APIError struct { + Message string + Status int +} + +func (e *APIError) Error() string { + return fmt.Sprintf("github: HTTP %d: %s", e.Status, e.Message) +} + +func apiMessage(body []byte) string { + var m struct { + Message string `json:"message"` + } + if json.Unmarshal(body, &m) == nil && m.Message != "" { + return m.Message + } + s := strings.TrimSpace(string(body)) + if len(s) > 200 { + s = s[:200] + } + return s +} + +func isNotFound(err error) bool { + var ae *APIError + return errors.As(err, &ae) && ae.Status == http.StatusNotFound +} + +// TokenSource yields a bearer token for requests against repo. +type TokenSource interface { + Token(ctx context.Context, repo string) (string, error) +} + +type Client struct { + rateReset time.Time + blockedUntil time.Time + auth TokenSource + hc *http.Client + // sleep is replaced in tests. + sleep func(context.Context, time.Duration) error + now func() time.Time + cache map[string]cachedResponse + base string + rateRemaining atomic.Int64 + maxItems int + mu sync.Mutex +} + +// cachedResponse backs conditional requests: a 304 reply does not count against the primary rate limit. +type cachedResponse struct { + etag string + link string + body []byte +} + +func NewClient(baseURL string, auth TokenSource, hc *http.Client, maxItems int) *Client { + c := &Client{base: strings.TrimRight(baseURL, "/"), hc: hc, auth: auth, maxItems: maxItems, sleep: sleepCtx, now: time.Now, cache: map[string]cachedResponse{}} + c.rateRemaining.Store(-1) + return c +} + +// RateLimitRemaining returns the last reported remaining request budget. +func (c *Client) RateLimitRemaining() (int, bool) { + v := c.rateRemaining.Load() + return int(v), v >= 0 +} + +func sleepCtx(ctx context.Context, d time.Duration) error { + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } +} + +// do performs one authenticated GET, retrying transient failures, and returns the body and Link header. +func (c *Client) do(ctx context.Context, repo, target string) ([]byte, string, error) { + var lastErr error + for attempt := 0; attempt <= maxRetries; attempt++ { + if attempt > 0 { + if err := c.sleep(ctx, time.Duration(attempt)*time.Second); err != nil { + return nil, "", err + } + } + body, link, retry, err := c.once(ctx, repo, target) + if err == nil { + return body, link, nil + } + lastErr = err + if !retry { + break + } + } + return nil, "", lastErr +} + +func (c *Client) once(ctx context.Context, repo, target string) (body []byte, link string, retry bool, err error) { + if gerr := c.guard(); gerr != nil { + return nil, "", false, gerr + } + tok, err := c.auth.Token(ctx, repo) + if err != nil { + return nil, "", false, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return nil, "", false, err + } + req.Header.Set("Authorization", "Bearer "+tok) + req.Header.Set("Accept", acceptHeader) + req.Header.Set("X-GitHub-Api-Version", apiVersion) + req.Header.Set("User-Agent", userAgent) + c.mu.Lock() + cached, haveCache := c.cache[target] + c.mu.Unlock() + if haveCache { + req.Header.Set("If-None-Match", cached.etag) + } + resp, err := c.hc.Do(req) + if err != nil { + return nil, "", ctx.Err() == nil, err + } + defer func() { _ = resp.Body.Close() }() + c.noteRate(resp.Header) + if resp.StatusCode == http.StatusNotModified && haveCache { + return cached.body, cached.link, false, nil + } + data, err := io.ReadAll(io.LimitReader(resp.Body, maxBody)) + if err != nil { + return nil, "", true, err + } + if resp.StatusCode/100 != 2 { + ae := &APIError{Status: resp.StatusCode, Message: apiMessage(data)} + if c.noteLimited(resp, ae) { + return nil, "", false, fmt.Errorf("%w: %v", model.ErrRateLimited, ae) + } + return nil, "", resp.StatusCode >= 500, ae + } + link = resp.Header.Get("Link") + if etag := resp.Header.Get("ETag"); etag != "" { + c.mu.Lock() + c.cache[target] = cachedResponse{etag: etag, body: data, link: link} + c.mu.Unlock() + } + return data, link, false, nil +} + +// guard refuses requests while a limit block is active or the remaining budget is at the reserve. +func (c *Client) guard() error { + c.mu.Lock() + defer c.mu.Unlock() + now := c.now() + if now.Before(c.blockedUntil) { + return fmt.Errorf("%w: blocked until %s", model.ErrRateLimited, c.blockedUntil.Format(time.RFC3339)) + } + if rem := c.rateRemaining.Load(); rem >= 0 && rem < rateReserve && now.Before(c.rateReset) { + return fmt.Errorf("%w: %d requests left, resets %s", model.ErrRateLimited, rem, c.rateReset.Format(time.RFC3339)) + } + return nil +} + +func (c *Client) noteRate(h http.Header) { + if v, err := strconv.ParseInt(h.Get("X-RateLimit-Remaining"), 10, 64); err == nil { + c.rateRemaining.Store(v) + } + if v, err := strconv.ParseInt(h.Get("X-RateLimit-Reset"), 10, 64); err == nil { + c.mu.Lock() + c.rateReset = time.Unix(v, 0) + c.mu.Unlock() + } +} + +// noteLimited records a block when the response is a primary or secondary rate-limit rejection. +func (c *Client) noteLimited(resp *http.Response, ae *APIError) bool { + if resp.StatusCode != http.StatusForbidden && resp.StatusCode != http.StatusTooManyRequests { + return false + } + now := c.now() + var until time.Time + switch secs, err := strconv.Atoi(resp.Header.Get("Retry-After")); { + case err == nil: + until = now.Add(time.Duration(secs) * time.Second) + case resp.Header.Get("X-RateLimit-Remaining") == "0": + c.mu.Lock() + until = c.rateReset + c.mu.Unlock() + case strings.Contains(strings.ToLower(ae.Message), "rate limit"): + until = now.Add(secondaryBackoff) + default: + return false + } + c.mu.Lock() + c.blockedUntil = until + c.mu.Unlock() + return true +} + +func (c *Client) url(path string, q url.Values) string { + u := c.base + path + if len(q) > 0 { + u += "?" + q.Encode() + } + return u +} + +func (c *Client) getJSON(ctx context.Context, repo, path string, q url.Values, out any) error { + body, _, err := c.do(ctx, repo, c.url(path, q)) + if err != nil { + return err + } + return json.Unmarshal(body, out) +} + +// listAll follows Link pagination, collecting at most limit items. +func listAll[T any](ctx context.Context, c *Client, repo, path string, q url.Values, limit int, unwrap func([]byte) ([]T, error)) ([]T, error) { + if q == nil { + q = url.Values{} + } + q.Set("per_page", strconv.Itoa(perPage)) + next := c.url(path, q) + var all []T + for next != "" && len(all) < limit { + body, link, err := c.do(ctx, repo, next) + if err != nil { + return all, err + } + page, err := unwrap(body) + if err != nil { + return all, err + } + all = append(all, page...) + next = nextLink(link) + } + if len(all) > limit { + all = all[:limit] + } + return all, nil +} + +func unwrapArray[T any](b []byte) ([]T, error) { + var out []T + return out, json.Unmarshal(b, &out) +} + +// nextLink extracts rel="next" from a Link header. +func nextLink(h string) string { + for part := range strings.SplitSeq(h, ",") { + target, params, found := strings.Cut(part, ";") + if found && strings.TrimSpace(params) == `rel="next"` { + return strings.Trim(strings.TrimSpace(target), "<>") + } + } + return "" +} diff --git a/internal/gh/fetch.go b/internal/gh/fetch.go new file mode 100644 index 0000000..90195dd --- /dev/null +++ b/internal/gh/fetch.go @@ -0,0 +1,345 @@ +package gh + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/url" + "slices" + "time" + + "github.com/lukaszraczylo/ghtracker/internal/model" +) + +const ( + maxFailingNames = 5 + runsPerWorkflow = 5 + maxWorkflows = 100 + eventPullRequest = "pull_request" + eventPullReqTgt = "pull_request_target" + statusStateFail = "failure" + statusStateError = "error" + statusStatePend = "pending" +) + +// backgroundEvents are triggers that run without a person pushing code. +var backgroundEvents = []string{"schedule", "dynamic", "workflow_dispatch", "workflow_run", "repository_dispatch"} + +type userRef struct { + Login string `json:"login"` +} + +type labelRef struct { + Name string `json:"name"` +} + +func labelNames(ls []labelRef) []string { + out := make([]string, 0, len(ls)) + for _, l := range ls { + out = append(out, l.Name) + } + return out +} + +// FetchRepo gathers the dashboard data for one "owner/name" repository. +// A failure of the repository lookup fails the call; other sections degrade into Repo.Warnings. +func (c *Client) FetchRepo(ctx context.Context, full string) (model.Repo, error) { + var info struct { + HTMLURL string `json:"html_url"` + Description string `json:"description"` + DefaultBranch string `json:"default_branch"` + Archived bool `json:"archived"` + } + if err := c.getJSON(ctx, full, "/repos/"+full, nil, &info); err != nil { + return model.Repo{}, fmt.Errorf("repo %s: %w", full, err) + } + r := model.Repo{ + FullName: full, + URL: info.HTMLURL, + ReleasesURL: info.HTMLURL + "/releases", + Description: info.Description, + DefaultBranch: info.DefaultBranch, + Archived: info.Archived, + Up: true, + } + var limited error + warn := func(section string, err error) { + if errors.Is(err, model.ErrRateLimited) { + limited = err + } + r.Warnings = append(r.Warnings, fmt.Sprintf("%s: %v", section, err)) + } + + var err error + if r.Issues, err = c.openIssues(ctx, full); err != nil { + warn("issues", err) + } + if r.PRs, err = c.openPRs(ctx, full); err != nil { + warn("pull requests", err) + } + if cerr := c.attachChecks(ctx, full, r.PRs); cerr != nil { + warn("pr checks", cerr) + } + if r.Workflows, err = c.workflows(ctx, full, r.DefaultBranch); err != nil { + warn("workflows", err) + } + if r.Release, err = c.latestRelease(ctx, full, r.URL); err != nil { + warn("releases", err) + } + if limited != nil { + return model.Repo{}, limited + } + return r, nil +} + +func (c *Client) openIssues(ctx context.Context, full string) ([]model.Issue, error) { + type raw struct { + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Title string `json:"title"` + HTMLURL string `json:"html_url"` + User userRef `json:"user"` + Labels []labelRef `json:"labels"` + PullRequest json.RawMessage `json:"pull_request"` + Number int `json:"number"` + Comments int `json:"comments"` + } + // The issues endpoint also returns pull requests; they are dropped here and fetched separately. + q := url.Values{"state": {"open"}, "sort": {"created"}, "direction": {"asc"}} + items, err := listAll(ctx, c, full, "/repos/"+full+"/issues", q, c.maxItems, unwrapArray[raw]) + out := make([]model.Issue, 0, len(items)) + for _, i := range items { + if len(i.PullRequest) > 0 { + continue + } + out = append(out, model.Issue{ + Number: i.Number, Title: i.Title, URL: i.HTMLURL, Author: i.User.Login, + Labels: labelNames(i.Labels), Comments: i.Comments, CreatedAt: i.CreatedAt, UpdatedAt: i.UpdatedAt, + }) + } + return out, err +} + +func (c *Client) openPRs(ctx context.Context, full string) ([]model.PullRequest, error) { + type raw struct { + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Title string `json:"title"` + HTMLURL string `json:"html_url"` + User userRef `json:"user"` + Head struct { + SHA string `json:"sha"` + } `json:"head"` + Labels []labelRef `json:"labels"` + Number int `json:"number"` + Draft bool `json:"draft"` + } + q := url.Values{"state": {"open"}, "sort": {"created"}, "direction": {"asc"}} + items, err := listAll(ctx, c, full, "/repos/"+full+"/pulls", q, c.maxItems, unwrapArray[raw]) + out := make([]model.PullRequest, 0, len(items)) + for _, p := range items { + out = append(out, model.PullRequest{ + Number: p.Number, Title: p.Title, URL: p.HTMLURL, Author: p.User.Login, Draft: p.Draft, + Labels: labelNames(p.Labels), HeadSHA: p.Head.SHA, CreatedAt: p.CreatedAt, UpdatedAt: p.UpdatedAt, + Checks: model.CheckSummary{State: model.ChecksUnknown}, + }) + } + return out, err +} + +// attachChecks fills each PR's check summary; it reports the first failure but still tries every PR. +func (c *Client) attachChecks(ctx context.Context, full string, prs []model.PullRequest) error { + var first error + for i := range prs { + s, err := c.checkSummary(ctx, full, prs[i].HeadSHA) + if err != nil { + if first == nil { + first = fmt.Errorf("#%d: %w", prs[i].Number, err) + } + continue + } + prs[i].Checks = s + } + return first +} + +func (c *Client) checkSummary(ctx context.Context, full, sha string) (model.CheckSummary, error) { + var runs struct { + CheckRuns []struct { + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + } `json:"check_runs"` + } + base := "/repos/" + full + "/commits/" + sha + if err := c.getJSON(ctx, full, base+"/check-runs", url.Values{"per_page": {"100"}}, &runs); err != nil { + return model.CheckSummary{}, err + } + var status struct { + Statuses []struct { + Context string `json:"context"` + State string `json:"state"` + } `json:"statuses"` + TotalCount int `json:"total_count"` + } + if err := c.getJSON(ctx, full, base+"/status", nil, &status); err != nil { + return model.CheckSummary{}, err + } + + var s model.CheckSummary + for _, cr := range runs.CheckRuns { + s.Total++ + switch { + case cr.Status != "completed": + s.Pending++ + case (model.Run{Status: cr.Status, Conclusion: cr.Conclusion}).Failed(): + s.Failed++ + s.Failing = append(s.Failing, cr.Name) + } + } + for _, st := range status.Statuses { + s.Total++ + switch st.State { + case statusStateFail, statusStateError: + s.Failed++ + s.Failing = append(s.Failing, st.Context) + case statusStatePend: + s.Pending++ + } + } + if len(s.Failing) > maxFailingNames { + s.Failing = s.Failing[:maxFailingNames] + } + switch { + case s.Total == 0: + s.State = model.ChecksNone + case s.Failed > 0: + s.State = model.ChecksFail + case s.Pending > 0: + s.State = model.ChecksPending + default: + s.State = model.ChecksPass + } + return s, nil +} + +type rawRun struct { + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + HTMLURL string `json:"html_url"` + Event string `json:"event"` + HeadBranch string `json:"head_branch"` + Status string `json:"status"` + Conclusion string `json:"conclusion"` + ID int64 `json:"id"` +} + +func (r rawRun) model() model.Run { + return model.Run{ + ID: r.ID, URL: r.HTMLURL, Event: r.Event, Branch: r.HeadBranch, Status: r.Status, + Conclusion: r.Conclusion, CreatedAt: r.CreatedAt, UpdatedAt: r.UpdatedAt, + } +} + +// workflows returns, per active workflow, its newest default-branch run and newest completed one. +// Pull request runs are excluded because PR checks cover them. +func (c *Client) workflows(ctx context.Context, full, branch string) ([]model.Workflow, error) { + var list struct { + Workflows []struct { + Name string `json:"name"` + HTMLURL string `json:"html_url"` + State string `json:"state"` + ID int64 `json:"id"` + } `json:"workflows"` + } + if err := c.getJSON(ctx, full, "/repos/"+full+"/actions/workflows", url.Values{"per_page": {"100"}}, &list); err != nil { + return nil, err + } + var out []model.Workflow + var first error + for _, w := range list.Workflows { + if w.State != "active" || len(out) >= maxWorkflows { + continue + } + var runs struct { + WorkflowRuns []rawRun `json:"workflow_runs"` + } + q := url.Values{ + "branch": {branch}, + "exclude_pull_requests": {"true"}, + "per_page": {fmt.Sprint(runsPerWorkflow)}, + } + path := fmt.Sprintf("/repos/%s/actions/workflows/%d/runs", full, w.ID) + if err := c.getJSON(ctx, full, path, q, &runs); err != nil { + if first == nil { + first = fmt.Errorf("%s: %w", w.Name, err) + } + continue + } + if wf, ok := buildWorkflow(w.Name, w.HTMLURL, runs.WorkflowRuns); ok { + out = append(out, wf) + } + } + return out, first +} + +func buildWorkflow(name, htmlURL string, runs []rawRun) (model.Workflow, bool) { + var kept []model.Run + for _, r := range runs { + if r.Event == eventPullRequest || r.Event == eventPullReqTgt { + continue + } + kept = append(kept, r.model()) + } + if len(kept) == 0 { + return model.Workflow{}, false + } + slices.SortStableFunc(kept, func(a, b model.Run) int { return b.CreatedAt.Compare(a.CreatedAt) }) + wf := model.Workflow{ + Name: name, + URL: htmlURL, + Latest: kept[0], + Background: slices.Contains(backgroundEvents, kept[0].Event), + } + for i := range kept { + if !kept[i].Running() { + done := kept[i] + wf.LastDone = &done + break + } + } + return wf, true +} + +func (c *Client) latestRelease(ctx context.Context, full, repoURL string) (*model.Release, error) { + var rel struct { + PublishedAt time.Time `json:"published_at"` + TagName string `json:"tag_name"` + Name string `json:"name"` + HTMLURL string `json:"html_url"` + Prerelease bool `json:"prerelease"` + } + err := c.getJSON(ctx, full, "/repos/"+full+"/releases/latest", nil, &rel) + if err == nil { + return &model.Release{Tag: rel.TagName, Name: rel.Name, URL: rel.HTMLURL, PublishedAt: rel.PublishedAt, Prerelease: rel.Prerelease}, nil + } + if !isNotFound(err) { + return nil, err + } + // No published release: fall back to the newest tag so unreleased-but-tagged projects show a version. + var tags []struct { + Name string `json:"name"` + } + if err := c.getJSON(ctx, full, "/repos/"+full+"/tags", url.Values{"per_page": {"1"}}, &tags); err != nil { + return nil, err + } + if len(tags) == 0 { + return nil, nil + } + return &model.Release{ + Tag: tags[0].Name, + URL: repoURL + "/releases/tag/" + url.PathEscape(tags[0].Name), + FromTag: true, + }, nil +} diff --git a/internal/gh/gh_test.go b/internal/gh/gh_test.go new file mode 100644 index 0000000..56ae426 --- /dev/null +++ b/internal/gh/gh_test.go @@ -0,0 +1,461 @@ +package gh + +import ( + "context" + "crypto" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/lukaszraczylo/ghtracker/internal/model" +) + +func testKey(t *testing.T) (*rsa.PrivateKey, string) { + t.Helper() + k, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + p := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)}) + return k, string(p) +} + +// fakeGitHub serves canned JSON by path and counts hits per path. +type fakeGitHub struct { + srv *httptest.Server + routes map[string]func(w http.ResponseWriter, r *http.Request) + hits map[string]*atomic.Int64 + appKeyPub *rsa.PublicKey + t *testing.T + installs atomic.Int64 + tokens atomic.Int64 +} + +func newFake(t *testing.T) *fakeGitHub { + t.Helper() + f := &fakeGitHub{t: t, routes: map[string]func(http.ResponseWriter, *http.Request){}, hits: map[string]*atomic.Int64{}} + f.srv = httptest.NewServer(http.HandlerFunc(f.serve)) + t.Cleanup(f.srv.Close) + return f +} + +func (f *fakeGitHub) serve(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/installation"): + f.installs.Add(1) + f.checkJWT(r) + _, _ = w.Write([]byte(`{"id":42}`)) + return + case strings.HasPrefix(r.URL.Path, "/app/installations/"): + f.tokens.Add(1) + f.checkJWT(r) + exp := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + _, _ = fmt.Fprintf(w, `{"token":"inst-token","expires_at":%q}`, exp) + return + } + if r.Header.Get("Authorization") != "Bearer inst-token" { + http.Error(w, `{"message":"Bad credentials"}`, http.StatusUnauthorized) + return + } + h, ok := f.routes[r.URL.Path] + if !ok { + http.Error(w, `{"message":"Not Found"}`, http.StatusNotFound) + return + } + c := f.hits[r.URL.Path] + if c == nil { + c = &atomic.Int64{} + f.hits[r.URL.Path] = c + } + c.Add(1) + h(w, r) +} + +func (f *fakeGitHub) checkJWT(r *http.Request) { + if f.appKeyPub == nil { + return + } + tok := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + parts := strings.Split(tok, ".") + if len(parts) != 3 { + f.t.Errorf("jwt has %d parts", len(parts)) + return + } + sig, _ := base64.RawURLEncoding.DecodeString(parts[2]) + sum := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + if err := rsa.VerifyPKCS1v15(f.appKeyPub, crypto.SHA256, sum[:], sig); err != nil { + f.t.Errorf("jwt signature invalid: %v", err) + } +} + +func (f *fakeGitHub) json(path, body string) { + f.routes[path] = func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) } +} + +func (f *fakeGitHub) client(t *testing.T, maxItems int) *Client { + t.Helper() + key, pemStr := testKey(t) + f.appKeyPub = &key.PublicKey + auth, err := NewAppAuth(7, pemStr, f.srv.URL, 0, f.srv.Client()) + if err != nil { + t.Fatal(err) + } + c := NewClient(f.srv.URL, auth, f.srv.Client(), maxItems) + c.sleep = func(context.Context, time.Duration) error { return nil } + return c +} + +func TestJWTClaimsAndKeyFormats(t *testing.T) { + key, pemStr := testKey(t) + a, err := NewAppAuth(99, pemStr, "http://x", 0, http.DefaultClient) + if err != nil { + t.Fatal(err) + } + fixed := time.Unix(1_700_000_000, 0) + a.now = func() time.Time { return fixed } + jwt, err := a.appJWT() + if err != nil { + t.Fatal(err) + } + parts := strings.Split(jwt, ".") + claimsRaw, _ := base64.RawURLEncoding.DecodeString(parts[1]) + var claims map[string]any + if err := json.Unmarshal(claimsRaw, &claims); err != nil { + t.Fatal(err) + } + if claims["iss"] != "99" || int64(claims["iat"].(float64)) != fixed.Unix()-60 || int64(claims["exp"].(float64)) != fixed.Unix()+540 { + t.Fatalf("claims = %v", claims) + } + sig, _ := base64.RawURLEncoding.DecodeString(parts[2]) + sum := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + if err := rsa.VerifyPKCS1v15(&key.PublicKey, crypto.SHA256, sum[:], sig); err != nil { + t.Fatal(err) + } + + der, _ := x509.MarshalPKCS8PrivateKey(key) + if _, err := ParsePrivateKey(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der})); err != nil { + t.Fatalf("pkcs8: %v", err) + } + if _, err := ParsePrivateKey([]byte("garbage")); err == nil { + t.Fatal("garbage key must fail") + } +} + +func TestTokenCachedAndInstallationDiscoveredOnce(t *testing.T) { + f := newFake(t) + c := f.client(t, 10) + f.json("/repos/o/r", `{"html_url":"https://github.com/o/r","default_branch":"main"}`) + for range 3 { + if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); err != nil { + t.Fatal(err) + } + } + if f.installs.Load() != 1 || f.tokens.Load() != 1 { + t.Fatalf("installs=%d tokens=%d, want 1 each", f.installs.Load(), f.tokens.Load()) + } +} + +func TestPaginationStopsAtLimit(t *testing.T) { + f := newFake(t) + c := f.client(t, 3) + f.routes["/repos/o/r/issues"] = func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("page") == "2" { + _, _ = w.Write([]byte(`[{"number":3,"title":"c"},{"number":4,"title":"d"}]`)) + return + } + w.Header().Set("Link", fmt.Sprintf(`<%s/repos/o/r/issues?page=2>; rel="next", ; rel="last"`, f.srv.URL)) + _, _ = w.Write([]byte(`[{"number":1,"title":"a"},{"number":2,"title":"b"}]`)) + } + got, err := c.openIssues(context.Background(), "o/r") + if err != nil { + t.Fatal(err) + } + if len(got) != 3 { + t.Fatalf("got %d issues, want 3 (limit)", len(got)) + } +} + +func TestNextLink(t *testing.T) { + h := `; rel="next", ; rel="last"` + if got := nextLink(h); got != "https://api/x?page=2" { + t.Fatal(got) + } + if nextLink(`; rel="prev"`) != "" || nextLink("") != "" { + t.Fatal("no next link expected") + } +} + +func TestETagConditionalRequestServedFromCache(t *testing.T) { + f := newFake(t) + c := f.client(t, 10) + var conditional atomic.Int64 + f.routes["/repos/o/r"] = func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("If-None-Match") == `"v1"` { + conditional.Add(1) + w.WriteHeader(http.StatusNotModified) + return + } + w.Header().Set("ETag", `"v1"`) + _, _ = w.Write([]byte(`{"default_branch":"main"}`)) + } + for range 2 { + var out struct { + DefaultBranch string `json:"default_branch"` + } + if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &out); err != nil || out.DefaultBranch != "main" { + t.Fatalf("out=%+v err=%v", out, err) + } + } + if conditional.Load() != 1 { + t.Fatalf("conditional requests = %d, want 1", conditional.Load()) + } +} + +func TestRetriesServerErrorsThenSucceeds(t *testing.T) { + f := newFake(t) + c := f.client(t, 10) + var n atomic.Int64 + f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) { + if n.Add(1) < 3 { + http.Error(w, `{"message":"boom"}`, http.StatusBadGateway) + return + } + _, _ = w.Write([]byte(`{}`)) + } + if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); err != nil { + t.Fatal(err) + } + if n.Load() != 3 { + t.Fatalf("attempts = %d", n.Load()) + } +} + +func TestNoRetryOnClientError(t *testing.T) { + f := newFake(t) + c := f.client(t, 10) + err := c.getJSON(context.Background(), "o/r", "/missing", nil, &struct{}{}) + if !isNotFound(err) { + t.Fatalf("err = %v", err) + } +} + +func TestPrimaryRateLimitBlocksFurtherRequests(t *testing.T) { + f := newFake(t) + c := f.client(t, 10) + reset := time.Now().Add(30 * time.Minute) + f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-RateLimit-Remaining", "0") + w.Header().Set("X-RateLimit-Reset", fmt.Sprint(reset.Unix())) + http.Error(w, `{"message":"API rate limit exceeded"}`, http.StatusForbidden) + } + err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}) + if !errors.Is(err, model.ErrRateLimited) { + t.Fatalf("err = %v", err) + } + hits := f.hits["/repos/o/r"].Load() + err = c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}) + if !errors.Is(err, model.ErrRateLimited) || f.hits["/repos/o/r"].Load() != hits { + t.Fatalf("second call must be refused locally; err=%v hits=%d->%d", err, hits, f.hits["/repos/o/r"].Load()) + } +} + +func TestSecondaryRateLimitHonoursRetryAfter(t *testing.T) { + f := newFake(t) + c := f.client(t, 10) + now := time.Now() + c.now = func() time.Time { return now } + f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Retry-After", "120") + http.Error(w, `{"message":"secondary rate limit"}`, http.StatusTooManyRequests) + } + if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); !errors.Is(err, model.ErrRateLimited) { + t.Fatalf("err = %v", err) + } + c.now = func() time.Time { return now.Add(119 * time.Second) } + if err := c.guard(); !errors.Is(err, model.ErrRateLimited) { + t.Fatalf("still blocked at 119s, got %v", err) + } + c.now = func() time.Time { return now.Add(121 * time.Second) } + if err := c.guard(); err != nil { + t.Fatalf("unblocked at 121s, got %v", err) + } +} + +func TestLowBudgetStopsBeforeQuotaExhausted(t *testing.T) { + f := newFake(t) + c := f.client(t, 10) + f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-RateLimit-Remaining", "50") + w.Header().Set("X-RateLimit-Reset", fmt.Sprint(time.Now().Add(time.Hour).Unix())) + _, _ = w.Write([]byte(`{}`)) + } + if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); err != nil { + t.Fatal(err) + } + if err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}); !errors.Is(err, model.ErrRateLimited) { + t.Fatalf("budget below reserve must refuse, got %v", err) + } + if rem, ok := c.RateLimitRemaining(); !ok || rem != 50 { + t.Fatalf("remaining = %d %v", rem, ok) + } +} + +func TestPlain403IsNotTreatedAsRateLimit(t *testing.T) { + f := newFake(t) + c := f.client(t, 10) + f.routes["/repos/o/r"] = func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, `{"message":"Resource not accessible by integration"}`, http.StatusForbidden) + } + err := c.getJSON(context.Background(), "o/r", "/repos/o/r", nil, &struct{}{}) + var ae *APIError + if errors.Is(err, model.ErrRateLimited) || !errors.As(err, &ae) || ae.Status != 403 { + t.Fatalf("err = %v", err) + } + if err := c.guard(); err != nil { + t.Fatalf("plain 403 must not block: %v", err) + } +} + +func registerRepo(f *fakeGitHub) { + f.json("/repos/o/r", `{"html_url":"https://github.com/o/r","description":"d","default_branch":"main"}`) + f.json("/repos/o/r/issues", `[ + {"number":1,"title":"real issue","html_url":"u1","user":{"login":"a"},"labels":[{"name":"bug"}],"comments":2,"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-02T00:00:00Z"}, + {"number":2,"title":"a PR","html_url":"u2","pull_request":{"url":"x"}}]`) + f.json("/repos/o/r/pulls", `[{"number":2,"title":"a PR","html_url":"u2","user":{"login":"b"},"draft":false,"head":{"sha":"abc"},"created_at":"2026-01-03T00:00:00Z","updated_at":"2026-01-03T00:00:00Z"}]`) + f.json("/repos/o/r/commits/abc/check-runs", `{"check_runs":[{"name":"build","status":"completed","conclusion":"failure"},{"name":"lint","status":"completed","conclusion":"success"},{"name":"slow","status":"in_progress"}]}`) + f.json("/repos/o/r/commits/abc/status", `{"total_count":1,"statuses":[{"context":"ci/ext","state":"error"}]}`) + f.json("/repos/o/r/actions/workflows", `{"workflows":[ + {"id":1,"name":"CI","html_url":"w1","state":"active"}, + {"id":2,"name":"Nightly","html_url":"w2","state":"active"}, + {"id":3,"name":"Old","html_url":"w3","state":"disabled_manually"}, + {"id":4,"name":"Never ran","html_url":"w4","state":"active"}]}`) + f.json("/repos/o/r/actions/workflows/1/runs", `{"workflow_runs":[ + {"id":11,"html_url":"r11","event":"push","head_branch":"main","status":"in_progress","created_at":"2026-01-05T00:00:00Z"}, + {"id":10,"html_url":"r10","event":"push","head_branch":"main","status":"completed","conclusion":"success","created_at":"2026-01-04T00:00:00Z"}]}`) + f.json("/repos/o/r/actions/workflows/2/runs", `{"workflow_runs":[ + {"id":20,"html_url":"r20","event":"schedule","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-01-05T00:00:00Z"}]}`) + f.json("/repos/o/r/actions/workflows/4/runs", `{"workflow_runs":[]}`) + f.json("/repos/o/r/releases/latest", `{"tag_name":"v1.2.3","name":"x","html_url":"rel","published_at":"2026-01-01T00:00:00Z"}`) +} + +func TestFetchRepoEndToEnd(t *testing.T) { + f := newFake(t) + c := f.client(t, 50) + registerRepo(f) + r, err := c.FetchRepo(context.Background(), "o/r") + if err != nil { + t.Fatal(err) + } + if len(r.Warnings) != 0 { + t.Fatalf("warnings: %v", r.Warnings) + } + if len(r.Issues) != 1 || r.Issues[0].Number != 1 || r.Issues[0].Labels[0] != "bug" { + t.Fatalf("issues must exclude PRs: %+v", r.Issues) + } + if len(r.PRs) != 1 || r.PRs[0].Checks.State != model.ChecksFail || r.PRs[0].Checks.Failed != 2 || r.PRs[0].Checks.Pending != 1 { + t.Fatalf("pr checks: %+v", r.PRs) + } + if got := r.PRs[0].Checks.Failing; len(got) != 2 || got[0] != "build" || got[1] != "ci/ext" { + t.Fatalf("failing names: %v", got) + } + if len(r.Workflows) != 2 { + t.Fatalf("want CI and Nightly only, got %+v", r.Workflows) + } + ci, nightly := r.Workflows[0], r.Workflows[1] + if !ci.Latest.Running() || ci.LastDone == nil || ci.LastDone.ID != 10 || ci.Background { + t.Fatalf("ci: %+v", ci) + } + if !nightly.Background || nightly.LastDone == nil || !nightly.LastDone.Failed() { + t.Fatalf("nightly: %+v", nightly) + } + if r.Release == nil || r.Release.Tag != "v1.2.3" || r.Release.URL != "rel" || r.ReleasesURL != "https://github.com/o/r/releases" { + t.Fatalf("release: %+v %s", r.Release, r.ReleasesURL) + } +} + +func TestReleaseFallsBackToTag(t *testing.T) { + f := newFake(t) + c := f.client(t, 10) + f.json("/repos/o/r/tags", `[{"name":"v0.1.0"}]`) + rel, err := c.latestRelease(context.Background(), "o/r", "https://github.com/o/r") + if err != nil { + t.Fatal(err) + } + if !rel.FromTag || rel.Tag != "v0.1.0" || rel.URL != "https://github.com/o/r/releases/tag/v0.1.0" { + t.Fatalf("%+v", rel) + } +} + +func TestReleaseNoneAtAll(t *testing.T) { + f := newFake(t) + c := f.client(t, 10) + f.json("/repos/o/r/tags", `[]`) + rel, err := c.latestRelease(context.Background(), "o/r", "https://github.com/o/r") + if err != nil || rel != nil { + t.Fatalf("rel=%+v err=%v", rel, err) + } +} + +func TestSectionFailureDegradesToWarning(t *testing.T) { + f := newFake(t) + c := f.client(t, 10) + registerRepo(f) + f.routes["/repos/o/r/actions/workflows"] = func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, `{"message":"Resource not accessible by integration"}`, http.StatusForbidden) + } + r, err := c.FetchRepo(context.Background(), "o/r") + if err != nil { + t.Fatal(err) + } + if len(r.Warnings) != 1 || !strings.Contains(r.Warnings[0], "workflows") || len(r.Issues) != 1 { + t.Fatalf("warnings=%v issues=%d", r.Warnings, len(r.Issues)) + } +} + +func TestRateLimitInSectionFailsWholeRepo(t *testing.T) { + f := newFake(t) + c := f.client(t, 10) + registerRepo(f) + f.routes["/repos/o/r/pulls"] = func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Retry-After", "60") + http.Error(w, `{"message":"secondary rate limit"}`, http.StatusForbidden) + } + if _, err := c.FetchRepo(context.Background(), "o/r"); !errors.Is(err, model.ErrRateLimited) { + t.Fatalf("err = %v", err) + } +} + +func TestBuildWorkflowIgnoresPullRequestRuns(t *testing.T) { + runs := []rawRun{ + {ID: 2, Event: "pull_request", Status: "completed", Conclusion: "failure", CreatedAt: time.Unix(200, 0)}, + {ID: 1, Event: "push", Status: "completed", Conclusion: "success", CreatedAt: time.Unix(100, 0)}, + } + wf, ok := buildWorkflow("CI", "u", runs) + if !ok || wf.Latest.ID != 1 || wf.LastDone.ID != 1 { + t.Fatalf("%+v", wf) + } + if _, ok := buildWorkflow("CI", "u", runs[:1]); ok { + t.Fatal("only PR runs must yield no workflow") + } +} + +func TestAPIErrorMessageParsing(t *testing.T) { + if got := apiMessage([]byte(`{"message":"nope"}`)); got != "nope" { + t.Fatal(got) + } + if got := apiMessage([]byte(strings.Repeat("x", 500))); len(got) != 200 { + t.Fatalf("len=%d", len(got)) + } +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go new file mode 100644 index 0000000..ba7573b --- /dev/null +++ b/internal/metrics/metrics.go @@ -0,0 +1,178 @@ +// Package metrics exposes dashboard state as Prometheus metrics, computed at scrape time +// so age-based series keep advancing between refreshes. +package metrics + +import ( + "strconv" + "time" + + "github.com/prometheus/client_golang/prometheus" + + "github.com/lukaszraczylo/ghtracker/internal/collector" + "github.com/lukaszraczylo/ghtracker/internal/model" +) + +const ns = "ghtracker" + +// Source supplies the state to export. +type Source interface { + View() collector.View + Stats() collector.Stats +} + +// RateLimit reports the remaining GitHub request budget; ok is false before the first response. +type RateLimit func() (remaining int, ok bool) + +type Collector struct { + src Source + rate RateLimit + d map[string]*prometheus.Desc +} + +var ( + repoLabels = []string{"repo"} + prLabels = []string{"repo", "number"} + workflowLabels = []string{"repo", "workflow", "background"} +) + +var descs = []struct { + key, help string + labels []string +}{ + {"repo_up", "1 if the last refresh of the repo succeeded.", repoLabels}, + {"repo_health", "Worst alert severity for the repo: 0 ok, 1 warn, 2 crit.", repoLabels}, + {"repo_last_refresh_timestamp_seconds", "Unix time of the last successful refresh of the repo.", repoLabels}, + {"repo_open_issues", "Open issues, excluding pull requests.", repoLabels}, + {"repo_open_prs", "Open pull requests, including drafts.", repoLabels}, + {"repo_open_prs_failing_checks", "Open non-draft pull requests whose checks fail.", repoLabels}, + {"repo_oldest_pr_age_seconds", "Age of the oldest open non-draft pull request; 0 if none.", repoLabels}, + {"repo_stale_issues", "Open issues without activity for longer than thresholds.issue_stale_after.", repoLabels}, + {"pr_age_seconds", "Age of an open non-draft pull request.", prLabels}, + {"pr_checks_failing", "1 if the pull request has failing checks.", prLabels}, + {"workflow_failing", "1 if the newest completed default-branch run of the workflow failed.", workflowLabels}, + {"workflow_running", "1 if the workflow has a run in progress or queued.", workflowLabels}, + {"workflow_last_run_timestamp_seconds", "Unix time the newest default-branch run was created.", workflowLabels}, + {"release_info", "Latest release or tag; value is always 1.", []string{"repo", "version", "url"}}, + {"release_published_timestamp_seconds", "Unix time the latest release was published.", repoLabels}, + {"alerts", "Active alerts by severity.", []string{"severity"}}, + {"last_refresh_timestamp_seconds", "Unix time the last refresh started.", nil}, + {"last_refresh_duration_seconds", "Duration of the last refresh.", nil}, + {"refreshes_total", "Completed refresh cycles.", nil}, + {"repo_refresh_errors_total", "Repo refresh failures.", nil}, + {"github_rate_limit_remaining", "Remaining GitHub API requests in the current window.", nil}, +} + +func New(src Source, rate RateLimit) *Collector { + c := &Collector{src: src, rate: rate, d: make(map[string]*prometheus.Desc, len(descs))} + for _, d := range descs { + c.d[d.key] = prometheus.NewDesc(prometheus.BuildFQName(ns, "", d.key), d.help, d.labels, nil) + } + return c +} + +func (c *Collector) Describe(ch chan<- *prometheus.Desc) { + for _, d := range c.d { + ch <- d + } +} + +func (c *Collector) Collect(ch chan<- prometheus.Metric) { + g := func(key string, v float64, labels ...string) { + ch <- prometheus.MustNewConstMetric(c.d[key], prometheus.GaugeValue, v, labels...) + } + ctr := func(key string, v float64) { + ch <- prometheus.MustNewConstMetric(c.d[key], prometheus.CounterValue, v) + } + + v, st := c.src.View(), c.src.Stats() + for _, r := range v.Repos { + c.collectRepo(g, r, v) + } + for _, sev := range []model.Severity{model.Crit, model.Warn} { + n := v.Crit + if sev == model.Warn { + n = v.Warn + } + g("alerts", float64(n), sev.String()) + } + + g("last_refresh_timestamp_seconds", unix(st.LastRefresh)) + g("last_refresh_duration_seconds", st.LastDuration.Seconds()) + ctr("refreshes_total", float64(st.RefreshTotal)) + ctr("repo_refresh_errors_total", float64(st.RepoErrorsTotal)) + if c.rate != nil { + if rem, ok := c.rate(); ok { + g("github_rate_limit_remaining", float64(rem)) + } + } +} + +func (c *Collector) collectRepo(g func(string, float64, ...string), r model.Repo, v collector.View) { + g("repo_up", b2f(r.Up), r.FullName) + g("repo_health", float64(r.Health), r.FullName) + g("repo_last_refresh_timestamp_seconds", unix(r.RefreshedAt), r.FullName) + g("repo_open_issues", float64(len(r.Issues)), r.FullName) + g("repo_open_prs", float64(len(r.PRs)), r.FullName) + + var failing int + var oldest time.Duration + for _, pr := range r.PRs { + if pr.Draft { + continue + } + age := v.Now.Sub(pr.CreatedAt) + oldest = max(oldest, age) + num := strconv.Itoa(pr.Number) + g("pr_age_seconds", age.Seconds(), r.FullName, num) + g("pr_checks_failing", b2f(pr.Checks.State == model.ChecksFail), r.FullName, num) + if pr.Checks.State == model.ChecksFail { + failing++ + } + } + g("repo_open_prs_failing_checks", float64(failing), r.FullName) + g("repo_oldest_pr_age_seconds", oldest.Seconds(), r.FullName) + + var stale int + if v.Thresholds.IssueStaleAfter > 0 { + for _, is := range r.Issues { + if v.Now.Sub(is.UpdatedAt) >= v.Thresholds.IssueStaleAfter { + stale++ + } + } + } + g("repo_stale_issues", float64(stale), r.FullName) + + seen := map[string]bool{} + for _, wf := range r.Workflows { + // Two workflows may share a name; duplicate label sets would fail the whole scrape. + if seen[wf.Name] { + continue + } + seen[wf.Name] = true + bg := strconv.FormatBool(wf.Background) + g("workflow_failing", b2f(wf.LastDone != nil && wf.LastDone.Failed() && !collector.Dormant(wf, v.Now, v.Thresholds)), r.FullName, wf.Name, bg) + g("workflow_running", b2f(wf.Latest.Running()), r.FullName, wf.Name, bg) + g("workflow_last_run_timestamp_seconds", unix(wf.Latest.CreatedAt), r.FullName, wf.Name, bg) + } + + if rel := r.Release; rel != nil { + g("release_info", 1, r.FullName, rel.Tag, rel.URL) + if !rel.PublishedAt.IsZero() { + g("release_published_timestamp_seconds", unix(rel.PublishedAt), r.FullName) + } + } +} + +func b2f(b bool) float64 { + if b { + return 1 + } + return 0 +} + +func unix(t time.Time) float64 { + if t.IsZero() { + return 0 + } + return float64(t.Unix()) +} diff --git a/internal/metrics/metrics_test.go b/internal/metrics/metrics_test.go new file mode 100644 index 0000000..3b4593f --- /dev/null +++ b/internal/metrics/metrics_test.go @@ -0,0 +1,133 @@ +package metrics + +import ( + "strings" + "testing" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/testutil" + + "github.com/lukaszraczylo/ghtracker/internal/collector" + "github.com/lukaszraczylo/ghtracker/internal/config" + "github.com/lukaszraczylo/ghtracker/internal/model" +) + +var now = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) + +type fakeSource struct { + st collector.Stats + v collector.View +} + +func (f fakeSource) View() collector.View { return f.v } +func (f fakeSource) Stats() collector.Stats { return f.st } + +func source() fakeSource { + failed := &model.Run{Status: "completed", Conclusion: "failure"} + repo := model.Repo{ + FullName: "o/r", Up: true, Health: model.Crit, RefreshedAt: now.Add(-time.Hour), + Issues: []model.Issue{{UpdatedAt: now.Add(-40 * 24 * time.Hour)}, {UpdatedAt: now}}, + PRs: []model.PullRequest{ + {Number: 7, CreatedAt: now.Add(-50 * time.Hour), Checks: model.CheckSummary{State: model.ChecksFail}}, + {Number: 8, Draft: true, CreatedAt: now.Add(-900 * time.Hour)}, + }, + Workflows: []model.Workflow{ + {Name: "CI", Latest: *failed, LastDone: failed}, + {Name: "CI", Latest: *failed, LastDone: failed}, // duplicate name must not break the scrape + {Name: "Nightly", Background: true, Latest: model.Run{Status: "in_progress", CreatedAt: now}}, + }, + Release: &model.Release{Tag: "v1.0.0", URL: "https://x/rel", PublishedAt: now.Add(-24 * time.Hour)}, + } + return fakeSource{ + v: collector.View{Now: now, Repos: []model.Repo{repo}, Crit: 1, Warn: 2, + Thresholds: config.Thresholds{IssueStaleAfter: 30 * 24 * time.Hour}}, + st: collector.Stats{LastRefresh: now.Add(-time.Hour), LastDuration: 3 * time.Second, RefreshTotal: 4, RepoErrorsTotal: 1, Loaded: true}, + } +} + +func TestScrapeValuesAndLint(t *testing.T) { + reg := prometheus.NewPedanticRegistry() + reg.MustRegister(New(source(), func() (int, bool) { return 4321, true })) + want := ` +# HELP ghtracker_repo_up 1 if the last refresh of the repo succeeded. +# TYPE ghtracker_repo_up gauge +ghtracker_repo_up{repo="o/r"} 1 +# HELP ghtracker_repo_open_prs_failing_checks Open non-draft pull requests whose checks fail. +# TYPE ghtracker_repo_open_prs_failing_checks gauge +ghtracker_repo_open_prs_failing_checks{repo="o/r"} 1 +# HELP ghtracker_repo_oldest_pr_age_seconds Age of the oldest open non-draft pull request; 0 if none. +# TYPE ghtracker_repo_oldest_pr_age_seconds gauge +ghtracker_repo_oldest_pr_age_seconds{repo="o/r"} 180000 +# HELP ghtracker_repo_stale_issues Open issues without activity for longer than thresholds.issue_stale_after. +# TYPE ghtracker_repo_stale_issues gauge +ghtracker_repo_stale_issues{repo="o/r"} 1 +# HELP ghtracker_github_rate_limit_remaining Remaining GitHub API requests in the current window. +# TYPE ghtracker_github_rate_limit_remaining gauge +ghtracker_github_rate_limit_remaining 4321 +` + if err := testutil.GatherAndCompare(reg, strings.NewReader(want), + "ghtracker_repo_up", "ghtracker_repo_open_prs_failing_checks", "ghtracker_repo_oldest_pr_age_seconds", + "ghtracker_repo_stale_issues", "ghtracker_github_rate_limit_remaining"); err != nil { + t.Fatal(err) + } + if probs, err := testutil.GatherAndLint(reg); err != nil || len(probs) != 0 { + t.Fatalf("lint: %v %v", probs, err) + } +} + +func TestDraftPRsExcludedFromAgeSeries(t *testing.T) { + reg := prometheus.NewPedanticRegistry() + reg.MustRegister(New(source(), nil)) + n, err := testutil.GatherAndCount(reg, "ghtracker_pr_age_seconds") + if err != nil || n != 1 { + t.Fatalf("pr_age series = %d err=%v, want 1 (draft excluded)", n, err) + } +} + +func TestWorkflowSeriesDedupedAndLabelled(t *testing.T) { + reg := prometheus.NewPedanticRegistry() + reg.MustRegister(New(source(), nil)) + want := ` +# HELP ghtracker_workflow_failing 1 if the newest completed default-branch run of the workflow failed. +# TYPE ghtracker_workflow_failing gauge +ghtracker_workflow_failing{background="false",repo="o/r",workflow="CI"} 1 +ghtracker_workflow_failing{background="true",repo="o/r",workflow="Nightly"} 0 +` + if err := testutil.GatherAndCompare(reg, strings.NewReader(want), "ghtracker_workflow_failing"); err != nil { + t.Fatal(err) + } +} + +func TestReleaseAndCounters(t *testing.T) { + reg := prometheus.NewPedanticRegistry() + reg.MustRegister(New(source(), nil)) + want := ` +# HELP ghtracker_release_info Latest release or tag; value is always 1. +# TYPE ghtracker_release_info gauge +ghtracker_release_info{repo="o/r",url="https://x/rel",version="v1.0.0"} 1 +# HELP ghtracker_refreshes_total Completed refresh cycles. +# TYPE ghtracker_refreshes_total counter +ghtracker_refreshes_total 4 +# HELP ghtracker_repo_refresh_errors_total Repo refresh failures. +# TYPE ghtracker_repo_refresh_errors_total counter +ghtracker_repo_refresh_errors_total 1 +# HELP ghtracker_alerts Active alerts by severity. +# TYPE ghtracker_alerts gauge +ghtracker_alerts{severity="crit"} 1 +ghtracker_alerts{severity="warn"} 2 +` + if err := testutil.GatherAndCompare(reg, strings.NewReader(want), + "ghtracker_release_info", "ghtracker_refreshes_total", "ghtracker_repo_refresh_errors_total", "ghtracker_alerts"); err != nil { + t.Fatal(err) + } +} + +func TestRateLimitOmittedWhenUnknown(t *testing.T) { + reg := prometheus.NewPedanticRegistry() + reg.MustRegister(New(source(), func() (int, bool) { return 0, false })) + n, err := testutil.GatherAndCount(reg, "ghtracker_github_rate_limit_remaining") + if err != nil || n != 0 { + t.Fatalf("n=%d err=%v", n, err) + } +} diff --git a/internal/model/model.go b/internal/model/model.go new file mode 100644 index 0000000..f393c76 --- /dev/null +++ b/internal/model/model.go @@ -0,0 +1,146 @@ +// Package model holds the data shapes shared by the collector, metrics and web layers. +package model + +import ( + "errors" + "time" +) + +// ErrRateLimited means GitHub limits were hit or are about to be; callers must stop issuing requests. +var ErrRateLimited = errors.New("github rate limit reached") + +type Severity int + +const ( + OK Severity = iota + Warn + Crit +) + +func (s Severity) String() string { + switch s { + case Warn: + return "warn" + case Crit: + return "crit" + default: + return "ok" + } +} + +type CheckState string + +const ( + ChecksNone CheckState = "none" + ChecksPending CheckState = "pending" + ChecksPass CheckState = "success" + ChecksFail CheckState = "failure" + ChecksUnknown CheckState = "unknown" +) + +type Repo struct { + RefreshedAt time.Time + Release *Release + Description string + FullName string + DefaultBranch string + ReleasesURL string + // Err is set when the last refresh failed entirely; the other fields hold the last good data. + Err string + URL string + Issues []Issue + PRs []PullRequest + Workflows []Workflow + // Warnings lists sections that failed to load while the rest succeeded. + Warnings []string + Health Severity + Archived bool + Up bool +} + +type Release struct { + PublishedAt time.Time + Tag string + Name string + URL string + Prerelease bool + // FromTag marks a version taken from tags because the repo has no releases. + FromTag bool +} + +type Issue struct { + CreatedAt time.Time + UpdatedAt time.Time + Title string + URL string + Author string + Labels []string + Number int + Comments int +} + +type PullRequest struct { + CreatedAt time.Time + UpdatedAt time.Time + Title string + URL string + Author string + HeadSHA string + Labels []string + Checks CheckSummary + Number int + Draft bool +} + +type CheckSummary struct { + State CheckState + Failing []string + Total int + Failed int + Pending int +} + +type Workflow struct { + // LastDone is the newest completed run; nil when no run has completed yet. + LastDone *Run + Name string + URL string + Latest Run + Background bool +} + +type Run struct { + CreatedAt time.Time + UpdatedAt time.Time + URL string + Event string + Branch string + Status string + Conclusion string + ID int64 +} + +// Running reports whether the run has not completed. +func (r Run) Running() bool { return r.Status != "completed" } + +// Failed reports whether a completed run ended in a failing conclusion. +func (r Run) Failed() bool { + switch r.Conclusion { + case "failure", "timed_out", "startup_failure", "action_required": + return true + } + return false +} + +type Alert struct { + Since time.Time + Repo string + Kind string + Subject string + Detail string + URL string + Severity Severity +} + +// Message renders the alert as one line for logs and the JSON API. +func (a Alert) Message() string { return a.Subject + ": " + a.Detail } diff --git a/internal/web/api.go b/internal/web/api.go new file mode 100644 index 0000000..d694cb5 --- /dev/null +++ b/internal/web/api.go @@ -0,0 +1,174 @@ +package web + +import ( + "time" + + "github.com/lukaszraczylo/ghtracker/internal/collector" + "github.com/lukaszraczylo/ghtracker/internal/model" +) + +// apiState is the JSON contract with the UI. Lists of healthy items stay on GitHub; +// the UI gets counts, release info and the alerts that explain every non-green state. +type apiState struct { + Now time.Time `json:"now"` + LastRefresh time.Time `json:"lastRefresh"` + Repos []apiRepo `json:"repos"` + Alerts []apiAlert `json:"alerts"` + Thresholds apiThresholds `json:"thresholds"` + Counts apiCounts `json:"counts"` + IntervalSeconds int64 `json:"intervalSeconds"` + Loaded bool `json:"loaded"` + Refreshing bool `json:"refreshing"` + ManualRefresh bool `json:"manualRefresh"` +} + +type apiThresholds struct { + PRWarnSeconds int64 `json:"prWarnSeconds"` + PRCritSeconds int64 `json:"prCritSeconds"` + IssueStaleSecs int64 `json:"issueStaleSeconds"` + WorkflowStuck int64 `json:"workflowStuckSeconds"` + WorkflowDormant int64 `json:"workflowDormantSeconds"` +} + +type apiCounts struct { + Crit int `json:"crit"` + Warn int `json:"warn"` +} + +type apiRepo struct { + RefreshedAt time.Time `json:"refreshedAt"` + Release *apiRelease `json:"release"` + FullName string `json:"fullName"` + URL string `json:"url"` + ReleasesURL string `json:"releasesUrl"` + Description string `json:"description"` + Health string `json:"health"` + Error string `json:"error"` + Workflows apiWorkflows `json:"workflows"` + PRs apiPRs `json:"prs"` + Issues apiIssues `json:"issues"` + Archived bool `json:"archived"` + Up bool `json:"up"` +} + +type apiWorkflows struct { + Total int `json:"total"` + Failing int `json:"failing"` + Running int `json:"running"` +} + +type apiPRs struct { + OldestSeconds int64 `json:"oldestSeconds"` + Open int `json:"open"` + Waiting int `json:"waiting"` + FailingChecks int `json:"failingChecks"` +} + +type apiIssues struct { + Open int `json:"open"` + Stale int `json:"stale"` +} + +type apiRelease struct { + PublishedAt time.Time `json:"publishedAt"` + Tag string `json:"tag"` + URL string `json:"url"` + Prerelease bool `json:"prerelease"` + FromTag bool `json:"fromTag"` +} + +type apiAlert struct { + Since time.Time `json:"since"` + Repo string `json:"repo"` + Kind string `json:"kind"` + Severity string `json:"severity"` + Subject string `json:"subject"` + Detail string `json:"detail"` + URL string `json:"url"` +} + +func seconds(d time.Duration) int64 { return int64(d / time.Second) } + +func buildState(v collector.View, manualRefresh bool) apiState { + st := apiState{ + Now: v.Now, + LastRefresh: v.LastRefresh, + IntervalSeconds: seconds(v.Interval), + Loaded: v.Loaded, + Refreshing: v.Refreshing, + ManualRefresh: manualRefresh, + Counts: apiCounts{Crit: v.Crit, Warn: v.Warn}, + Thresholds: apiThresholds{ + PRWarnSeconds: seconds(v.Thresholds.PRWarnAfter), + PRCritSeconds: seconds(v.Thresholds.PRCritAfter), + IssueStaleSecs: seconds(v.Thresholds.IssueStaleAfter), + WorkflowStuck: seconds(v.Thresholds.WorkflowStuck), + WorkflowDormant: seconds(v.Thresholds.WorkflowDormant), + }, + Repos: make([]apiRepo, 0, len(v.Repos)), + Alerts: make([]apiAlert, 0, len(v.Alerts)), + } + for _, r := range v.Repos { + ar := apiRepo{ + FullName: r.FullName, URL: r.URL, ReleasesURL: r.ReleasesURL, Description: r.Description, + Health: r.Health.String(), Error: r.Err, Archived: r.Archived, Up: r.Up, RefreshedAt: r.RefreshedAt, + Workflows: workflowStats(r, v), PRs: prStats(r, v), Issues: issueStats(r, v), + } + if rel := r.Release; rel != nil { + ar.Release = &apiRelease{Tag: rel.Tag, URL: rel.URL, PublishedAt: rel.PublishedAt, Prerelease: rel.Prerelease, FromTag: rel.FromTag} + } + st.Repos = append(st.Repos, ar) + } + for _, a := range v.Alerts { + st.Alerts = append(st.Alerts, apiAlert{ + Severity: a.Severity.String(), Repo: a.Repo, Kind: a.Kind, Subject: a.Subject, + Detail: a.Detail, URL: a.URL, Since: a.Since, + }) + } + return st +} + +func workflowStats(r model.Repo, v collector.View) apiWorkflows { + out := apiWorkflows{Total: len(r.Workflows)} + for _, wf := range r.Workflows { + if wf.LastDone != nil && wf.LastDone.Failed() && !collector.Dormant(wf, v.Now, v.Thresholds) { + out.Failing++ + } + if wf.Latest.Running() { + out.Running++ + } + } + return out +} + +// prStats counts non-draft pull requests; drafts never wait on anyone. +func prStats(r model.Repo, v collector.View) apiPRs { + out := apiPRs{Open: len(r.PRs)} + for _, pr := range r.PRs { + if pr.Draft { + continue + } + age := v.Now.Sub(pr.CreatedAt) + out.OldestSeconds = max(out.OldestSeconds, seconds(age)) + if v.Thresholds.PRWarnAfter > 0 && age >= v.Thresholds.PRWarnAfter { + out.Waiting++ + } + if pr.Checks.State == model.ChecksFail { + out.FailingChecks++ + } + } + return out +} + +func issueStats(r model.Repo, v collector.View) apiIssues { + out := apiIssues{Open: len(r.Issues)} + if v.Thresholds.IssueStaleAfter <= 0 { + return out + } + for _, is := range r.Issues { + if v.Now.Sub(is.UpdatedAt) >= v.Thresholds.IssueStaleAfter { + out.Stale++ + } + } + return out +} diff --git a/internal/web/server.go b/internal/web/server.go new file mode 100644 index 0000000..fb95125 --- /dev/null +++ b/internal/web/server.go @@ -0,0 +1,129 @@ +// Package web serves the embedded UI, the JSON state, the health probe and metrics. +package web + +import ( + "encoding/json" + "io/fs" + "log/slog" + "net/http" + "path" + "strings" + "sync" + "time" + + "github.com/lukaszraczylo/ghtracker/internal/collector" +) + +const ( + minManualRefreshGap = 5 * time.Minute + indexFile = "index.html" + assetsPrefix = "/assets/" + immutableCache = "public, max-age=31536000, immutable" + contentSecurity = "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; " + + "connect-src 'self'; img-src 'self' data:; font-src 'self'; form-action 'self'; base-uri 'none'" +) + +// Source supplies dashboard state and accepts refresh requests. +type Source interface { + View() collector.View + Stats() collector.Stats + RequestRefresh() bool +} + +type Server struct { + lastManual time.Time + now func() time.Time + src Source + ui fs.FS + metrics http.Handler + log *slog.Logger + mu sync.Mutex + manualRefresh bool +} + +// New builds the server; ui is the built frontend (index.html at its root). +func New(src Source, metrics http.Handler, ui fs.FS, manualRefresh bool, log *slog.Logger) *Server { + return &Server{src: src, ui: ui, metrics: metrics, manualRefresh: manualRefresh, log: log, now: time.Now} +} + +func (s *Server) Handler() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /{$}", s.index) + mux.HandleFunc("GET /assets/", s.asset) + mux.HandleFunc("GET /api/state", s.state) + mux.HandleFunc("GET /healthz", s.health) + mux.Handle("GET /metrics", s.metrics) + if s.manualRefresh { + mux.HandleFunc("POST /refresh", s.refresh) + } + return securityHeaders(mux) +} + +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Content-Security-Policy", contentSecurity) + h.Set("X-Content-Type-Options", "nosniff") + h.Set("Referrer-Policy", "no-referrer") + next.ServeHTTP(w, r) + }) +} + +func (s *Server) index(w http.ResponseWriter, _ *http.Request) { + b, err := fs.ReadFile(s.ui, indexFile) + if err != nil { + http.Error(w, "UI is not built: run `pnpm build` before `go build`", http.StatusServiceUnavailable) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache") + _, _ = w.Write(b) +} + +// asset serves hashed build output under /assets/ from the embedded filesystem. +func (s *Server) asset(w http.ResponseWriter, r *http.Request) { + name := path.Clean(strings.TrimPrefix(r.URL.Path, "/")) + if !strings.HasPrefix("/"+name, assetsPrefix) { + http.NotFound(w, r) + return + } + w.Header().Set("Cache-Control", immutableCache) + http.ServeFileFS(w, r, s.ui, name) +} + +func (s *Server) state(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + if err := json.NewEncoder(w).Encode(buildState(s.src.View(), s.manualRefresh)); err != nil { + s.log.Error("encode state", "err", err) + } +} + +// health reports 503 until the first refresh finishes, for probes. +func (s *Server) health(w http.ResponseWriter, _ *http.Request) { + if !s.src.Stats().Loaded { + http.Error(w, "loading", http.StatusServiceUnavailable) + return + } + _, _ = w.Write([]byte("ok\n")) +} + +func (s *Server) refresh(w http.ResponseWriter, r *http.Request) { + // Browsers label cross-site requests; refuse them so another page cannot spend the API budget. + if site := r.Header.Get("Sec-Fetch-Site"); site != "" && site != "same-origin" && site != "none" { + http.Error(w, "cross-site refresh refused", http.StatusForbidden) + return + } + s.mu.Lock() + tooSoon := s.now().Sub(s.lastManual) < minManualRefreshGap + if !tooSoon { + s.lastManual = s.now() + } + s.mu.Unlock() + if tooSoon { + http.Error(w, "refresh allowed once per "+minManualRefreshGap.String(), http.StatusTooManyRequests) + return + } + s.src.RequestRefresh() + w.WriteHeader(http.StatusAccepted) +} diff --git a/internal/web/server_test.go b/internal/web/server_test.go new file mode 100644 index 0000000..3f85a46 --- /dev/null +++ b/internal/web/server_test.go @@ -0,0 +1,239 @@ +package web + +import ( + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" + "time" + + "github.com/lukaszraczylo/ghtracker/internal/collector" + "github.com/lukaszraczylo/ghtracker/internal/config" + "github.com/lukaszraczylo/ghtracker/internal/model" +) + +var now = time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) + +type fakeSource struct { + st collector.Stats + v collector.View + refreshed int +} + +func (f *fakeSource) View() collector.View { return f.v } +func (f *fakeSource) Stats() collector.Stats { return f.st } +func (f *fakeSource) RequestRefresh() bool { f.refreshed++; return true } + +func testView() collector.View { + repo := model.Repo{ + FullName: "o/r", URL: "https://github.com/o/r", ReleasesURL: "https://github.com/o/r/releases", Up: true, + Health: model.Crit, RefreshedAt: now.Add(-time.Hour), Description: "desc", + Release: &model.Release{Tag: "v1.2.3", URL: "https://github.com/o/r/releases/tag/v1.2.3", PublishedAt: now.Add(-48 * time.Hour)}, + PRs: []model.PullRequest{ + {Number: 5, CreatedAt: now.Add(-72 * time.Hour), Checks: model.CheckSummary{State: model.ChecksFail}}, + {Number: 6, Draft: true, CreatedAt: now.Add(-900 * time.Hour)}, + }, + Issues: []model.Issue{{Number: 9, UpdatedAt: now.Add(-40 * 24 * time.Hour)}, {Number: 10, UpdatedAt: now}}, + Workflows: []model.Workflow{ + {Name: "CI", Latest: model.Run{Status: "completed", Conclusion: "failure", CreatedAt: now.Add(-time.Hour)}, LastDone: &model.Run{Status: "completed", Conclusion: "failure", CreatedAt: now.Add(-time.Hour)}}, + {Name: "Nightly", Latest: model.Run{Status: "in_progress", CreatedAt: now}}, + }, + } + bare := model.Repo{FullName: "o/bare", URL: "https://github.com/o/bare", ReleasesURL: "https://github.com/o/bare/releases", Err: "boom"} + return collector.View{ + Now: now, LastRefresh: now.Add(-time.Hour), Interval: 6 * time.Hour, Loaded: true, Crit: 1, Warn: 1, + Thresholds: config.Thresholds{PRWarnAfter: 48 * time.Hour, PRCritAfter: 168 * time.Hour, IssueStaleAfter: 720 * time.Hour}, + Repos: []model.Repo{repo, bare}, + Alerts: []model.Alert{ + {Severity: model.Crit, Repo: "o/r", Kind: "workflow_failed", Subject: "CI", Detail: "failure on main (push)", URL: "https://github.com/o/r/actions/runs/1", Since: now.Add(-2 * time.Hour)}, + {Severity: model.Warn, Repo: "o/r", Kind: "pr_waiting", Subject: "#5 fix", Detail: "open 3d", URL: "https://github.com/o/r/pull/5", Since: now.Add(-72 * time.Hour)}, + }, + } +} + +func testUI() fstest.MapFS { + return fstest.MapFS{ + "index.html": {Data: []byte("ui")}, + "assets/app.js": {Data: []byte("console.log(1)")}, + "secret.txt": {Data: []byte("nope")}, + } +} + +func newServer(src *fakeSource, ui fstest.MapFS, manual bool) *Server { + return New(src, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("metrics")) }), + ui, manual, slog.New(slog.NewTextHandler(io.Discard, nil))) +} + +func do(h http.Handler, method, path string) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(method, path, nil)) + return rec +} + +func TestIndexServesBuiltUIWithSecurityHeaders(t *testing.T) { + rec := do(newServer(&fakeSource{}, testUI(), false).Handler(), "GET", "/") + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "ui") { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } + if rec.Header().Get("Cache-Control") != "no-cache" || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") { + t.Fatalf("headers: %v", rec.Header()) + } + csp := rec.Header().Get("Content-Security-Policy") + for _, want := range []string{"default-src 'none'", "script-src 'self'", "connect-src 'self'"} { + if !strings.Contains(csp, want) { + t.Errorf("CSP %q missing %q", csp, want) + } + } + if rec.Header().Get("X-Content-Type-Options") != "nosniff" { + t.Fatal("nosniff missing") + } +} + +func TestIndexWithoutBuiltUIIs503(t *testing.T) { + rec := do(newServer(&fakeSource{}, fstest.MapFS{}, false).Handler(), "GET", "/") + if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "pnpm build") { + t.Fatalf("%d %s", rec.Code, rec.Body.String()) + } +} + +func TestAssetsCachedAndScopedToAssetsDir(t *testing.T) { + h := newServer(&fakeSource{}, testUI(), false).Handler() + rec := do(h, "GET", "/assets/app.js") + if rec.Code != 200 || rec.Header().Get("Cache-Control") != immutableCache || rec.Body.String() != "console.log(1)" { + t.Fatalf("%d %v", rec.Code, rec.Header()) + } + for _, p := range []string{"/assets/missing.js", "/secret.txt", "/assets/../secret.txt", "/nope"} { + if rec := do(h, "GET", p); rec.Code != http.StatusNotFound && rec.Code != http.StatusMovedPermanently && rec.Code != http.StatusTemporaryRedirect { + t.Errorf("%s -> %d, want 404", p, rec.Code) + } else if rec.Body.Len() > 0 && strings.Contains(rec.Body.String(), "nope") { + t.Errorf("%s leaked a file outside assets", p) + } + } +} + +func TestStateJSONContract(t *testing.T) { + src := &fakeSource{v: testView()} + rec := do(newServer(src, testUI(), true).Handler(), "GET", "/api/state") + if rec.Code != 200 || rec.Header().Get("Content-Type") != "application/json" || rec.Header().Get("Cache-Control") != "no-store" { + t.Fatalf("%d %v", rec.Code, rec.Header()) + } + var got struct { + Repos []map[string]any + Alerts []map[string]any + Counts struct{ Crit, Warn int } + Thresholds struct{ PRWarnSeconds, PRCritSeconds int64 } + IntervalSeconds int64 + Loaded bool + ManualRefresh bool + } + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if got.IntervalSeconds != 21600 || !got.Loaded || !got.ManualRefresh || got.Counts.Crit != 1 || got.Counts.Warn != 1 { + t.Fatalf("top level: %+v", got) + } + if got.Thresholds.PRWarnSeconds != 172800 || got.Thresholds.PRCritSeconds != 604800 { + t.Fatalf("thresholds: %+v", got.Thresholds) + } + r := got.Repos[0] + if r["fullName"] != "o/r" || r["health"] != "crit" { + t.Fatalf("repo: %v", r) + } + prs, issues, wf := r["prs"].(map[string]any), r["issues"].(map[string]any), r["workflows"].(map[string]any) + if prs["open"] != float64(2) || prs["waiting"] != float64(1) || prs["failingChecks"] != float64(1) || prs["oldestSeconds"] != float64(72*3600) { + t.Fatalf("prs: %v", prs) + } + if issues["open"] != float64(2) || issues["stale"] != float64(1) { + t.Fatalf("issues: %v", issues) + } + if wf["total"] != float64(2) || wf["failing"] != float64(1) || wf["running"] != float64(1) { + t.Fatalf("workflows: %v", wf) + } + if rel := r["release"].(map[string]any); rel["tag"] != "v1.2.3" || rel["url"] == "" { + t.Fatalf("release: %v", rel) + } + if got.Repos[1]["release"] != nil || got.Repos[1]["error"] != "boom" || got.Repos[1]["health"] != "ok" { + t.Fatalf("bare repo: %v", got.Repos[1]) + } + if len(got.Alerts) != 2 || got.Alerts[0]["severity"] != "crit" || got.Alerts[0]["subject"] != "CI" { + t.Fatalf("alerts: %v", got.Alerts) + } +} + +func TestStateJSONEmptyListsAreArrays(t *testing.T) { + rec := do(newServer(&fakeSource{}, testUI(), false).Handler(), "GET", "/api/state") + body := rec.Body.String() + if !strings.Contains(body, `"repos":[]`) || !strings.Contains(body, `"alerts":[]`) { + t.Fatalf("lists must encode as [], got %s", body) + } +} + +func TestHealthzAndMetrics(t *testing.T) { + src := &fakeSource{} + h := newServer(src, testUI(), false).Handler() + if rec := do(h, "GET", "/healthz"); rec.Code != http.StatusServiceUnavailable { + t.Fatalf("before first refresh: %d", rec.Code) + } + src.st.Loaded = true + if rec := do(h, "GET", "/healthz"); rec.Code != 200 { + t.Fatalf("after refresh: %d", rec.Code) + } + if rec := do(h, "GET", "/metrics"); rec.Body.String() != "metrics" { + t.Fatal("metrics handler not mounted") + } +} + +func TestManualRefreshDisabledByDefault(t *testing.T) { + src := &fakeSource{} + rec := do(newServer(src, testUI(), false).Handler(), "POST", "/refresh") + if rec.Code == http.StatusAccepted || src.refreshed != 0 { + t.Fatalf("refresh must not be routed when disabled: %d", rec.Code) + } +} + +func TestManualRefreshRateLimited(t *testing.T) { + src := &fakeSource{} + s := newServer(src, testUI(), true) + clock := now + s.now = func() time.Time { return clock } + h := s.Handler() + if rec := do(h, "POST", "/refresh"); rec.Code != http.StatusAccepted { + t.Fatalf("first: %d", rec.Code) + } + clock = clock.Add(time.Minute) + if rec := do(h, "POST", "/refresh"); rec.Code != http.StatusTooManyRequests { + t.Fatalf("second within gap: %d", rec.Code) + } + clock = clock.Add(5 * time.Minute) + if rec := do(h, "POST", "/refresh"); rec.Code != http.StatusAccepted || src.refreshed != 2 { + t.Fatalf("after gap: %d refreshed=%d", rec.Code, src.refreshed) + } + if rec := do(h, "GET", "/refresh"); rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("GET /refresh: %d", rec.Code) + } +} + +func TestManualRefreshRefusesCrossSiteRequests(t *testing.T) { + src := &fakeSource{} + h := newServer(src, testUI(), true).Handler() + for _, site := range []string{"cross-site", "same-site"} { + req := httptest.NewRequest("POST", "/refresh", nil) + req.Header.Set("Sec-Fetch-Site", site) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden || src.refreshed != 0 { + t.Fatalf("%s: %d refreshed=%d", site, rec.Code, src.refreshed) + } + } + req := httptest.NewRequest("POST", "/refresh", nil) + req.Header.Set("Sec-Fetch-Site", "same-origin") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusAccepted { + t.Fatalf("same-origin: %d", rec.Code) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..b576aec --- /dev/null +++ b/main.go @@ -0,0 +1,101 @@ +// Command ghtracker serves a dashboard and Prometheus metrics for a list of GitHub repositories. +package main + +import ( + "context" + "embed" + "errors" + "flag" + "fmt" + "io/fs" + "log/slog" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/prometheus/client_golang/prometheus/promhttp" + + "github.com/lukaszraczylo/ghtracker/internal/collector" + "github.com/lukaszraczylo/ghtracker/internal/config" + "github.com/lukaszraczylo/ghtracker/internal/gh" + "github.com/lukaszraczylo/ghtracker/internal/metrics" + "github.com/lukaszraczylo/ghtracker/internal/web" +) + +// distFS holds the built UI; dist/.gitkeep keeps the pattern valid before the first UI build. +// +//go:embed all:dist +var distFS embed.FS + +// version is set at release time with -ldflags "-X main.version=...". +var version = "dev" + +const ( + httpTimeout = 30 * time.Second + shutdownTimeout = 10 * time.Second + headerTimeout = 10 * time.Second +) + +func main() { + cfgPath := flag.String("config", "config.yaml", "path to the YAML config file") + showVersion := flag.Bool("version", false, "print the version and exit") + flag.Parse() + if *showVersion { + fmt.Println(version) + return + } + log := slog.New(slog.NewTextHandler(os.Stderr, nil)) + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + if err := run(ctx, *cfgPath, log); err != nil { + stop() + log.Error("fatal", "err", err) + os.Exit(1) + } +} + +func run(ctx context.Context, cfgPath string, log *slog.Logger) error { + cfg, err := config.Load(cfgPath) + if err != nil { + return err + } + hc := &http.Client{Timeout: httpTimeout} + auth, err := gh.NewAppAuth(cfg.GitHub.AppID, cfg.GitHub.PrivateKeyPEM, cfg.GitHub.APIURL, cfg.GitHub.InstallationID, hc) + if err != nil { + return err + } + client := gh.NewClient(cfg.GitHub.APIURL, auth, hc, cfg.MaxItems) + coll := collector.New(client, cfg, log) + + reg := prometheus.NewRegistry() + reg.MustRegister(collectors.NewGoCollector(), collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}), + metrics.New(coll, client.RateLimitRemaining)) + ui, err := fs.Sub(distFS, "dist") + if err != nil { + return err + } + srv := web.New(coll, promhttp.HandlerFor(reg, promhttp.HandlerOpts{}), ui, *cfg.ManualRefresh, log) + + go coll.Run(ctx) + + hs := &http.Server{Addr: cfg.Listen, Handler: srv.Handler(), ReadHeaderTimeout: headerTimeout} + errc := make(chan error, 1) + go func() { errc <- hs.ListenAndServe() }() + log.Info("listening", "version", version, "addr", cfg.Listen, "repos", len(cfg.Repos), "refresh", cfg.RefreshInterval) + + select { + case err := <-errc: + return err + case <-ctx.Done(): + } + sctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) + defer cancel() + if err := hs.Shutdown(sctx); err != nil && !errors.Is(err, http.ErrServerClosed) { + return err + } + return nil +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..19d855b --- /dev/null +++ b/main_test.go @@ -0,0 +1,133 @@ +package main + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" +) + +func freeAddr(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer l.Close() + return l.Addr().String() +} + +func get(t *testing.T, url string) (int, string) { + t.Helper() + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil) + if err != nil { + t.Fatal(err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return 0, "" + } + defer func() { _ = resp.Body.Close() }() + b, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(b) +} + +// TestEndToEnd wires config, app auth, collector, metrics and web against a fake GitHub API. +func TestEndToEnd(t *testing.T) { + var apiCalls atomic.Int64 + gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + apiCalls.Add(1) + p := r.URL.Path + switch { + case strings.HasSuffix(p, "/installation"): + fmt.Fprint(w, `{"id":1}`) + case strings.HasPrefix(p, "/app/installations/"): + fmt.Fprintf(w, `{"token":"t","expires_at":%q}`, time.Now().Add(time.Hour).UTC().Format(time.RFC3339)) + case p == "/repos/o/r": + fmt.Fprint(w, `{"html_url":"https://github.com/o/r","default_branch":"main"}`) + case p == "/repos/o/r/issues", p == "/repos/o/r/pulls", p == "/repos/o/r/tags": + fmt.Fprint(w, `[]`) + case p == "/repos/o/r/actions/workflows": + fmt.Fprint(w, `{"workflows":[{"id":1,"name":"CI","html_url":"w","state":"active"}]}`) + case p == "/repos/o/r/actions/workflows/1/runs": + fmt.Fprint(w, `{"workflow_runs":[{"id":1,"html_url":"r","event":"push","head_branch":"main","status":"completed","conclusion":"failure","created_at":"2026-10-01T00:00:00Z"}]}`) + case p == "/repos/o/r/releases/latest": + fmt.Fprint(w, `{"tag_name":"v9.9.9","html_url":"rel","published_at":"2026-10-01T00:00:00Z"}`) + default: + http.NotFound(w, r) + } + })) + defer gh.Close() + + key, _ := rsa.GenerateKey(rand.Reader, 2048) + dir := t.TempDir() + keyPath := filepath.Join(dir, "k.pem") + _ = os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}), 0o600) + addr := freeAddr(t) + cfgPath := filepath.Join(dir, "c.yaml") + cfg := fmt.Sprintf("listen: %q\ngithub:\n app_id: 1\n private_key_file: %s\n api_url: %s\nrepos: [o/r]\n", addr, keyPath, gh.URL) + _ = os.WriteFile(cfgPath, []byte(cfg), 0o600) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- run(ctx, cfgPath, slog.New(slog.NewTextHandler(io.Discard, nil))) }() + + base := "http://" + addr + deadline := time.Now().Add(10 * time.Second) + for { + if code, _ := get(t, base+"/healthz"); code == 200 { + break + } + if time.Now().After(deadline) { + t.Fatal("server never became healthy") + } + time.Sleep(20 * time.Millisecond) + } + + _, metrics := get(t, base+"/metrics") + for _, want := range []string{ + `ghtracker_repo_up{repo="o/r"} 1`, + `ghtracker_workflow_failing{background="false",repo="o/r",workflow="CI"} 1`, + `ghtracker_release_info{repo="o/r",url="rel",version="v9.9.9"} 1`, + `ghtracker_repo_health{repo="o/r"} 2`, + `ghtracker_alerts{severity="crit"} 1`, + "ghtracker_refreshes_total 1", + } { + if !strings.Contains(metrics, want) { + t.Errorf("metrics missing %q", want) + } + } + _, state := get(t, base+"/api/state") + for _, want := range []string{`"tag":"v9.9.9"`, `"failure on main (push)"`, `"health":"crit"`} { + if !strings.Contains(state, want) { + t.Errorf("state missing %q", want) + } + } + + if n := apiCalls.Load(); n > 12 { + t.Errorf("one refresh of one repo used %d API calls, want a handful", n) + } + + cancel() + select { + case err := <-done: + if err != nil { + t.Fatalf("shutdown: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("no graceful shutdown") + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..db942b6 --- /dev/null +++ b/package.json @@ -0,0 +1,47 @@ +{ + "name": "ghtracker-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && node scripts/clean-dist.mjs && vite build", + "typecheck": "vue-tsc --noEmit", + "lint": "eslint .", + "test": "vitest run" + }, + "dependencies": { + "@fontsource-variable/jetbrains-mono": "5.3.0", + "@fontsource-variable/schibsted-grotesk": "5.3.0", + "@fortawesome/fontawesome-svg-core": "^7.3.1", + "@fortawesome/free-solid-svg-icons": "^7.3.1", + "@fortawesome/vue-fontawesome": "^3.3.3", + "@lucide/vue": "^1.49.0", + "@vueuse/core": "^15.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "pinia": "^4.0.3", + "reka-ui": "^2.10.5", + "tailwind-merge": "^3.7.0", + "vue": "^3.5.43" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^26.6.2", + "@vitejs/plugin-vue": "^6.0.9", + "@vue/test-utils": "^2.5.1", + "eslint": "^10.11.0", + "eslint-plugin-vue": "^10.11.1", + "globals": "^17.12.0", + "happy-dom": "^20.14.5", + "prettier": "^3.9.9", + "tailwindcss": "^4.3.3", + "typescript": "~5.9.3", + "typescript-eslint": "^8.70.1", + "vite": "^8.3.1", + "vitest": "^5.0.1", + "vue-tsc": "^3.3.11" + }, + "packageManager": "pnpm@11.21.0" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..a084304 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,3623 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + .: + dependencies: + '@fontsource-variable/jetbrains-mono': + specifier: 5.3.0 + version: 5.3.0 + '@fontsource-variable/schibsted-grotesk': + specifier: 5.3.0 + version: 5.3.0 + '@fortawesome/fontawesome-svg-core': + specifier: ^7.3.1 + version: 7.3.1 + '@fortawesome/free-solid-svg-icons': + specifier: ^7.3.1 + version: 7.3.1 + '@fortawesome/vue-fontawesome': + specifier: ^3.3.3 + version: 3.3.3(@fortawesome/fontawesome-svg-core@7.3.1)(vue@3.5.43(typescript@5.9.3)) + '@lucide/vue': + specifier: ^1.49.0 + version: 1.49.0(vue@3.5.43(typescript@5.9.3)) + '@vueuse/core': + specifier: ^15.0.0 + version: 15.0.0(vue@3.5.43(typescript@5.9.3)) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + pinia: + specifier: ^4.0.3 + version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3)) + reka-ui: + specifier: ^2.10.5 + version: 2.10.5(vue@3.5.43(typescript@5.9.3)) + tailwind-merge: + specifier: ^3.7.0 + version: 3.7.0 + vue: + specifier: ^3.5.43 + version: 3.5.43(typescript@5.9.3) + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.11.0(jiti@2.7.0)) + '@tailwindcss/vite': + specifier: ^4.3.3 + version: 4.3.3(vite@8.3.1(@types/node@26.6.2)(jiti@2.7.0)) + '@types/node': + specifier: ^26.6.2 + version: 26.6.2 + '@vitejs/plugin-vue': + specifier: ^6.0.9 + version: 6.0.9(vite@8.3.1(@types/node@26.6.2)(jiti@2.7.0))(vue@3.5.43(typescript@5.9.3)) + '@vue/test-utils': + specifier: ^2.5.1 + version: 2.5.1(@vue/compiler-dom@3.5.43)(@vue/server-renderer@3.5.43)(vue@3.5.43(typescript@5.9.3)) + eslint: + specifier: ^10.11.0 + version: 10.11.0(jiti@2.7.0) + eslint-plugin-vue: + specifier: ^10.11.1 + version: 10.11.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.11.0(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@10.11.0(jiti@2.7.0))) + globals: + specifier: ^17.12.0 + version: 17.12.0 + happy-dom: + specifier: ^20.14.5 + version: 20.14.5 + prettier: + specifier: ^3.9.9 + version: 3.9.9 + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 + typescript: + specifier: ~5.9.3 + version: 5.9.3 + typescript-eslint: + specifier: ^8.70.1 + version: 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3) + vite: + specifier: ^8.3.1 + version: 8.3.1(@types/node@26.6.2)(jiti@2.7.0) + vitest: + specifier: ^5.0.1 + version: 5.0.1(@types/node@26.6.2)(happy-dom@20.14.5)(vite@8.3.1(@types/node@26.6.2)(jiti@2.7.0)) + vue-tsc: + specifier: ^3.3.11 + version: 3.3.11(typescript@5.9.3) + +packages: + '@babel/helper-string-parser@7.29.7': + resolution: + { + integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==, + } + engines: { node: '>=6.9.0' } + + '@babel/helper-validator-identifier@7.29.7': + resolution: + { + integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==, + } + engines: { node: '>=6.9.0' } + + '@babel/parser@7.29.9': + resolution: + { + integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==, + } + engines: { node: '>=6.0.0' } + hasBin: true + + '@babel/types@7.29.8': + resolution: + { + integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==, + } + engines: { node: '>=6.9.0' } + + '@cacheable/memory@2.2.0': + resolution: + { + integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==, + } + + '@cacheable/utils@2.5.0': + resolution: + { + integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==, + } + + '@eslint-community/eslint-utils@4.10.1': + resolution: + { + integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==, + } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: + { + integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==, + } + engines: { node: ^12.0.0 || ^14.0.0 || >=16.0.0 } + + '@eslint/config-array@0.23.5': + resolution: + { + integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==, + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + '@eslint/config-helpers@0.7.0': + resolution: + { + integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==, + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + '@eslint/core@1.2.1': + resolution: + { + integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==, + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + '@eslint/js@10.0.1': + resolution: + { + integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==, + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: + { + integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==, + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + '@eslint/plugin-kit@0.7.3': + resolution: + { + integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==, + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + '@floating-ui/core@1.8.0': + resolution: + { + integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==, + } + + '@floating-ui/dom@1.8.0': + resolution: + { + integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==, + } + + '@floating-ui/utils@0.2.12': + resolution: + { + integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==, + } + + '@floating-ui/vue@1.1.11': + resolution: + { + integrity: sha512-HzHKCNVxnGS35r9fCHBc3+uCnjw9IWIlCPL683cGgM9Kgj2BiAl8x1mS7vtvP6F9S/e/q4O6MApwSHj8hNLGfw==, + } + + '@fontsource-variable/jetbrains-mono@5.3.0': + resolution: + { + integrity: sha512-F32xpS2NsGYoQi2ADSkKTgpJj7ozajsGgDJ8woTnqjmIB+dxDIqImjl4pXZVEExu8UFZ2ndhmX18EBS/hdz3Lw==, + } + + '@fontsource-variable/schibsted-grotesk@5.3.0': + resolution: + { + integrity: sha512-ndS6H/KICWANchlHF3q4UdZD+xrZ3Ji0rUobIuXlgvDUzDfqZDsVuXPXZb636nJZMHE9zZ9Fmwgqazm8hMB50Q==, + } + + '@fortawesome/fontawesome-common-types@7.3.1': + resolution: + { + integrity: sha512-k0C0sdHmZtAo6dRDtd1Z/qcpyHbL0CKsjV8seMY/21xGhY5Wsv0XRmiI/xEEH4y2c9b1+jvgNs/3EqhV27yUEA==, + } + engines: { node: '>=6' } + + '@fortawesome/fontawesome-svg-core@7.3.1': + resolution: + { + integrity: sha512-BoxVN3PKnMbgStHhjoaky/oWdxHomDqmBVA24IA3KEmssFGeI7u9YT/BJceOjIum/t6TpPa/vcMKaVYQeIQ/3Q==, + } + engines: { node: '>=6' } + + '@fortawesome/free-solid-svg-icons@7.3.1': + resolution: + { + integrity: sha512-v0BLa0eqg7ubvVWeNSHVBs8fWH/GJicERZoJaxJ3FE/lj67VSqzoMg9pzfZVOfLMX10y0pGwQAuxoRVVH2patg==, + } + engines: { node: '>=6' } + + '@fortawesome/vue-fontawesome@3.3.3': + resolution: + { + integrity: sha512-Jbjze98gGcVBSdkscLbnDlHECumMAFVWDarMtycctn9qL3p92ApnV36VW4PwVrcm9IWrHnnE6ZL9x7MXStSCfA==, + } + peerDependencies: + '@fortawesome/fontawesome-svg-core': ~1 || ~6 || ~7 + vue: '>= 3.0.0 < 4' + + '@humanfs/core@0.19.2': + resolution: + { + integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==, + } + engines: { node: '>=18.18.0' } + + '@humanfs/node@0.16.8': + resolution: + { + integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==, + } + engines: { node: '>=18.18.0' } + + '@humanfs/types@0.15.0': + resolution: + { + integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==, + } + engines: { node: '>=18.18.0' } + + '@humanwhocodes/module-importer@1.0.1': + resolution: + { + integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==, + } + engines: { node: '>=12.22' } + + '@humanwhocodes/retry@0.4.3': + resolution: + { + integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==, + } + engines: { node: '>=18.18' } + + '@internationalized/date@3.12.4': + resolution: + { + integrity: sha512-M1dEn4c1U1HsSlaVR8upZtSqvXrTkHDfv18H01uCSJyjVLDxnBR38v/fMxecmlwXKR4i9HeZcmgQAPE6A+aGJQ==, + } + + '@internationalized/number@3.6.8': + resolution: + { + integrity: sha512-8UmMFia46DUt+k97zKd9fKWXcWHR+k8ae3eYzILETuT2KbIvLyOfac7zesw+sJdRAAZ7Q9pM1Mk22aXp2LD0Ig==, + } + + '@jridgewell/gen-mapping@0.3.13': + resolution: + { + integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==, + } + + '@jridgewell/remapping@2.3.5': + resolution: + { + integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==, + } + + '@jridgewell/resolve-uri@3.1.2': + resolution: + { + integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==, + } + engines: { node: '>=6.0.0' } + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: + { + integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==, + } + + '@jridgewell/trace-mapping@0.3.31': + resolution: + { + integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==, + } + + '@keyv/bigmap@1.3.1': + resolution: + { + integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==, + } + engines: { node: '>= 18' } + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: + { + integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==, + } + + '@lucide/vue@1.49.0': + resolution: + { + integrity: sha512-7xS5gYnLoIx0jlmlRE3Rb71fIHaNqhVBiKMpOlqrYt6nMPy3C/62HJHo5XNsV3HTK+9l3M0hXICrIA7S5v6y5Q==, + } + peerDependencies: + vue: '>=3.0.1' + + '@one-ini/wasm@0.2.1': + resolution: + { + integrity: sha512-TUqERXGNTifZ9y2g3wPxQrw3HpHv/02DsW3D90T9x0hhonrL1ZqpSmNrU2XkoIq0fP1N6gZfVQzy2Fw1ZvGBNg==, + } + + '@oxc-project/types@0.152.0': + resolution: + { + integrity: sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==, + } + + '@rolldown/binding-android-arm-eabi@1.2.12': + resolution: + { + integrity: sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.12': + resolution: + { + integrity: sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.12': + resolution: + { + integrity: sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.12': + resolution: + { + integrity: sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.12': + resolution: + { + integrity: sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + resolution: + { + integrity: sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.12': + resolution: + { + integrity: sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.12': + resolution: + { + integrity: sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + resolution: + { + integrity: sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.12': + resolution: + { + integrity: sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.12': + resolution: + { + integrity: sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.12': + resolution: + { + integrity: sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.12': + resolution: + { + integrity: sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.12': + resolution: + { + integrity: sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.12': + resolution: + { + integrity: sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: + { + integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==, + } + + '@swc/helpers@0.5.23': + resolution: + { + integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==, + } + + '@tailwindcss/node@4.3.3': + resolution: + { + integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==, + } + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: + { + integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==, + } + engines: { node: '>= 20' } + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: + { + integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==, + } + engines: { node: '>= 20' } + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: + { + integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==, + } + engines: { node: '>= 20' } + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: + { + integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==, + } + engines: { node: '>= 20' } + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: + { + integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==, + } + engines: { node: '>= 20' } + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: + { + integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==, + } + engines: { node: '>= 20' } + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: + { + integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==, + } + engines: { node: '>= 20' } + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: + { + integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==, + } + engines: { node: '>= 20' } + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: + { + integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==, + } + engines: { node: '>= 20' } + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: + { + integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==, + } + engines: { node: '>=14.0.0' } + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: + { + integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==, + } + engines: { node: '>= 20' } + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: + { + integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==, + } + engines: { node: '>= 20' } + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: + { + integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==, + } + engines: { node: '>= 20' } + + '@tailwindcss/vite@4.3.3': + resolution: + { + integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==, + } + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tanstack/virtual-core@3.17.11': + resolution: + { + integrity: sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g==, + } + + '@tanstack/vue-virtual@3.13.39': + resolution: + { + integrity: sha512-AG8sI+6nfrDvN6ixuHJL9LaJTf3//0V5VITZYW3UOaIuSVexnEzsbMu4LH/JhMfRTxwhUXPnLqkl1DlzkZbRtg==, + } + peerDependencies: + vue: ^2.7.0 || ^3.0.0 + + '@types/chai@5.2.3': + resolution: + { + integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==, + } + + '@types/deep-eql@4.0.2': + resolution: + { + integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==, + } + + '@types/esrecurse@4.3.1': + resolution: + { + integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==, + } + + '@types/estree@1.0.9': + resolution: + { + integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==, + } + + '@types/json-schema@7.0.15': + resolution: + { + integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==, + } + + '@types/node@26.6.2': + resolution: + { + integrity: sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==, + } + + '@types/node@26.6.3': + resolution: + { + integrity: sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==, + } + + '@types/web-bluetooth@0.0.21': + resolution: + { + integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==, + } + + '@types/whatwg-mimetype@3.0.2': + resolution: + { + integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==, + } + + '@types/ws@8.18.2': + resolution: + { + integrity: sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==, + } + + '@typescript-eslint/eslint-plugin@8.70.1': + resolution: + { + integrity: sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + '@typescript-eslint/parser': ^8.70.1 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.70.1': + resolution: + { + integrity: sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.70.1': + resolution: + { + integrity: sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.70.1': + resolution: + { + integrity: sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@typescript-eslint/tsconfig-utils@8.70.1': + resolution: + { + integrity: sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/tsconfig-utils@8.71.0': + resolution: + { + integrity: sha512-Z1UlWHADEK2Mlb9NpWfDeSjqoZ5EyrOv4R3eQpbkzqn/EwaIdOpXXupEA1+0ZIOSJSZZDBHG0BrQyN8zUG6Pwg==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.70.1': + resolution: + { + integrity: sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.70.1': + resolution: + { + integrity: sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@typescript-eslint/types@8.71.0': + resolution: + { + integrity: sha512-cJ4OoxPGWvFnBTnSZyaU+qJzGTqPTGJY+gDchj6cRyLRdmIdt4rcsE4twj+zPfrNiWuVi38wijHzShL++Z9atQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@typescript-eslint/typescript-estree@8.70.1': + resolution: + { + integrity: sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.70.1': + resolution: + { + integrity: sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.70.1': + resolution: + { + integrity: sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + '@vitejs/plugin-vue@6.0.9': + resolution: + { + integrity: sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 + + '@vitest/mocker@5.0.1': + resolution: + { + integrity: sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==, + } + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.1': + resolution: + { + integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==, + } + + '@volar/language-core@2.4.28': + resolution: + { + integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==, + } + + '@volar/source-map@2.4.28': + resolution: + { + integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==, + } + + '@volar/typescript@2.4.28': + resolution: + { + integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==, + } + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/compiler-core@3.5.43': + resolution: + { + integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==, + } + + '@vue/compiler-dom@3.5.43': + resolution: + { + integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==, + } + + '@vue/compiler-sfc@3.5.43': + resolution: + { + integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==, + } + + '@vue/compiler-ssr@3.5.43': + resolution: + { + integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==, + } + + '@vue/devtools-api@8.2.1': + resolution: + { + integrity: sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==, + } + + '@vue/devtools-kit@8.2.1': + resolution: + { + integrity: sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==, + } + + '@vue/devtools-shared@8.2.1': + resolution: + { + integrity: sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==, + } + + '@vue/language-core@3.3.11': + resolution: + { + integrity: sha512-QJmpliwAVpC/OxubIByPAhNzsQPRc8/gxlN2qnVzVfIMjMDz/9RnXRFoetjz5yEgXVXyp4LqhXq3V53PjmNzFw==, + } + + '@vue/reactivity@3.5.43': + resolution: + { + integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==, + } + + '@vue/runtime-core@3.5.43': + resolution: + { + integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==, + } + + '@vue/runtime-dom@3.5.43': + resolution: + { + integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==, + } + + '@vue/server-renderer@3.5.43': + resolution: + { + integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==, + } + + '@vue/shared@3.5.43': + resolution: + { + integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==, + } + + '@vue/test-utils@2.5.1': + resolution: + { + integrity: sha512-V/4a99odJ7hapr0bRt35XxljGk8hefq31gSOnlKTcEq2fUeoj2qb+FcpJYfQu4qfYhW9Ug+O2+CZ3QmZ7gXDzA==, + } + peerDependencies: + '@vue/compiler-dom': 3.x + '@vue/server-renderer': 3.x + vue: 3.x + peerDependenciesMeta: + '@vue/server-renderer': + optional: true + + '@vueuse/core@14.4.0': + resolution: + { + integrity: sha512-X4WHz1HlCzCBoYXesUkifzzWBAcZgXG8Fi5iNPQg/epdzOB3gu8Fawj3hvuwYR1nGcXGnvxwYYcUC/71++svtQ==, + } + peerDependencies: + vue: ^3.5.0 + + '@vueuse/core@15.0.0': + resolution: + { + integrity: sha512-Zv67h5mrk7YHDJsRObx+DXaCBz6RKBUpxqLesJ+3xZNPG6yuQs7ja7kg3veFrrouYLWAqydQ7CwGEbLVxB43Ng==, + } + engines: { node: '>=22' } + peerDependencies: + vue: ^3.5.0 + + '@vueuse/metadata@14.4.0': + resolution: + { + integrity: sha512-swx/255R6JyHZFJhx845iz5CRWDZdCfvkZOpACWc5+c5WHcG24mv8gUT1WIdFQaHt6dq79rvILd9QnCWiyVm9g==, + } + + '@vueuse/metadata@15.0.0': + resolution: + { + integrity: sha512-sfWE5LADAKJ0KnjtGliMdSN8Ij/L7uP6rXIZlgPloAI4zvvefbw6H5Lt7KJA6RqQOYgVqWF5Guj50HHMIzygBg==, + } + engines: { node: '>=22' } + + '@vueuse/shared@14.4.0': + resolution: + { + integrity: sha512-JRgY90Sz8DDtPMsaDflvPMp9xYk69JZAmbuDvAquUVXKr2gEjqtzGNTTthLfckH0BzBqvnu31gb4a8TGLRe79g==, + } + peerDependencies: + vue: ^3.5.0 + + '@vueuse/shared@15.0.0': + resolution: + { + integrity: sha512-vbNXhGrbV53xPOMSyOzMHQRvhMgZf7oNDw2RChUetVxwTIEkInQ1joZ9JbT0G3z2n2AJNjvmCWeM9ySYrnvogA==, + } + engines: { node: '>=22' } + peerDependencies: + vue: ^3.5.0 + + abbrev@5.0.0: + resolution: + { + integrity: sha512-/XrFJgzQQQHpti1raDJC6m4ws6aNktmjBlhk8Fdlk7LwCEuDoieEJJY9OFHjfiFJFFRM2tK+Ky/IsfbbmlMu1w==, + } + engines: { node: ^22.22.2 || ^24.15.0 || >=26.0.0 } + + acorn-jsx@5.3.2: + resolution: + { + integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==, + } + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: + { + integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==, + } + engines: { node: '>=0.4.0' } + hasBin: true + + ajv@6.15.0: + resolution: + { + integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==, + } + + alien-signals@3.2.1: + resolution: + { + integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==, + } + + aria-hidden@1.2.6: + resolution: + { + integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==, + } + engines: { node: '>=10' } + + assertion-error@2.0.1: + resolution: + { + integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==, + } + engines: { node: '>=12' } + + balanced-match@4.0.4: + resolution: + { + integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==, + } + engines: { node: 18 || 20 || >=22 } + + birpc@2.9.0: + resolution: + { + integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==, + } + + boolbase@1.0.0: + resolution: + { + integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==, + } + + brace-expansion@5.0.12: + resolution: + { + integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==, + } + engines: { node: 20 || >=22 } + + buffer-image-size@0.6.4: + resolution: + { + integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==, + } + engines: { node: '>=4.0' } + + cacheable@2.5.0: + resolution: + { + integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==, + } + + chai@6.3.0: + resolution: + { + integrity: sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==, + } + engines: { node: '>=18' } + + class-variance-authority@0.7.1: + resolution: + { + integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==, + } + + clsx@2.1.1: + resolution: + { + integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==, + } + engines: { node: '>=6' } + + commander@14.0.3: + resolution: + { + integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==, + } + engines: { node: '>=20' } + + config-chain@1.1.13: + resolution: + { + integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==, + } + + cross-spawn@7.0.6: + resolution: + { + integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==, + } + engines: { node: '>= 8' } + + cssesc@3.0.0: + resolution: + { + integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==, + } + engines: { node: '>=4' } + hasBin: true + + csstype@3.2.3: + resolution: + { + integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==, + } + + debug@4.4.3: + resolution: + { + integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==, + } + engines: { node: '>=6.0' } + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: + { + integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==, + } + + defu@6.1.7: + resolution: + { + integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==, + } + + detect-libc@2.1.2: + resolution: + { + integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==, + } + engines: { node: '>=8' } + + editorconfig@3.0.2: + resolution: + { + integrity: sha512-T0ix8GhtxyKVfUFEcvdNDt3YGqlwkFHbD4/5bgFUDgFmxhI/cSRAeJ87/Sz//Cq8Eam6JX/e23RkoFO71P7aAA==, + } + engines: { node: '>=20' } + hasBin: true + + enhanced-resolve@5.26.0: + resolution: + { + integrity: sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw==, + } + engines: { node: '>=10.13.0' } + + entities@7.0.1: + resolution: + { + integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==, + } + engines: { node: '>=0.12' } + + es-module-lexer@2.3.2: + resolution: + { + integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==, + } + + escape-string-regexp@4.0.0: + resolution: + { + integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==, + } + engines: { node: '>=10' } + + eslint-plugin-vue@10.11.1: + resolution: + { + integrity: sha512-pasVXhl7wUmIvhujoqs/m0mJ9r59QW1IR9XXYKGR0FETzSM+Hhlf4qKIYgWiDDP+9YKv4YfbwskD7LqIdVcgQw==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + '@stylistic/eslint-plugin': ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 + '@typescript-eslint/parser': ^7.0.0 || ^8.0.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + vue-eslint-parser: ^10.3.0 + peerDependenciesMeta: + '@stylistic/eslint-plugin': + optional: true + '@typescript-eslint/parser': + optional: true + + eslint-scope@9.1.2: + resolution: + { + integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==, + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + eslint-visitor-keys@3.4.3: + resolution: + { + integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==, + } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } + + eslint-visitor-keys@5.0.1: + resolution: + { + integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==, + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + eslint@10.11.0: + resolution: + { + integrity: sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==, + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: + { + integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==, + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + esquery@1.7.0: + resolution: + { + integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==, + } + engines: { node: '>=0.10' } + + esrecurse@4.3.0: + resolution: + { + integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==, + } + engines: { node: '>=4.0' } + + estraverse@5.3.0: + resolution: + { + integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==, + } + engines: { node: '>=4.0' } + + estree-walker@2.0.2: + resolution: + { + integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==, + } + + estree-walker@3.0.3: + resolution: + { + integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==, + } + + esutils@2.0.3: + resolution: + { + integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==, + } + engines: { node: '>=0.10.0' } + + expect-type@1.4.0: + resolution: + { + integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==, + } + engines: { node: '>=12.0.0' } + + fast-deep-equal@3.1.3: + resolution: + { + integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==, + } + + fast-json-stable-stringify@2.1.0: + resolution: + { + integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==, + } + + fast-levenshtein@2.0.6: + resolution: + { + integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==, + } + + fdir@6.5.0: + resolution: + { + integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==, + } + engines: { node: '>=12.0.0' } + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@11.1.5: + resolution: + { + integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==, + } + + find-up@5.0.0: + resolution: + { + integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==, + } + engines: { node: '>=10' } + + flat-cache@6.1.23: + resolution: + { + integrity: sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==, + } + + flatted@3.4.4: + resolution: + { + integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==, + } + + fsevents@2.3.3: + resolution: + { + integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, + } + engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } + os: [darwin] + + glob-parent@6.0.2: + resolution: + { + integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==, + } + engines: { node: '>=10.13.0' } + + glob@13.0.6: + resolution: + { + integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==, + } + engines: { node: 18 || 20 || >=22 } + + globals@17.12.0: + resolution: + { + integrity: sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==, + } + engines: { node: '>=18' } + + graceful-fs@4.2.11: + resolution: + { + integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==, + } + + happy-dom@20.14.5: + resolution: + { + integrity: sha512-x/RzkpWO40bTjIoT30iQtt64FLLmH/iRcUCN2X//bLx7H3ifkdfPXyqsro/OYtqzIAhiLMMA7mmiOR9C3NOKjQ==, + } + engines: { node: '>=20.0.0' } + + hashery@1.5.1: + resolution: + { + integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==, + } + engines: { node: '>=20' } + + hookable@5.5.3: + resolution: + { + integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==, + } + + hookified@1.15.1: + resolution: + { + integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==, + } + + hookified@2.2.0: + resolution: + { + integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==, + } + + ignore@5.3.2: + resolution: + { + integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==, + } + engines: { node: '>= 4' } + + ignore@7.0.11: + resolution: + { + integrity: sha512-YChdK5txDjwGUvgR7oCJcLGkwi3LDh8Zx8tS7ndYciLEg/oOCL26VUBSUEtgS7EjiybcBqFxh5j0rAdDX7/bbg==, + } + engines: { node: '>= 4' } + + imurmurhash@0.1.4: + resolution: + { + integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==, + } + engines: { node: '>=0.8.19' } + + ini@1.3.8: + resolution: + { + integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==, + } + + is-extglob@2.1.1: + resolution: + { + integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==, + } + engines: { node: '>=0.10.0' } + + is-glob@4.0.3: + resolution: + { + integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==, + } + engines: { node: '>=0.10.0' } + + isexe@2.0.0: + resolution: + { + integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, + } + + jiti@2.7.0: + resolution: + { + integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==, + } + hasBin: true + + js-beautify@2.0.3: + resolution: + { + integrity: sha512-cyFbh3tkPhknnTD/0bLf0T0yy2ZIbqL05mttzbt4y1Zfr7NxqXQZ62dkBLKs3oHH/lpjmDRAnciJiSUyOy8XwQ==, + } + engines: { node: '>=14' } + hasBin: true + + js-cookie@3.0.8: + resolution: + { + integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==, + } + + json-schema-traverse@0.4.1: + resolution: + { + integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==, + } + + json-stable-stringify-without-jsonify@1.0.1: + resolution: + { + integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==, + } + + keyv@5.6.0: + resolution: + { + integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==, + } + + levn@0.4.1: + resolution: + { + integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==, + } + engines: { node: '>= 0.8.0' } + + lightningcss-android-arm64@1.32.0: + resolution: + { + integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [android] + + lightningcss-android-arm64@1.33.0: + resolution: + { + integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: + { + integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-arm64@1.33.0: + resolution: + { + integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: + { + integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: + { + integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: + { + integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [freebsd] + + lightningcss-freebsd-x64@1.33.0: + resolution: + { + integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: + { + integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm] + os: [linux] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: + { + integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: + { + integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: + { + integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: + { + integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: + { + integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: + { + integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: + { + integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: + { + integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-musl@1.33.0: + resolution: + { + integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: + { + integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [win32] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: + { + integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==, + } + engines: { node: '>= 12.0.0' } + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: + { + integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: + { + integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==, + } + engines: { node: '>= 12.0.0' } + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: + { + integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==, + } + engines: { node: '>= 12.0.0' } + + lightningcss@1.33.0: + resolution: + { + integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==, + } + engines: { node: '>= 12.0.0' } + + locate-path@6.0.0: + resolution: + { + integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==, + } + engines: { node: '>=10' } + + lru-cache@11.5.3: + resolution: + { + integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==, + } + engines: { node: 20 || >=22 } + + magic-string@0.30.21: + resolution: + { + integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==, + } + + magic-string@1.4.2: + resolution: + { + integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==, + } + + minimatch@10.2.6: + resolution: + { + integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==, + } + engines: { node: 18 || 20 || >=22 } + + minipass@7.1.3: + resolution: + { + integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==, + } + engines: { node: '>=16 || 14 >=14.17' } + + ms@2.1.3: + resolution: + { + integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, + } + + muggle-string@0.4.1: + resolution: + { + integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==, + } + + nanoid@3.3.19: + resolution: + { + integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==, + } + engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } + hasBin: true + + natural-compare@1.4.0: + resolution: + { + integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==, + } + + nopt@10.0.1: + resolution: + { + integrity: sha512-df3sBr/6ax9hSGuC3CspvLlbnX8cP5L5nZwXF8cGN8l0zSWR6BvzmQ6jPUKjvo6+/xdpkNvEcucBNUdBeeV13g==, + } + engines: { node: ^22.22.2 || ^24.15.0 || >=26.0.0 } + hasBin: true + + nostics@1.3.0: + resolution: + { + integrity: sha512-wzRsrJwNlFbqIRQt8aCeL2WE8USip/uuvGSOZV1D02CxX8ABTMbw8IywKjH7yWHETMXbWJI4OBpihg9rmz2Vyg==, + } + + nth-check@2.1.1: + resolution: + { + integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==, + } + + obug@2.2.1: + resolution: + { + integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==, + } + engines: { node: '>=12.20.0' } + + ohash@2.0.12: + resolution: + { + integrity: sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==, + } + + optionator@0.9.4: + resolution: + { + integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==, + } + engines: { node: '>= 0.8.0' } + + p-limit@3.1.0: + resolution: + { + integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==, + } + engines: { node: '>=10' } + + p-locate@5.0.0: + resolution: + { + integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==, + } + engines: { node: '>=10' } + + path-browserify@1.0.1: + resolution: + { + integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==, + } + + path-exists@4.0.0: + resolution: + { + integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==, + } + engines: { node: '>=8' } + + path-key@3.1.1: + resolution: + { + integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==, + } + engines: { node: '>=8' } + + path-scurry@2.0.2: + resolution: + { + integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==, + } + engines: { node: 18 || 20 || >=22 } + + perfect-debounce@2.1.0: + resolution: + { + integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==, + } + + picocolors@1.1.1: + resolution: + { + integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, + } + + picomatch@4.0.7: + resolution: + { + integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==, + } + engines: { node: '>=12' } + + pinia@4.0.3: + resolution: + { + integrity: sha512-XMQqpvjgG7LMqVhhFUzKLT4KEbsYbfZZ0CZU9PgdFm1O2VKBmIkykL8+SfNhOaT7sR0wT6BEgKT0YNDYWgmVRA==, + } + peerDependencies: + '@vue/devtools-api': ^8.1.5 + typescript: '>=5.6.0' + vue: ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + postcss-selector-parser@7.1.6: + resolution: + { + integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==, + } + engines: { node: '>=4' } + + postcss@8.5.28: + resolution: + { + integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==, + } + engines: { node: ^10 || ^12 || >=14 } + + prelude-ls@1.2.1: + resolution: + { + integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==, + } + engines: { node: '>= 0.8.0' } + + prettier@3.9.9: + resolution: + { + integrity: sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==, + } + engines: { node: '>=14' } + hasBin: true + + proto-list@1.2.4: + resolution: + { + integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==, + } + + punycode@2.3.1: + resolution: + { + integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==, + } + engines: { node: '>=6' } + + qified@0.10.1: + resolution: + { + integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==, + } + engines: { node: '>=20' } + + reka-ui@2.10.5: + resolution: + { + integrity: sha512-/8o0y9BThE0hcHKFg+3770tWcJh3qKdOEFxLOPb9HIajXeFoH/J530cZH62jUJunbqwBgpSBL9v7nB8LJ5pDXA==, + } + peerDependencies: + vue: '>= 3.4.0' + + rolldown@1.2.12: + resolution: + { + integrity: sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + hasBin: true + + semver@7.8.5: + resolution: + { + integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==, + } + engines: { node: '>=10' } + hasBin: true + + shebang-command@2.0.0: + resolution: + { + integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==, + } + engines: { node: '>=8' } + + shebang-regex@3.0.0: + resolution: + { + integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==, + } + engines: { node: '>=8' } + + siginfo@2.0.0: + resolution: + { + integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==, + } + + source-map-js@1.2.2: + resolution: + { + integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==, + } + engines: { node: '>=0.10.0' } + + stackback@0.0.2: + resolution: + { + integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==, + } + + std-env@4.3.0: + resolution: + { + integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==, + } + + tailwind-merge@3.7.0: + resolution: + { + integrity: sha512-XPPUyAc+cvspz3lHTcR/QgPfW2A0lv/xQNIjX3HGhLR+Nq2lHaLq5MtTesHn8GUr3W3DguT2KT5x3NVgRtYwmA==, + } + + tailwindcss@4.3.3: + resolution: + { + integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==, + } + + tapable@2.3.3: + resolution: + { + integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==, + } + engines: { node: '>=6' } + + tinybench@6.1.4: + resolution: + { + integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==, + } + engines: { node: '>=20.0.0' } + + tinyexec@1.3.0: + resolution: + { + integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==, + } + engines: { node: '>=18' } + + tinyglobby@0.2.17: + resolution: + { + integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==, + } + engines: { node: '>=12.0.0' } + + ts-api-utils@2.5.0: + resolution: + { + integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==, + } + engines: { node: '>=18.12' } + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.8.1: + resolution: + { + integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, + } + + type-check@0.4.0: + resolution: + { + integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==, + } + engines: { node: '>= 0.8.0' } + + typescript-eslint@8.70.1: + resolution: + { + integrity: sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: + { + integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==, + } + engines: { node: '>=14.17' } + hasBin: true + + undici-types@8.9.0: + resolution: + { + integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==, + } + + uri-js@4.4.1: + resolution: + { + integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==, + } + + util-deprecate@1.0.2: + resolution: + { + integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==, + } + + vite@8.3.1: + resolution: + { + integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.1: + resolution: + { + integrity: sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==, + } + engines: { node: ^22.12.0 || ^24.0.0 || >=26.0.0 } + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.1 + '@vitest/browser-preview': 5.0.1 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.1 + '@vitest/coverage-v8': 5.0.1 + '@vitest/ui': 5.0.1 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-uri@3.2.0: + resolution: + { + integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==, + } + + vue-component-type-helpers@3.3.11: + resolution: + { + integrity: sha512-LwcxzeliO9fkQcpJG0PoX8X5kmAhKmH9wkpDLxNabwzkQ9Zeib2YVHwFV4pcWmMLfXVfjr/dSV+DaJ3cIPgSNA==, + } + + vue-demi@0.14.10: + resolution: + { + integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==, + } + engines: { node: '>=12' } + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue-eslint-parser@10.4.1: + resolution: + { + integrity: sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + + vue-tsc@3.3.11: + resolution: + { + integrity: sha512-gOb0B9rtU2+f1dszwPqSH5kAieIF9ReeLhD3kSRNHv5WZZUQz/JdVXW0RTdqhNTMlQkqKzrTTviqKr/4FYZraQ==, + } + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.43: + resolution: + { + integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==, + } + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + whatwg-mimetype@3.0.0: + resolution: + { + integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==, + } + engines: { node: '>=12' } + + which@2.0.2: + resolution: + { + integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==, + } + engines: { node: '>= 8' } + hasBin: true + + why-is-node-running@2.3.0: + resolution: + { + integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==, + } + engines: { node: '>=8' } + hasBin: true + + word-wrap@1.2.5: + resolution: + { + integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==, + } + engines: { node: '>=0.10.0' } + + ws@8.22.0: + resolution: + { + integrity: sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==, + } + engines: { node: '>=10.0.0' } + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: + { + integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==, + } + engines: { node: '>=18' } + + yocto-queue@0.1.0: + resolution: + { + integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==, + } + engines: { node: '>=10' } + +snapshots: + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@cacheable/memory@2.2.0': + dependencies: + '@cacheable/utils': 2.5.0 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/utils@2.5.0': + dependencies: + hashery: 1.5.1 + keyv: 5.6.0 + + '@eslint-community/eslint-utils@4.10.1(eslint@10.11.0(jiti@2.7.0))': + dependencies: + eslint: 10.11.0(jiti@2.7.0) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.6 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.7.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.11.0(jiti@2.7.0))': + optionalDependencies: + eslint: 10.11.0(jiti@2.7.0) + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.3': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/utils@0.2.12': {} + + '@floating-ui/vue@1.1.11(vue@3.5.43(typescript@5.9.3))': + dependencies: + '@floating-ui/dom': 1.8.0 + '@floating-ui/utils': 0.2.12 + vue-demi: 0.14.10(vue@3.5.43(typescript@5.9.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@fontsource-variable/jetbrains-mono@5.3.0': {} + + '@fontsource-variable/schibsted-grotesk@5.3.0': {} + + '@fortawesome/fontawesome-common-types@7.3.1': {} + + '@fortawesome/fontawesome-svg-core@7.3.1': + dependencies: + '@fortawesome/fontawesome-common-types': 7.3.1 + + '@fortawesome/free-solid-svg-icons@7.3.1': + dependencies: + '@fortawesome/fontawesome-common-types': 7.3.1 + + '@fortawesome/vue-fontawesome@3.3.3(@fortawesome/fontawesome-svg-core@7.3.1)(vue@3.5.43(typescript@5.9.3))': + dependencies: + '@fortawesome/fontawesome-svg-core': 7.3.1 + vue: 3.5.43(typescript@5.9.3) + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@internationalized/date@3.12.4': + dependencies: + '@swc/helpers': 0.5.23 + + '@internationalized/number@3.6.8': + dependencies: + '@swc/helpers': 0.5.23 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.1 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + + '@lucide/vue@1.49.0(vue@3.5.43(typescript@5.9.3))': + dependencies: + vue: 3.5.43(typescript@5.9.3) + + '@one-ini/wasm@0.2.1': {} + + '@oxc-project/types@0.152.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.12': + optional: true + + '@rolldown/binding-android-arm64@1.2.12': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.12': + optional: true + + '@rolldown/binding-darwin-x64@1.2.12': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.12': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.12': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.12': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.12': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.12': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.12': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.12': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.12': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.26.0 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.2 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@8.3.1(@types/node@26.6.2)(jiti@2.7.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.3.1(@types/node@26.6.2)(jiti@2.7.0) + + '@tanstack/virtual-core@3.17.11': {} + + '@tanstack/vue-virtual@3.13.39(vue@3.5.43(typescript@5.9.3))': + dependencies: + '@tanstack/virtual-core': 3.17.11 + vue: 3.5.43(typescript@5.9.3) + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@26.6.2': + dependencies: + undici-types: 8.9.0 + + '@types/node@26.6.3': + dependencies: + undici-types: 8.9.0 + + '@types/web-bluetooth@0.0.21': {} + + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.2': + dependencies: + '@types/node': 26.6.3 + + '@typescript-eslint/eslint-plugin@8.70.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.70.1 + '@typescript-eslint/type-utils': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.70.1 + eslint: 10.11.0(jiti@2.7.0) + ignore: 7.0.11 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.70.1 + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/typescript-estree': 8.70.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.70.1 + debug: 4.4.3 + eslint: 10.11.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.70.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.71.0(typescript@5.9.3) + '@typescript-eslint/types': 8.71.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.70.1': + dependencies: + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/visitor-keys': 8.70.1 + + '@typescript-eslint/tsconfig-utils@8.70.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/tsconfig-utils@8.71.0(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/typescript-estree': 8.70.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3) + debug: 4.4.3 + eslint: 10.11.0(jiti@2.7.0) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.70.1': {} + + '@typescript-eslint/types@8.71.0': {} + + '@typescript-eslint/typescript-estree@8.70.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.70.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@5.9.3) + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/visitor-keys': 8.70.1 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.70.1 + '@typescript-eslint/types': 8.70.1 + '@typescript-eslint/typescript-estree': 8.70.1(typescript@5.9.3) + eslint: 10.11.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.70.1': + dependencies: + '@typescript-eslint/types': 8.70.1 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-vue@6.0.9(vite@8.3.1(@types/node@26.6.2)(jiti@2.7.0))(vue@3.5.43(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.1(@types/node@26.6.2)(jiti@2.7.0) + vue: 3.5.43(typescript@5.9.3) + + '@vitest/mocker@5.0.1(vite@8.3.1(@types/node@26.6.2)(jiti@2.7.0))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.1 + estree-walker: 3.0.3 + magic-string: 1.4.2 + optionalDependencies: + vite: 8.3.1(@types/node@26.6.2)(jiti@2.7.0) + + '@vitest/spy@5.0.1': {} + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.2.0 + optionalDependencies: + typescript: 5.9.3 + + '@vue/compiler-core@3.5.43': + dependencies: + '@babel/parser': 7.29.9 + '@vue/shared': 3.5.43 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.2 + + '@vue/compiler-dom@3.5.43': + dependencies: + '@vue/compiler-core': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/compiler-sfc@3.5.43': + dependencies: + '@babel/parser': 7.29.9 + '@vue/compiler-core': 3.5.43 + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-ssr': 3.5.43 + '@vue/shared': 3.5.43 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.28 + source-map-js: 1.2.2 + + '@vue/compiler-ssr@3.5.43': + dependencies: + '@vue/compiler-dom': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/devtools-api@8.2.1': + dependencies: + '@vue/devtools-kit': 8.2.1 + + '@vue/devtools-kit@8.2.1': + dependencies: + '@vue/devtools-shared': 8.2.1 + birpc: 2.9.0 + hookable: 5.5.3 + perfect-debounce: 2.1.0 + + '@vue/devtools-shared@8.2.1': {} + + '@vue/language-core@3.3.11': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.43 + '@vue/shared': 3.5.43 + alien-signals: 3.2.1 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + picomatch: 4.0.7 + + '@vue/reactivity@3.5.43': + dependencies: + '@vue/shared': 3.5.43 + + '@vue/runtime-core@3.5.43': + dependencies: + '@vue/reactivity': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/runtime-dom@3.5.43': + dependencies: + '@vue/reactivity': 3.5.43 + '@vue/runtime-core': 3.5.43 + '@vue/shared': 3.5.43 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.43': + dependencies: + '@vue/compiler-ssr': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/shared@3.5.43': {} + + '@vue/test-utils@2.5.1(@vue/compiler-dom@3.5.43)(@vue/server-renderer@3.5.43)(vue@3.5.43(typescript@5.9.3))': + dependencies: + '@vue/compiler-dom': 3.5.43 + js-beautify: 2.0.3 + vue: 3.5.43(typescript@5.9.3) + vue-component-type-helpers: 3.3.11 + optionalDependencies: + '@vue/server-renderer': 3.5.43 + + '@vueuse/core@14.4.0(vue@3.5.43(typescript@5.9.3))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.4.0 + '@vueuse/shared': 14.4.0(vue@3.5.43(typescript@5.9.3)) + vue: 3.5.43(typescript@5.9.3) + + '@vueuse/core@15.0.0(vue@3.5.43(typescript@5.9.3))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 15.0.0 + '@vueuse/shared': 15.0.0(vue@3.5.43(typescript@5.9.3)) + vue: 3.5.43(typescript@5.9.3) + + '@vueuse/metadata@14.4.0': {} + + '@vueuse/metadata@15.0.0': {} + + '@vueuse/shared@14.4.0(vue@3.5.43(typescript@5.9.3))': + dependencies: + vue: 3.5.43(typescript@5.9.3) + + '@vueuse/shared@15.0.0(vue@3.5.43(typescript@5.9.3))': + dependencies: + vue: 3.5.43(typescript@5.9.3) + + abbrev@5.0.0: {} + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + alien-signals@3.2.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + assertion-error@2.0.1: {} + + balanced-match@4.0.4: {} + + birpc@2.9.0: {} + + boolbase@1.0.0: {} + + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + + buffer-image-size@0.6.4: + dependencies: + '@types/node': 26.6.3 + + cacheable@2.5.0: + dependencies: + '@cacheable/memory': 2.2.0 + '@cacheable/utils': 2.5.0 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.10.1 + + chai@6.3.0: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clsx@2.1.1: {} + + commander@14.0.3: {} + + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + defu@6.1.7: {} + + detect-libc@2.1.2: {} + + editorconfig@3.0.2: + dependencies: + '@one-ini/wasm': 0.2.1 + commander: 14.0.3 + minimatch: 10.2.6 + semver: 7.8.5 + + enhanced-resolve@5.26.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + entities@7.0.1: {} + + es-module-lexer@2.3.2: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-vue@10.11.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.11.0(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@10.11.0(jiti@2.7.0))): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) + eslint: 10.11.0(jiti@2.7.0) + natural-compare: 1.4.0 + nth-check: 2.1.1 + postcss-selector-parser: 7.1.6 + semver: 7.8.5 + vue-eslint-parser: 10.4.1(eslint@10.11.0(jiti@2.7.0)) + xml-name-validator: 5.0.0 + optionalDependencies: + '@typescript-eslint/parser': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3) + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.11.0(jiti@2.7.0): + dependencies: + '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.7.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.3 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 11.1.5 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.6 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.7.0 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.4.0: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + file-entry-cache@11.1.5: + dependencies: + flat-cache: 6.1.23 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@6.1.23: + dependencies: + cacheable: 2.5.0 + flatted: 3.4.4 + hookified: 1.15.1 + + flatted@3.4.4: {} + + fsevents@2.3.3: + optional: true + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + + globals@17.12.0: {} + + graceful-fs@4.2.11: {} + + happy-dom@20.14.5: + dependencies: + '@types/node': 26.6.3 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.2 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.22.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + hashery@1.5.1: + dependencies: + hookified: 1.15.1 + + hookable@5.5.3: {} + + hookified@1.15.1: {} + + hookified@2.2.0: {} + + ignore@5.3.2: {} + + ignore@7.0.11: {} + + imurmurhash@0.1.4: {} + + ini@1.3.8: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + jiti@2.7.0: {} + + js-beautify@2.0.3: + dependencies: + config-chain: 1.1.13 + editorconfig: 3.0.2 + glob: 13.0.6 + js-cookie: 3.0.8 + nopt: 10.0.1 + + js-cookie@3.0.8: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lru-cache@11.5.3: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magic-string@1.4.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + + minipass@7.1.3: {} + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.19: {} + + natural-compare@1.4.0: {} + + nopt@10.0.1: + dependencies: + abbrev: 5.0.0 + + nostics@1.3.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + obug@2.2.1: {} + + ohash@2.0.12: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + path-browserify@1.0.1: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.3 + minipass: 7.1.3 + + perfect-debounce@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 8.2.1 + nostics: 1.3.0 + vue: 3.5.43(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + postcss-selector-parser@7.1.6: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + prelude-ls@1.2.1: {} + + prettier@3.9.9: {} + + proto-list@1.2.4: {} + + punycode@2.3.1: {} + + qified@0.10.1: + dependencies: + hookified: 2.2.0 + + reka-ui@2.10.5(vue@3.5.43(typescript@5.9.3)): + dependencies: + '@floating-ui/dom': 1.8.0 + '@floating-ui/vue': 1.1.11(vue@3.5.43(typescript@5.9.3)) + '@internationalized/date': 3.12.4 + '@internationalized/number': 3.6.8 + '@tanstack/vue-virtual': 3.13.39(vue@3.5.43(typescript@5.9.3)) + '@vueuse/core': 14.4.0(vue@3.5.43(typescript@5.9.3)) + '@vueuse/shared': 14.4.0(vue@3.5.43(typescript@5.9.3)) + aria-hidden: 1.2.6 + defu: 6.1.7 + ohash: 2.0.12 + vue: 3.5.43(typescript@5.9.3) + transitivePeerDependencies: + - '@vue/composition-api' + + rolldown@1.2.12: + dependencies: + '@oxc-project/types': 0.152.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.12 + '@rolldown/binding-android-arm64': 1.2.12 + '@rolldown/binding-darwin-arm64': 1.2.12 + '@rolldown/binding-darwin-x64': 1.2.12 + '@rolldown/binding-freebsd-x64': 1.2.12 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.12 + '@rolldown/binding-linux-arm64-gnu': 1.2.12 + '@rolldown/binding-linux-arm64-musl': 1.2.12 + '@rolldown/binding-linux-ppc64-gnu': 1.2.12 + '@rolldown/binding-linux-s390x-gnu': 1.2.12 + '@rolldown/binding-linux-x64-gnu': 1.2.12 + '@rolldown/binding-linux-x64-musl': 1.2.12 + '@rolldown/binding-openharmony-arm64': 1.2.12 + '@rolldown/binding-win32-arm64-msvc': 1.2.12 + '@rolldown/binding-win32-x64-msvc': 1.2.12 + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.2: {} + + stackback@0.0.2: {} + + std-env@4.3.0: {} + + tailwind-merge@3.7.0: {} + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + tinybench@6.1.4: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.70.1(@typescript-eslint/parser@8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3))(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/parser': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.70.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.1(eslint@10.11.0(jiti@2.7.0))(typescript@5.9.3) + eslint: 10.11.0(jiti@2.7.0) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + undici-types@8.9.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + util-deprecate@1.0.2: {} + + vite@8.3.1(@types/node@26.6.2)(jiti@2.7.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.12 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.6.2 + fsevents: 2.3.3 + jiti: 2.7.0 + + vitest@5.0.1(@types/node@26.6.2)(happy-dom@20.14.5)(vite@8.3.1(@types/node@26.6.2)(jiti@2.7.0)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.1(vite@8.3.1(@types/node@26.6.2)(jiti@2.7.0)) + chai: 6.3.0 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.2 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.3.0 + tinybench: 6.1.4 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + vite: 8.3.1(@types/node@26.6.2)(jiti@2.7.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.6.2 + happy-dom: 20.14.5 + transitivePeerDependencies: + - msw + + vscode-uri@3.2.0: {} + + vue-component-type-helpers@3.3.11: {} + + vue-demi@0.14.10(vue@3.5.43(typescript@5.9.3)): + dependencies: + vue: 3.5.43(typescript@5.9.3) + + vue-eslint-parser@10.4.1(eslint@10.11.0(jiti@2.7.0)): + dependencies: + debug: 4.4.3 + eslint: 10.11.0(jiti@2.7.0) + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + semver: 7.8.5 + transitivePeerDependencies: + - supports-color + + vue-tsc@3.3.11(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.28(typescript@5.9.3) + '@vue/language-core': 3.3.11 + typescript: 5.9.3 + + vue@3.5.43(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-sfc': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/server-renderer': 3.5.43 + '@vue/shared': 3.5.43 + optionalDependencies: + typescript: 5.9.3 + + whatwg-mimetype@3.0.0: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + ws@8.22.0: {} + + xml-name-validator@5.0.0: {} + + yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..6923ab1 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +allowBuilds: + vue-demi: false diff --git a/scripts/clean-dist.mjs b/scripts/clean-dist.mjs new file mode 100644 index 0000000..5aaa712 --- /dev/null +++ b/scripts/clean-dist.mjs @@ -0,0 +1,8 @@ +// Empties dist/ but keeps .gitkeep, which lets `go build` embed dist/ before the first UI build. +import { mkdirSync, readdirSync, rmSync, writeFileSync } from 'node:fs' + +mkdirSync('dist', { recursive: true }) +for (const name of readdirSync('dist')) { + if (name !== '.gitkeep') rmSync(`dist/${name}`, { recursive: true, force: true }) +} +writeFileSync('dist/.gitkeep', '') diff --git a/semver.yaml b/semver.yaml new file mode 100644 index 0000000..ad6c8d5 --- /dev/null +++ b/semver.yaml @@ -0,0 +1,18 @@ +# Read by the shared release workflow (lukaszraczylo/semver-generator). +version: 1 + +force: + existing: true + strict: false + +# Fuzzy matching counts stray words such as "majority" as a major bump. +# Only a `Semver-Major:` trailer line may start a major release. +matching: regex + +wording: + patch: + - '(?m)^(fix|perf|refactor|revert|build|chore|ci|docs|style|test)(\([^)]*\))?!?:' + minor: + - '(?m)^feat(\([^)]*\))?!?:' + major: + - '(?m)^Semver-Major:' diff --git a/src/App.test.ts b/src/App.test.ts new file mode 100644 index 0000000..4de9b45 --- /dev/null +++ b/src/App.test.ts @@ -0,0 +1,91 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { createPinia } from 'pinia' +import { afterEach, describe, expect, it, vi } from 'vitest' +import App from './App.vue' +import { mixedState, repo, state } from '@/test/fixtures' +import type { TrackerState } from '@/lib/types' + +function mountWith(body: TrackerState | Error) { + vi.stubGlobal( + 'fetch', + vi.fn(() => + body instanceof Error + ? Promise.reject(body) + : Promise.resolve(new Response(JSON.stringify(body), { status: 200 })), + ), + ) + return mount(App, { global: { plugins: [createPinia()] } }) +} + +describe('App', () => { + afterEach(() => vi.unstubAllGlobals()) + + it('shows the overview, only the problems and every project', async () => { + const w = mountWith(mixedState()) + await flushPromises() + expect(w.get('[data-test=headline]').text()).toContain('2') + expect(w.findAll('[data-test=segment]')).toHaveLength(2) + expect(w.findAll('[data-test=repo-block]')).toHaveLength(1) + expect(w.findAll('[data-test=row]')).toHaveLength(2) + expect(w.findAll('[data-test=project]')).toHaveLength(2) + }) + + it('shows an all-clear state without repo blocks', async () => { + const w = mountWith(state({ repos: [repo()] })) + await flushPromises() + expect(w.get('[data-test=headline]').text()).toBe('All clear') + expect(w.find('[data-test=repo-block]').exists()).toBe(false) + expect(w.findAll('[data-test=project]')).toHaveLength(1) + }) + + it('focuses one project from the strip and clears the focus', async () => { + const w = mountWith(mixedState()) + await flushPromises() + await w.findAll('[data-test=segment]')[1].trigger('click') + expect(w.find('[data-test=repo-block]').exists()).toBe(false) + expect(w.find('[data-test=empty]').exists()).toBe(true) + await w.get('[data-test=clear-all]').trigger('click') + expect(w.findAll('[data-test=repo-block]')).toHaveLength(1) + }) + + it('narrows the list as you type and restores it when cleared', async () => { + const w = mountWith(mixedState()) + await flushPromises() + await w.get('[data-test=search]').setValue('zzz-no-match') + expect(w.find('[data-test=repo-block]').exists()).toBe(false) + expect(w.find('[data-test=no-projects]').exists()).toBe(true) + await w.get('[data-test=clear-all]').trigger('click') + expect(w.findAll('[data-test=repo-block]')).toHaveLength(1) + expect(w.findAll('[data-test=project]')).toHaveLength(2) + }) + + it('applies a kind filter from the toolbar', async () => { + const w = mountWith(mixedState()) + await flushPromises() + await w.get('[data-test=kind-workflow]').trigger('click') + expect(w.findAll('[data-test=row]')).toHaveLength(1) + expect(w.get('[data-test=sev-all]').text()).toBe('All 1') + }) + + it('waits while the server has not finished its first refresh', async () => { + const w = mountWith(state({ loaded: false })) + await flushPromises() + expect(w.find('[data-test=waiting]').exists()).toBe(true) + expect(w.find('[data-test=headline]').exists()).toBe(false) + }) + + it('surfaces a connection error', async () => { + const w = mountWith(new Error('offline')) + await flushPromises() + expect(w.get('[data-test=error]').text()).toContain('offline') + }) + + it('offers a rescan only when the server allows it', async () => { + const on = mountWith(state({ manualRefresh: true, repos: [repo()] })) + await flushPromises() + expect(on.find('[data-test=rescan]').exists()).toBe(true) + const off = mountWith(state({ manualRefresh: false, repos: [repo()] })) + await flushPromises() + expect(off.find('[data-test=rescan]').exists()).toBe(false) + }) +}) diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..f488f5b --- /dev/null +++ b/src/App.vue @@ -0,0 +1,134 @@ + + + diff --git a/src/components/AgeMeter.vue b/src/components/AgeMeter.vue new file mode 100644 index 0000000..ec967d8 --- /dev/null +++ b/src/components/AgeMeter.vue @@ -0,0 +1,23 @@ + + + diff --git a/src/components/AppHeader.vue b/src/components/AppHeader.vue new file mode 100644 index 0000000..89bca0a --- /dev/null +++ b/src/components/AppHeader.vue @@ -0,0 +1,59 @@ + + + diff --git a/src/components/AttentionList.vue b/src/components/AttentionList.vue new file mode 100644 index 0000000..17a0fa4 --- /dev/null +++ b/src/components/AttentionList.vue @@ -0,0 +1,56 @@ + + + diff --git a/src/components/AttentionRow.vue b/src/components/AttentionRow.vue new file mode 100644 index 0000000..7fcd081 --- /dev/null +++ b/src/components/AttentionRow.vue @@ -0,0 +1,45 @@ + + + diff --git a/src/components/FilterBar.vue b/src/components/FilterBar.vue new file mode 100644 index 0000000..13e081d --- /dev/null +++ b/src/components/FilterBar.vue @@ -0,0 +1,150 @@ + + + diff --git a/src/components/FleetOverview.vue b/src/components/FleetOverview.vue new file mode 100644 index 0000000..2322bf7 --- /dev/null +++ b/src/components/FleetOverview.vue @@ -0,0 +1,122 @@ + + + diff --git a/src/components/FleetStrip.vue b/src/components/FleetStrip.vue new file mode 100644 index 0000000..64f736e --- /dev/null +++ b/src/components/FleetStrip.vue @@ -0,0 +1,52 @@ + + + diff --git a/src/components/ProjectList.vue b/src/components/ProjectList.vue new file mode 100644 index 0000000..49cf191 --- /dev/null +++ b/src/components/ProjectList.vue @@ -0,0 +1,91 @@ + + + diff --git a/src/components/RepoGroup.vue b/src/components/RepoGroup.vue new file mode 100644 index 0000000..f7607db --- /dev/null +++ b/src/components/RepoGroup.vue @@ -0,0 +1,41 @@ + + + diff --git a/src/components/components.test.ts b/src/components/components.test.ts new file mode 100644 index 0000000..7335105 --- /dev/null +++ b/src/components/components.test.ts @@ -0,0 +1,306 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import AgeMeter from './AgeMeter.vue' +import AppHeader from './AppHeader.vue' +import AttentionList from './AttentionList.vue' +import FilterBar from './FilterBar.vue' +import AttentionRow from './AttentionRow.vue' +import FleetOverview from './FleetOverview.vue' +import ProjectList from './ProjectList.vue' +import RepoGroup from './RepoGroup.vue' +import { activeFilterCount, emptyFilters, facetCounts, type Filters } from '@/lib/filters' +import { buildBlocks, computeFacts, countByRepo } from '@/stores/tracker' +import { hoursAgo, mixedState, NOW, repo } from '@/test/fixtures' +import type { Row } from '@/lib/types' + +const nowMs = Date.parse(NOW) +const CRIT_SECONDS = 604800 +const text = (w: { text(): string }) => w.text().replace(/\s+/g, ' ') + +describe('AppHeader', () => { + const base = { + lastRefresh: '2026-10-02T11:00:00Z', + nowMs, + intervalSeconds: 21600, + loaded: true, + scanning: false, + } + + it('shows when data was fetched and when the next refresh is due', () => { + const w = mount(AppHeader, { props: { ...base, manualRefresh: false } }) + expect(text(w.get('[data-test=updated]'))).toBe('Scanned 1h ago · next in 5h') + expect(w.find('[data-test=rescan]').exists()).toBe(false) + }) + + it('shows Loading before the first refresh finishes', () => { + const w = mount(AppHeader, { props: { ...base, loaded: false, manualRefresh: false } }) + expect(w.text()).toContain('Loading') + }) + + it('emits rescan and shows progress while a scan runs', async () => { + const w = mount(AppHeader, { props: { ...base, manualRefresh: true } }) + expect(w.get('[data-test=rescan]').text()).toBe('Rescan repositories') + await w.get('[data-test=rescan]').trigger('click') + expect(w.emitted('rescan')).toHaveLength(1) + const busy = mount(AppHeader, { props: { ...base, manualRefresh: true, scanning: true } }) + expect(busy.get('[data-test=rescan]').attributes('disabled')).toBeDefined() + expect(busy.get('[data-test=rescan]').text()).toBe('Rescanning…') + }) +}) + +describe('FleetOverview', () => { + const s = mixedState() + const props = { + crit: 2, + warn: 1, + blockCount: 1, + repos: s.repos, + counts: countByRepo(s), + facts: computeFacts(s), + focused: null, + matching: new Set(['o/r', 'o/fine']), + } + + it('leads with the critical and warning counts and the scope', () => { + const w = mount(FleetOverview, { props }) + const headline = w.get('[data-test=headline]').text() + expect(headline).toMatch(/2\s*critical/) + expect(headline).toMatch(/1\s*warning/) + expect(w.get('[data-test=scope]').text()).toBe('1 of 2 projects need attention') + }) + + it('lists the headline facts, dimming zeros', () => { + const w = mount(FleetOverview, { props }) + const facts = Object.fromEntries( + w.findAll('[data-test=facts] > div').map((d) => [d.get('dt').text(), d.get('dd').text()]), + ) + expect(facts).toMatchObject({ + 'Workflows failing': '1', + 'Stale issues': '2', + 'Data problems': '0', + }) + }) + + it('says all clear when nothing is wrong', () => { + const w = mount(FleetOverview, { props: { ...props, crit: 0, warn: 0, blockCount: 0 } }) + expect(w.get('[data-test=headline]').text()).toBe('All clear') + expect(w.get('[data-test=scope]').text()).toBe('All 2 projects are healthy') + }) + + it('draws one segment per project, labelled for screen readers, and emits on click', async () => { + const w = mount(FleetOverview, { props: { ...props, focused: 'o/r' } }) + const segments = w.findAll('[data-test=segment]') + expect(segments).toHaveLength(2) + expect(segments[0].attributes('aria-label')).toBe('o/r: 2 critical · 1 warning') + expect(segments[0].attributes('aria-pressed')).toBe('true') + expect(segments[1].attributes('aria-label')).toBe('o/fine: healthy') + await segments[1].trigger('click') + expect(w.emitted('select')?.[0]).toEqual(['o/fine']) + }) +}) + +describe('AgeMeter', () => { + it('fills proportionally and always shows at least one segment', () => { + const filled = (ratio: number) => + mount(AgeMeter, { props: { ratio, severity: 'crit' } }) + .findAll('span span') + .filter((s) => s.classes().includes('bg-crit')).length + expect(filled(0)).toBe(1) + expect(filled(0.5)).toBe(5) + expect(filled(3)).toBe(10) + }) + + it('uses the warning colour for warnings', () => { + const w = mount(AgeMeter, { props: { ratio: 0.2, severity: 'warn' } }) + expect(w.find('.bg-warn').exists()).toBe(true) + expect(w.find('.bg-crit').exists()).toBe(false) + }) +}) + +describe('AttentionRow', () => { + const row = (over: Partial = {}): Row => ({ + kind: 'pr_waiting', + subject: '#5 bold title', + detail: 'open 9d', + url: 'https://github.com/o/r/pull/5', + since: hoursAgo(216), + severity: 'crit', + ...over, + }) + + it('links the subject, shows the age and escapes markup', () => { + const w = mount(AttentionRow, { props: { row: row(), nowMs, critSeconds: CRIT_SECONDS } }) + expect(w.get('a').attributes('href')).toBe('https://github.com/o/r/pull/5') + expect(w.text()).toContain('bold') + expect(w.find('b').exists()).toBe(false) + expect(w.text()).toContain('9d') + expect(w.text()).toContain('Critical') + }) + + it('shows the age meter only for waiting pull requests', () => { + const pr = mount(AttentionRow, { props: { row: row(), nowMs, critSeconds: CRIT_SECONDS } }) + expect(pr.find('[aria-label$="critical age"]').exists()).toBe(true) + const wf = mount(AttentionRow, { + props: { row: row({ kind: 'workflow_failed' }), nowMs, critSeconds: CRIT_SECONDS }, + }) + expect(wf.find('[aria-label$="critical age"]').exists()).toBe(false) + }) + + it('omits the age for an unknown timestamp', () => { + const w = mount(AttentionRow, { + props: { + row: row({ since: '0001-01-01T00:00:00Z', kind: 'issues_stale' }), + nowMs, + critSeconds: CRIT_SECONDS, + }, + }) + expect(w.find('.font-mono').text()).toBe('') + }) +}) + +describe('RepoGroup', () => { + const block = buildBlocks(mixedState())[0] + + it('shows the repo, its version link and an item count', () => { + const w = mount(RepoGroup, { props: { block, nowMs, critSeconds: CRIT_SECONDS } }) + expect(w.text()).toContain('o/') + expect(w.get('a[href="https://github.com/o/r"]').text()).toContain('r') + expect(w.get('a[href="https://github.com/o/r/releases/tag/v1.0.0"]').text()).toBe('v1.0.0') + expect(w.text()).toContain('2 items') + expect(w.findAll('[data-test=row]')).toHaveLength(2) + }) + + it('omits the version badge without a release and uses the singular for one item', () => { + const one = { repo: repo({ release: null }), rows: [block.rows[0]] } + const w = mount(RepoGroup, { props: { block: one, nowMs, critSeconds: CRIT_SECONDS } }) + expect(w.find('a[href*="/releases/"]').exists()).toBe(false) + expect(w.text()).toContain('1 item') + }) +}) + +describe('AttentionList', () => { + const blocks = buildBlocks(mixedState()) + const props = { + blocks, + nowMs, + critSeconds: CRIT_SECONDS, + facets: facetCounts(blocks, emptyFilters(), ['o']), + owners: ['o'], + active: 0, + filters: emptyFilters(), + } + + it('renders one group per repo', () => { + const w = mount(AttentionList, { props }) + expect(w.findAll('[data-test=repo-block]')).toHaveLength(1) + }) + + it('bubbles filter changes up', async () => { + const w = mount(AttentionList, { props }) + await w.get('[data-test=sev-crit]').trigger('click') + const [emitted] = w.emitted('update:filters')!.at(-1) as [Filters] + expect(emitted.severity).toBe('crit') + }) + + it('forwards clear', async () => { + const w = mount(AttentionList, { props: { ...props, active: 2 } }) + await w.get('[data-test=clear-all]').trigger('click') + expect(w.emitted('clear')).toHaveLength(1) + }) + + it('explains an empty selection', () => { + const w = mount(AttentionList, { props: { ...props, blocks: [] } }) + expect(w.get('[data-test=empty]').text()).toContain('Nothing matches') + }) +}) + +describe('FilterBar', () => { + const blocks = buildBlocks(mixedState()) + const base = (filters: Filters = emptyFilters(), owners = ['o', 'p']) => ({ + facets: facetCounts(blocks, filters, owners), + owners, + active: activeFilterCount(filters), + filters, + }) + + it('shows live counts in every facet', () => { + const w = mount(FilterBar, { props: base() }) + expect(w.get('[data-test=sev-all]').text()).toBe('All 2') + expect(w.get('[data-test=sev-crit]').text()).toBe('Critical 2') + expect(w.get('[data-test=sev-warn]').text()).toBe('Warnings 0') + expect(w.get('[data-test=kind-workflow]').text()).toContain('1') + expect(w.get('[data-test=kind-pr]').text()).toContain('1') + expect(w.get('[data-test=owner-o]').text()).toContain('2') + }) + + it('disables facet values that would match nothing, but not selected ones', () => { + const w = mount(FilterBar, { props: base() }) + expect(w.get('[data-test=kind-issue]').attributes('disabled')).toBeDefined() + expect(w.get('[data-test=owner-p]').attributes('disabled')).toBeDefined() + expect(w.get('[data-test=kind-pr]').attributes('disabled')).toBeUndefined() + const sel = mount(FilterBar, { props: base({ ...emptyFilters(), kinds: ['issue'] }) }) + expect(sel.get('[data-test=kind-issue]').attributes('disabled')).toBeUndefined() + }) + + it('narrows the other facets when one is selected', () => { + const w = mount(FilterBar, { props: base({ ...emptyFilters(), kinds: ['workflow'] }) }) + expect(w.get('[data-test=sev-all]').text()).toBe('All 1') + expect(w.get('[data-test=kind-pr]').text()).toContain('1') + }) + + it('hides the owner filter when there is only one owner', () => { + const w = mount(FilterBar, { props: base(emptyFilters(), ['o']) }) + expect(w.find('[data-test=owner-o]').exists()).toBe(false) + }) + + it('emits typed search text and clears it with Escape', async () => { + const w = mount(FilterBar, { props: base() }) + const input = w.get('[data-test=search]') + await input.setValue('grpc') + expect((w.emitted('update:filters')!.at(-1) as [Filters])[0].query).toBe('grpc') + const typed = mount(FilterBar, { props: base({ ...emptyFilters(), query: 'grpc' }) }) + await typed.get('[data-test=search]').trigger('keydown', { key: 'Escape' }) + expect((typed.emitted('update:filters')!.at(-1) as [Filters])[0].query).toBe('') + }) + + it('offers to clear active filters with a count', async () => { + const f = { ...emptyFilters(), query: 'x', kinds: ['pr' as const] } + const w = mount(FilterBar, { props: base(f) }) + expect(w.get('[data-test=clear-all]').text()).toBe('Clear 2 filters') + await w.get('[data-test=clear-all]').trigger('click') + expect(w.emitted('clear')).toHaveLength(1) + expect(mount(FilterBar, { props: base() }).find('[data-test=clear-all]').exists()).toBe(false) + }) +}) + +describe('ProjectList', () => { + const repos = mixedState().repos + + it('lists every project with release, counts and links', () => { + const w = mount(ProjectList, { props: { repos, total: 2, focused: null, nowMs } }) + const rows = w.findAll('[data-test=project]') + expect(rows).toHaveLength(2) + expect(rows[0].text()).toContain('v1.0.0') + expect(rows[0].text()).toContain('released 3d ago') + expect(rows[0].text()).toContain('1/3 failing') + expect(rows[0].get('a[href="https://github.com/o/r/pulls"]').text()).toContain('2') + expect(rows[0].get('a[href="https://github.com/o/r/issues"]').text()).toContain('4') + expect(rows[1].get('a[href="https://github.com/o/fine/releases"]').text()).toBe('no release') + }) + + it('shows how many projects match and a hint when none do', () => { + const some = mount(ProjectList, { + props: { repos: repos.slice(0, 1), total: 2, focused: null, nowMs }, + }) + expect(some.get('[data-test=project-count]').text()).toBe('1 of 2') + const none = mount(ProjectList, { props: { repos: [], total: 2, focused: null, nowMs } }) + expect(none.find('[data-test=no-projects]').exists()).toBe(true) + }) + + it('emits the repo when its name is chosen and highlights the focused one', async () => { + const w = mount(ProjectList, { props: { repos, total: 2, focused: 'o/fine', nowMs } }) + await w.findAll('[data-test=project] button')[0].trigger('click') + expect(w.emitted('select')?.[0]).toEqual(['o/r']) + expect(w.findAll('[data-test=project]')[1].classes()).toContain('bg-accent') + }) +}) diff --git a/src/components/ui/badge/Badge.vue b/src/components/ui/badge/Badge.vue new file mode 100644 index 0000000..3ba2fb7 --- /dev/null +++ b/src/components/ui/badge/Badge.vue @@ -0,0 +1,28 @@ + + + diff --git a/src/components/ui/badge/index.ts b/src/components/ui/badge/index.ts new file mode 100644 index 0000000..2f1a8b9 --- /dev/null +++ b/src/components/ui/badge/index.ts @@ -0,0 +1,24 @@ +import type { VariantProps } from 'class-variance-authority' +import { cva } from 'class-variance-authority' + +export { default as Badge } from './Badge.vue' + +export const badgeVariants = cva( + 'inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden', + { + variants: { + variant: { + default: 'border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90', + secondary: + 'border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90', + destructive: + 'border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) +export type BadgeVariants = VariantProps diff --git a/src/components/ui/button/Button.vue b/src/components/ui/button/Button.vue new file mode 100644 index 0000000..1b6a512 --- /dev/null +++ b/src/components/ui/button/Button.vue @@ -0,0 +1,31 @@ + + + diff --git a/src/components/ui/button/index.ts b/src/components/ui/button/index.ts new file mode 100644 index 0000000..9b70021 --- /dev/null +++ b/src/components/ui/button/index.ts @@ -0,0 +1,37 @@ +import type { VariantProps } from 'class-variance-authority' +import { cva } from 'class-variance-authority' + +export { default as Button } from './Button.vue' + +export const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground hover:bg-primary/90', + destructive: + 'bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', + outline: + 'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', + secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', + ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', + link: 'text-primary underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2 has-[>svg]:px-3', + xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', + lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', + icon: 'size-9', + 'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", + 'icon-sm': 'size-8', + 'icon-lg': 'size-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +) +export type ButtonVariants = VariantProps diff --git a/src/components/ui/input/Input.vue b/src/components/ui/input/Input.vue new file mode 100644 index 0000000..f7bfe46 --- /dev/null +++ b/src/components/ui/input/Input.vue @@ -0,0 +1,35 @@ + + + diff --git a/src/components/ui/input/index.ts b/src/components/ui/input/index.ts new file mode 100644 index 0000000..a691dd6 --- /dev/null +++ b/src/components/ui/input/index.ts @@ -0,0 +1 @@ +export { default as Input } from './Input.vue' diff --git a/src/components/ui/progress/Progress.vue b/src/components/ui/progress/Progress.vue new file mode 100644 index 0000000..6b2eb36 --- /dev/null +++ b/src/components/ui/progress/Progress.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/components/ui/progress/index.ts b/src/components/ui/progress/index.ts new file mode 100644 index 0000000..eace989 --- /dev/null +++ b/src/components/ui/progress/index.ts @@ -0,0 +1 @@ +export { default as Progress } from './Progress.vue' diff --git a/src/components/ui/separator/Separator.vue b/src/components/ui/separator/Separator.vue new file mode 100644 index 0000000..e9665ca --- /dev/null +++ b/src/components/ui/separator/Separator.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/components/ui/separator/index.ts b/src/components/ui/separator/index.ts new file mode 100644 index 0000000..2287bcb --- /dev/null +++ b/src/components/ui/separator/index.ts @@ -0,0 +1 @@ +export { default as Separator } from './Separator.vue' diff --git a/src/components/ui/table/Table.vue b/src/components/ui/table/Table.vue new file mode 100644 index 0000000..669b0d4 --- /dev/null +++ b/src/components/ui/table/Table.vue @@ -0,0 +1,16 @@ + + + diff --git a/src/components/ui/table/TableBody.vue b/src/components/ui/table/TableBody.vue new file mode 100644 index 0000000..95d50be --- /dev/null +++ b/src/components/ui/table/TableBody.vue @@ -0,0 +1,14 @@ + + + diff --git a/src/components/ui/table/TableCaption.vue b/src/components/ui/table/TableCaption.vue new file mode 100644 index 0000000..2c2fdf7 --- /dev/null +++ b/src/components/ui/table/TableCaption.vue @@ -0,0 +1,14 @@ + + + diff --git a/src/components/ui/table/TableCell.vue b/src/components/ui/table/TableCell.vue new file mode 100644 index 0000000..8c2468e --- /dev/null +++ b/src/components/ui/table/TableCell.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/ui/table/TableEmpty.vue b/src/components/ui/table/TableEmpty.vue new file mode 100644 index 0000000..575f949 --- /dev/null +++ b/src/components/ui/table/TableEmpty.vue @@ -0,0 +1,32 @@ + + + diff --git a/src/components/ui/table/TableFooter.vue b/src/components/ui/table/TableFooter.vue new file mode 100644 index 0000000..8055c55 --- /dev/null +++ b/src/components/ui/table/TableFooter.vue @@ -0,0 +1,17 @@ + + + diff --git a/src/components/ui/table/TableHead.vue b/src/components/ui/table/TableHead.vue new file mode 100644 index 0000000..fef04ea --- /dev/null +++ b/src/components/ui/table/TableHead.vue @@ -0,0 +1,22 @@ + + + diff --git a/src/components/ui/table/TableHeader.vue b/src/components/ui/table/TableHeader.vue new file mode 100644 index 0000000..ff81dbb --- /dev/null +++ b/src/components/ui/table/TableHeader.vue @@ -0,0 +1,14 @@ + + + diff --git a/src/components/ui/table/TableRow.vue b/src/components/ui/table/TableRow.vue new file mode 100644 index 0000000..7f0cf5a --- /dev/null +++ b/src/components/ui/table/TableRow.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/components/ui/table/index.ts b/src/components/ui/table/index.ts new file mode 100644 index 0000000..2b4ce39 --- /dev/null +++ b/src/components/ui/table/index.ts @@ -0,0 +1,9 @@ +export { default as Table } from './Table.vue' +export { default as TableBody } from './TableBody.vue' +export { default as TableCaption } from './TableCaption.vue' +export { default as TableCell } from './TableCell.vue' +export { default as TableEmpty } from './TableEmpty.vue' +export { default as TableFooter } from './TableFooter.vue' +export { default as TableHead } from './TableHead.vue' +export { default as TableHeader } from './TableHeader.vue' +export { default as TableRow } from './TableRow.vue' diff --git a/src/components/ui/toggle-group/ToggleGroup.vue b/src/components/ui/toggle-group/ToggleGroup.vue new file mode 100644 index 0000000..c15e45e --- /dev/null +++ b/src/components/ui/toggle-group/ToggleGroup.vue @@ -0,0 +1,59 @@ + + + diff --git a/src/components/ui/toggle-group/ToggleGroupItem.vue b/src/components/ui/toggle-group/ToggleGroupItem.vue new file mode 100644 index 0000000..1a4520d --- /dev/null +++ b/src/components/ui/toggle-group/ToggleGroupItem.vue @@ -0,0 +1,51 @@ + + + diff --git a/src/components/ui/toggle-group/index.ts b/src/components/ui/toggle-group/index.ts new file mode 100644 index 0000000..9f89ad7 --- /dev/null +++ b/src/components/ui/toggle-group/index.ts @@ -0,0 +1,2 @@ +export { default as ToggleGroup } from './ToggleGroup.vue' +export { default as ToggleGroupItem } from './ToggleGroupItem.vue' diff --git a/src/components/ui/toggle/Toggle.vue b/src/components/ui/toggle/Toggle.vue new file mode 100644 index 0000000..deff907 --- /dev/null +++ b/src/components/ui/toggle/Toggle.vue @@ -0,0 +1,40 @@ + + + diff --git a/src/components/ui/toggle/index.ts b/src/components/ui/toggle/index.ts new file mode 100644 index 0000000..b666620 --- /dev/null +++ b/src/components/ui/toggle/index.ts @@ -0,0 +1,28 @@ +import type { VariantProps } from 'class-variance-authority' +import { cva } from 'class-variance-authority' + +export { default as Toggle } from './Toggle.vue' + +export const toggleVariants = cva( + "inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-3 outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", + { + variants: { + variant: { + default: 'bg-transparent', + outline: + 'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground', + }, + size: { + default: 'h-9 px-2 min-w-9', + sm: 'h-8 px-1.5 min-w-8', + lg: 'h-10 px-2.5 min-w-10', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + }, +) + +export type ToggleVariants = VariantProps diff --git a/src/components/ui/tooltip/Tooltip.vue b/src/components/ui/tooltip/Tooltip.vue new file mode 100644 index 0000000..08d23d7 --- /dev/null +++ b/src/components/ui/tooltip/Tooltip.vue @@ -0,0 +1,15 @@ + + + diff --git a/src/components/ui/tooltip/TooltipContent.vue b/src/components/ui/tooltip/TooltipContent.vue new file mode 100644 index 0000000..8436b89 --- /dev/null +++ b/src/components/ui/tooltip/TooltipContent.vue @@ -0,0 +1,44 @@ + + + diff --git a/src/components/ui/tooltip/TooltipProvider.vue b/src/components/ui/tooltip/TooltipProvider.vue new file mode 100644 index 0000000..8cc971f --- /dev/null +++ b/src/components/ui/tooltip/TooltipProvider.vue @@ -0,0 +1,14 @@ + + + diff --git a/src/components/ui/tooltip/TooltipTrigger.vue b/src/components/ui/tooltip/TooltipTrigger.vue new file mode 100644 index 0000000..d290278 --- /dev/null +++ b/src/components/ui/tooltip/TooltipTrigger.vue @@ -0,0 +1,12 @@ + + + diff --git a/src/components/ui/tooltip/index.ts b/src/components/ui/tooltip/index.ts new file mode 100644 index 0000000..5ab9653 --- /dev/null +++ b/src/components/ui/tooltip/index.ts @@ -0,0 +1,4 @@ +export { default as Tooltip } from './Tooltip.vue' +export { default as TooltipContent } from './TooltipContent.vue' +export { default as TooltipProvider } from './TooltipProvider.vue' +export { default as TooltipTrigger } from './TooltipTrigger.vue' diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..d8ef660 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,16 @@ +import type { TrackerState } from './types' + +export async function fetchState(signal?: AbortSignal): Promise { + const res = await fetch('/api/state', { signal, headers: { Accept: 'application/json' } }) + if (!res.ok) throw new Error(`state request failed: HTTP ${res.status}`) + return (await res.json()) as TrackerState +} + +/** Thrown when the server refuses a rescan because one ran recently. */ +export class RescanLimitedError extends Error {} + +export async function requestRefresh(): Promise { + const res = await fetch('/refresh', { method: 'POST' }) + if (res.status === 429) throw new RescanLimitedError('Rescan is limited to once every 5 minutes') + if (!res.ok) throw new Error(`rescan request failed: HTTP ${res.status}`) +} diff --git a/src/lib/filters.test.ts b/src/lib/filters.test.ts new file mode 100644 index 0000000..92063e6 --- /dev/null +++ b/src/lib/filters.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest' +import { + activeFilterCount, + applyFilters, + emptyFilters, + facetCounts, + filtersFromSearch, + filtersToSearch, + kindGroup, + matchesQuery, + repoMatches, + type Filters, +} from './filters' +import { buildBlocks } from '@/stores/tracker' +import { alert, repo, state } from '@/test/fixtures' + +const fleet = () => + buildBlocks( + state({ + repos: [ + repo({ fullName: 'a/one', health: 'crit' }), + repo({ fullName: 'b/two', health: 'warn', release: null }), + ], + alerts: [ + alert({ + repo: 'a/one', + kind: 'workflow_failed', + severity: 'crit', + subject: 'CI', + url: '1', + }), + alert({ + repo: 'a/one', + kind: 'pr_waiting', + severity: 'warn', + subject: '#3 Bump grpc', + url: '2', + }), + alert({ + repo: 'b/two', + kind: 'issues_stale', + severity: 'warn', + subject: '4 stale issues', + url: '3', + }), + alert({ + repo: 'b/two', + kind: 'partial_data', + severity: 'warn', + subject: 'Incomplete data', + url: '4', + }), + ], + }), + ) + +const withFilters = (over: Partial): Filters => ({ ...emptyFilters(), ...over }) + +describe('kindGroup', () => { + it.each([ + ['workflow_failed', 'workflow'], + ['workflow_stuck', 'workflow'], + ['pr_waiting', 'pr'], + ['pr_checks_failing', 'pr'], + ['issues_stale', 'issue'], + ['refresh_failed', 'data'], + ['data_stale', 'data'], + ['partial_data', 'data'], + ['something_new', 'data'], + ])('%s -> %s', (kind, group) => expect(kindGroup(kind)).toBe(group)) +}) + +describe('matchesQuery', () => { + it('requires every term, ignores case and accepts an empty query', () => { + expect(matchesQuery('', 'anything')).toBe(true) + expect(matchesQuery(' ', 'anything')).toBe(true) + expect(matchesQuery('bump GRPC', 'o/r', '#3 Bump google.golang.org/grpc')).toBe(true) + expect(matchesQuery('bump nothing', '#3 Bump grpc')).toBe(false) + }) +}) + +describe('applyFilters', () => { + it('returns everything for empty filters', () => { + expect(applyFilters(fleet(), emptyFilters()).flatMap((b) => b.rows)).toHaveLength(4) + }) + + it('combines severity, kind, owner and search', () => { + const rows = (f: Filters) => + applyFilters(fleet(), f).flatMap((b) => b.rows.map((r) => r.subject)) + expect(rows(withFilters({ severity: 'crit' }))).toEqual(['CI']) + expect(rows(withFilters({ kinds: ['pr', 'issue'] }))).toEqual([ + '#3 Bump grpc', + '4 stale issues', + ]) + expect(rows(withFilters({ owners: ['b'] }))).toEqual(['4 stale issues', 'Incomplete data']) + expect(rows(withFilters({ query: 'grpc' }))).toEqual(['#3 Bump grpc']) + expect(rows(withFilters({ query: 'a/one', kinds: ['pr'] }))).toEqual(['#3 Bump grpc']) + expect(rows(withFilters({ repo: 'b/two', severity: 'crit' }))).toEqual([]) + }) + + it('drops blocks left without rows', () => { + expect( + applyFilters(fleet(), withFilters({ kinds: ['workflow'] })).map((b) => b.repo.fullName), + ).toEqual(['a/one']) + }) + + it('also searches the release tag', () => { + expect( + applyFilters(fleet(), withFilters({ query: 'v1.0.0' })).map((b) => b.repo.fullName), + ).toEqual(['a/one']) + }) +}) + +describe('facetCounts', () => { + it('counts each facet with the others applied, but not itself', () => { + const f = withFilters({ kinds: ['pr'] }) + const c = facetCounts(fleet(), f, ['a', 'b']) + expect(c.kinds).toEqual({ workflow: 1, pr: 1, issue: 1, data: 1 }) + expect(c.severity).toEqual({ all: 1, crit: 0, warn: 1 }) + expect(c.owners).toEqual({ a: 1, b: 0 }) + }) + + it('counts everything for empty filters', () => { + const c = facetCounts(fleet(), emptyFilters(), ['a', 'b']) + expect(c.severity).toEqual({ all: 4, crit: 1, warn: 3 }) + expect(c.owners).toEqual({ a: 2, b: 2 }) + }) +}) + +describe('repoMatches', () => { + it('uses the query and owner filters only', () => { + const r = repo({ fullName: 'a/one', description: 'Kubernetes mirror' }) + expect(repoMatches(r, withFilters({ query: 'mirror' }))).toBe(true) + expect(repoMatches(r, withFilters({ query: 'nope' }))).toBe(false) + expect(repoMatches(r, withFilters({ owners: ['b'] }))).toBe(false) + expect(repoMatches(r, withFilters({ severity: 'crit', kinds: ['pr'] }))).toBe(true) + }) +}) + +describe('activeFilterCount', () => { + it('counts each active filter value', () => { + expect(activeFilterCount(emptyFilters())).toBe(0) + expect( + activeFilterCount( + withFilters({ + query: ' x ', + severity: 'warn', + kinds: ['pr', 'issue'], + owners: ['a'], + repo: 'a/b', + }), + ), + ).toBe(6) + expect(activeFilterCount(withFilters({ query: ' ' }))).toBe(0) + }) +}) + +describe('URL round trip', () => { + it('omits defaults', () => { + expect(filtersToSearch(emptyFilters())).toBe('') + }) + + it('serialises and restores every filter', () => { + const f = withFilters({ + query: 'bump grpc', + severity: 'crit', + kinds: ['pr', 'data'], + owners: ['a', 'b'], + repo: 'a/one', + }) + expect(filtersFromSearch(filtersToSearch(f))).toEqual(f) + }) + + it('ignores unknown values from a hand-edited URL', () => { + expect(filtersFromSearch('?sev=nope&kind=pr,bogus&owner=,a')).toEqual( + withFilters({ kinds: ['pr'], owners: ['a'] }), + ) + }) +}) diff --git a/src/lib/filters.ts b/src/lib/filters.ts new file mode 100644 index 0000000..88ce95c --- /dev/null +++ b/src/lib/filters.ts @@ -0,0 +1,129 @@ +import type { Block, Repo, Row } from './types' + +export type SeverityFilter = 'all' | 'crit' | 'warn' +export type KindGroup = 'workflow' | 'pr' | 'issue' | 'data' + +export interface Filters { + query: string + severity: SeverityFilter + kinds: KindGroup[] + owners: string[] + repo: string | null +} + +export const KIND_GROUPS: KindGroup[] = ['workflow', 'pr', 'issue', 'data'] + +export const emptyFilters = (): Filters => ({ + query: '', + severity: 'all', + kinds: [], + owners: [], + repo: null, +}) + +export function kindGroup(kind: string): KindGroup { + if (kind.startsWith('workflow_')) return 'workflow' + if (kind.startsWith('pr_')) return 'pr' + if (kind === 'issues_stale') return 'issue' + return 'data' +} + +export const ownerOf = (fullName: string): string => fullName.split('/')[0] + +/** Every whitespace-separated term must appear somewhere in the haystack. */ +export function matchesQuery(query: string, ...fields: string[]): boolean { + const terms = query.toLowerCase().split(/\s+/).filter(Boolean) + if (terms.length === 0) return true + const haystack = fields.join(' ').toLowerCase() + return terms.every((t) => haystack.includes(t)) +} + +/** Facets a row or block can be counted under; the named one is ignored while counting it. */ +export type Facet = 'severity' | 'kinds' | 'owners' + +function rowMatches(repo: Repo, row: Row, f: Filters, skip?: Facet): boolean { + if (f.repo && repo.fullName !== f.repo) return false + if (skip !== 'severity' && f.severity !== 'all' && row.severity !== f.severity) return false + if (skip !== 'kinds' && f.kinds.length > 0 && !f.kinds.includes(kindGroup(row.kind))) return false + if (skip !== 'owners' && f.owners.length > 0 && !f.owners.includes(ownerOf(repo.fullName))) + return false + return matchesQuery(f.query, repo.fullName, repo.release?.tag ?? '', row.subject, row.detail) +} + +export function applyFilters(blocks: Block[], f: Filters, skip?: Facet): Block[] { + const out: Block[] = [] + for (const b of blocks) { + const rows = b.rows.filter((r) => rowMatches(b.repo, r, f, skip)) + if (rows.length > 0) out.push({ repo: b.repo, rows }) + } + return out +} + +export interface Facets { + severity: Record + kinds: Record + owners: Record +} + +/** Counts rows per facet value, each with every other filter applied (faceted search). */ +export function facetCounts(blocks: Block[], f: Filters, owners: string[]): Facets { + const rows = (skip: Facet) => + applyFilters(blocks, f, skip).flatMap((b) => b.rows.map((r) => ({ b, r }))) + const sev = rows('severity') + const kinds = rows('kinds') + const own = rows('owners') + return { + severity: { + all: sev.length, + crit: sev.filter(({ r }) => r.severity === 'crit').length, + warn: sev.filter(({ r }) => r.severity === 'warn').length, + }, + kinds: Object.fromEntries( + KIND_GROUPS.map((g) => [g, kinds.filter(({ r }) => kindGroup(r.kind) === g).length]), + ) as Facets['kinds'], + owners: Object.fromEntries( + owners.map((o) => [o, own.filter(({ b }) => ownerOf(b.repo.fullName) === o).length]), + ), + } +} + +/** Repos that pass the query and owner filters; used for the project list and the strip. */ +export function repoMatches(repo: Repo, f: Filters): boolean { + if (f.owners.length > 0 && !f.owners.includes(ownerOf(repo.fullName))) return false + return matchesQuery(f.query, repo.fullName, repo.release?.tag ?? '', repo.description) +} + +export function activeFilterCount(f: Filters): number { + return ( + (f.query.trim() ? 1 : 0) + + (f.severity !== 'all' ? 1 : 0) + + f.kinds.length + + f.owners.length + + (f.repo ? 1 : 0) + ) +} + +/** Serialises filters for the URL; defaults are omitted so the plain URL stays clean. */ +export function filtersToSearch(f: Filters): string { + const p = new URLSearchParams() + if (f.query.trim()) p.set('q', f.query.trim()) + if (f.severity !== 'all') p.set('sev', f.severity) + if (f.kinds.length) p.set('kind', f.kinds.join(',')) + if (f.owners.length) p.set('owner', f.owners.join(',')) + if (f.repo) p.set('repo', f.repo) + const s = p.toString() + return s ? `?${s}` : '' +} + +export function filtersFromSearch(search: string): Filters { + const p = new URLSearchParams(search) + const sev = p.get('sev') + const list = (key: string) => (p.get(key) ?? '').split(',').filter(Boolean) + return { + query: p.get('q') ?? '', + severity: sev === 'crit' || sev === 'warn' ? sev : 'all', + kinds: list('kind').filter((k): k is KindGroup => (KIND_GROUPS as string[]).includes(k)), + owners: list('owner'), + repo: p.get('repo'), + } +} diff --git a/src/lib/format.test.ts b/src/lib/format.test.ts new file mode 100644 index 0000000..d30db5a --- /dev/null +++ b/src/lib/format.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { ageLabel, ageSince, isZeroTime, secondsSince, shorten } from './format' + +describe('ageLabel', () => { + it.each([ + [-60, '0m'], + [300, '5m'], + [5400, '1h'], + [47 * 3600, '47h'], + [49 * 3600, '2d'], + [10 * 86400, '10d'], + ])('%is -> %s', (seconds, want) => { + expect(ageLabel(seconds)).toBe(want) + }) +}) + +describe('time helpers', () => { + const now = Date.parse('2026-10-02T12:00:00Z') + + it('measures seconds since a timestamp', () => { + expect(secondsSince('2026-10-02T11:00:00Z', now)).toBe(3600) + }) + + it('returns an empty age for unparsable input', () => { + expect(ageSince('not a date', now)).toBe('') + expect(ageSince('2026-10-02T09:00:00Z', now)).toBe('3h') + }) + + it('treats the Go zero time as unknown', () => { + expect(isZeroTime('0001-01-01T00:00:00Z')).toBe(true) + expect(isZeroTime('')).toBe(true) + expect(isZeroTime('2026-10-02T12:00:00Z')).toBe(false) + }) +}) + +describe('shorten', () => { + it('keeps short text and cuts long text by characters, not bytes', () => { + expect(shorten('ok')).toBe('ok') + const long = 'é'.repeat(200) + const out = shorten(long) + expect(Array.from(out)).toHaveLength(90) + expect(out.endsWith('…')).toBe(true) + }) +}) diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..2fee8bb --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,29 @@ +const HOUR = 3600 +const DAY = 24 * HOUR + +/** Compact age such as "3d", "5h" or "12m"; mirrors the Go collector.Age helper. */ +export function ageLabel(seconds: number): string { + if (seconds < 0) return '0m' + if (seconds >= 2 * DAY) return `${Math.floor(seconds / DAY)}d` + if (seconds >= HOUR) return `${Math.floor(seconds / HOUR)}h` + return `${Math.floor(seconds / 60)}m` +} + +/** Seconds between an RFC 3339 timestamp and nowMs. */ +export function secondsSince(iso: string, nowMs: number): number { + return Math.floor((nowMs - Date.parse(iso)) / 1000) +} + +export function ageSince(iso: string, nowMs: number): string { + return Number.isNaN(Date.parse(iso)) ? '' : ageLabel(secondsSince(iso, nowMs)) +} + +/** Go encodes the zero time as year 1; treat it as "unknown". */ +export function isZeroTime(iso: string): boolean { + return !iso || iso.startsWith('0001-') +} + +export function shorten(text: string, max = 90): string { + const chars = Array.from(text) + return chars.length <= max ? text : chars.slice(0, max - 1).join('') + '…' +} diff --git a/src/lib/kinds.ts b/src/lib/kinds.ts new file mode 100644 index 0000000..2d985e5 --- /dev/null +++ b/src/lib/kinds.ts @@ -0,0 +1,27 @@ +import { + faClockRotateLeft, + faCodePullRequest, + faCircleDot, + faGears, + faPlugCircleXmark, + faTriangleExclamation, + type IconDefinition, +} from '@fortawesome/free-solid-svg-icons' + +/** Maps a server alert kind to the icon and group used in the UI. */ +export const KIND_ICON: Record = { + workflow_failed: faGears, + workflow_stuck: faGears, + pr_waiting: faCodePullRequest, + pr_checks_failing: faCodePullRequest, + issues_stale: faCircleDot, + refresh_failed: faPlugCircleXmark, + data_stale: faClockRotateLeft, + partial_data: faTriangleExclamation, +} + +export function iconFor(kind: string): IconDefinition { + return KIND_ICON[kind] ?? faTriangleExclamation +} + +export const DATA_KINDS = ['refresh_failed', 'data_stale', 'partial_data'] diff --git a/src/lib/types.ts b/src/lib/types.ts new file mode 100644 index 0000000..bea5202 --- /dev/null +++ b/src/lib/types.ts @@ -0,0 +1,71 @@ +export type Severity = 'ok' | 'warn' | 'crit' + +export interface Release { + tag: string + url: string + publishedAt: string + prerelease: boolean + fromTag: boolean +} + +export interface Repo { + fullName: string + url: string + releasesUrl: string + description: string + health: Severity + error: string + workflows: { total: number; failing: number; running: number } + prs: { open: number; waiting: number; failingChecks: number; oldestSeconds: number } + issues: { open: number; stale: number } + archived: boolean + up: boolean + refreshedAt: string + release: Release | null +} + +export interface Alert { + repo: string + kind: string + severity: Severity + subject: string + detail: string + url: string + since: string +} + +export interface Thresholds { + prWarnSeconds: number + prCritSeconds: number + issueStaleSeconds: number + workflowStuckSeconds: number + workflowDormantSeconds: number +} + +export interface TrackerState { + now: string + lastRefresh: string + intervalSeconds: number + loaded: boolean + refreshing: boolean + manualRefresh: boolean + counts: { crit: number; warn: number } + thresholds: Thresholds + repos: Repo[] + alerts: Alert[] +} + +/** One line in a repo block: every alert that targets the same URL, merged. */ +export interface Row { + kind: string + subject: string + detail: string + url: string + since: string + severity: Exclude +} + +export interface Block { + repo: Repo + rows: Row[] +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..fed2fe9 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..4daf0d5 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,8 @@ +import '@fontsource-variable/jetbrains-mono' +import '@fontsource-variable/schibsted-grotesk' +import { createPinia } from 'pinia' +import { createApp } from 'vue' +import App from './App.vue' +import './style.css' + +createApp(App).use(createPinia()).mount('#app') diff --git a/src/shims-vue.d.ts b/src/shims-vue.d.ts new file mode 100644 index 0000000..d8c9b85 --- /dev/null +++ b/src/shims-vue.d.ts @@ -0,0 +1,6 @@ +// Lets plain `tsc` resolve .vue imports; vue-tsc reads the real component types. +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent + export default component +} diff --git a/src/stores/tracker.test.ts b/src/stores/tracker.test.ts new file mode 100644 index 0000000..acea24b --- /dev/null +++ b/src/stores/tracker.test.ts @@ -0,0 +1,213 @@ +import { createPinia, setActivePinia } from 'pinia' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { alert, mixedState, repo, state } from '@/test/fixtures' +import { buildBlocks, computeFacts, countByRepo, SCAN_POLL_MS, useTrackerStore } from './tracker' + +function mockFetch(handler: (url: string, init?: RequestInit) => Response | Promise) { + const fn = vi.fn((url: string, init?: RequestInit) => Promise.resolve(handler(url, init))) + vi.stubGlobal('fetch', fn) + return fn +} +const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }) + +describe('buildBlocks', () => { + it('shows only unhealthy repos and merges alerts on the same target', () => { + const blocks = buildBlocks(mixedState()) + expect(blocks.map((b) => b.repo.fullName)).toEqual(['o/r']) + const rows = blocks[0].rows + expect(rows).toHaveLength(2) + const pr = rows.find((r) => r.url.endsWith('/pull/5'))! + expect(pr.severity).toBe('crit') + expect(pr.detail).toBe('open 9d · checks failing: build') + }) + + it('keeps the worst severity when a warning arrives first', () => { + const s = state({ + repos: [repo({ health: 'crit' })], + alerts: [ + alert({ severity: 'warn', url: 'u', detail: 'a' }), + alert({ severity: 'crit', url: 'u', detail: 'b' }), + ], + }) + expect(buildBlocks(s)[0].rows[0].severity).toBe('crit') + }) + + it('orders rows by severity, critical first', () => { + const s = state({ + repos: [repo({ health: 'crit' })], + alerts: [ + alert({ severity: 'warn', url: 'w', subject: 'warn row' }), + alert({ severity: 'crit', url: 'c', subject: 'crit row' }), + ], + }) + expect(buildBlocks(s)[0].rows.map((r) => r.subject)).toEqual(['crit row', 'warn row']) + }) + + it('keeps the repo order from the server and ignores alerts for unknown repos', () => { + const s = state({ + repos: [repo({ fullName: 'o/b' }), repo({ fullName: 'o/a' })], + alerts: [ + alert({ repo: 'o/a', url: '1' }), + alert({ repo: 'o/b', url: '2' }), + alert({ repo: 'o/ghost', url: '3' }), + ], + }) + expect(buildBlocks(s).map((b) => b.repo.fullName)).toEqual(['o/b', 'o/a']) + }) + + it('returns no blocks when everything is healthy', () => { + expect(buildBlocks(state({ repos: [repo()] }))).toEqual([]) + }) +}) + +describe('overview helpers', () => { + it('counts alerts of each severity per repo', () => { + const counts = countByRepo(mixedState()) + expect(counts.get('o/r')).toEqual({ crit: 2, warn: 1 }) + expect(counts.has('o/fine')).toBe(false) + }) + + it('derives headline facts from repos and alert kinds', () => { + const s = mixedState() + s.alerts.push( + alert({ kind: 'partial_data', severity: 'warn', url: 'x', subject: 'Incomplete data' }), + ) + expect(computeFacts(s)).toEqual({ + failingWorkflows: 1, + waitingPrs: 1, + failingChecks: 1, + staleIssues: 2, + dataProblems: 1, + }) + }) +}) + +describe('tracker store', () => { + beforeEach(() => setActivePinia(createPinia())) + afterEach(() => vi.unstubAllGlobals()) + + it('loads state and derives blocks', async () => { + mockFetch(() => json(mixedState())) + const store = useTrackerStore() + await store.load() + expect(store.error).toBe('') + expect(store.state?.repos).toHaveLength(2) + expect(store.blocks).toHaveLength(1) + expect(store.loading).toBe(false) + }) + + it('keeps the last good state and reports the error when a poll fails', async () => { + const fetchMock = mockFetch(() => json(mixedState())) + const store = useTrackerStore() + await store.load() + fetchMock.mockImplementationOnce(() => Promise.resolve(new Response('boom', { status: 500 }))) + await store.load() + expect(store.error).toContain('HTTP 500') + expect(store.state?.repos).toHaveLength(2) + }) + + it('reports network failures', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('offline'))) + const store = useTrackerStore() + await store.load() + expect(store.error).toBe('offline') + expect(store.state).toBeNull() + }) + + it('toggles the focused repo and exposes filtered blocks and facets', async () => { + mockFetch(() => json(mixedState())) + const store = useTrackerStore() + await store.load() + store.toggleRepo('o/fine') + expect(store.filters.repo).toBe('o/fine') + expect(store.visibleBlocks).toEqual([]) + store.toggleRepo('o/fine') + expect(store.filters.repo).toBeNull() + store.filters.severity = 'warn' + expect(store.visibleBlocks).toEqual([]) + expect(store.facets.severity.all).toBe(store.facets.severity.crit + store.facets.severity.warn) + store.filters.severity = 'crit' + expect(store.visibleBlocks).toHaveLength(1) + expect(store.activeFilters).toBe(1) + expect(store.facts?.failingWorkflows).toBe(1) + expect(store.owners).toEqual(['o']) + }) + + it('searches projects by name and clears every filter at once', async () => { + mockFetch(() => json(mixedState())) + const store = useTrackerStore() + await store.load() + store.filters.query = 'fine' + expect(store.visibleRepos.map((r) => r.fullName)).toEqual(['o/fine']) + expect(store.visibleBlocks).toEqual([]) + store.clearFilters() + expect(store.activeFilters).toBe(0) + expect(store.visibleRepos).toHaveLength(2) + }) + + it('keeps a project in the list when the search matches one of its items', async () => { + mockFetch(() => json(mixedState())) + const store = useTrackerStore() + await store.load() + store.filters.query = 'bold' + expect(store.visibleRepos.map((r) => r.fullName)).toEqual(['o/r']) + store.filters.owners = ['other'] + expect(store.visibleRepos).toEqual([]) + }) + + it('anchors the clock to server time so skewed browsers show right ages', async () => { + mockFetch(() => json(state({ now: '2030-01-01T00:00:00Z' }))) + const store = useTrackerStore() + await store.load() + expect(Math.abs(store.nowMs - Date.parse('2030-01-01T00:00:00Z'))).toBeLessThan(2000) + }) + + it('posts a rescan and follows it until new data arrives', async () => { + vi.useFakeTimers() + try { + const calls: string[] = [] + let polls = 0 + mockFetch((url, init) => { + calls.push(`${init?.method ?? 'GET'} ${url}`) + if (url === '/refresh') return new Response(null, { status: 202 }) + polls += 1 + // Poll 1: old data. Poll 2: scan running. Poll 3: finished with a new timestamp. + if (polls === 1) return json(state({ lastRefresh: '2026-10-02T11:00:00Z' })) + if (polls === 2) + return json(state({ lastRefresh: '2026-10-02T11:00:00Z', refreshing: true })) + return json(state({ lastRefresh: '2026-10-02T12:00:00Z' })) + }) + const store = useTrackerStore() + await store.load() + const done = store.rescan() + await vi.advanceTimersByTimeAsync(0) + expect(store.scanning).toBe(true) + await vi.advanceTimersByTimeAsync(SCAN_POLL_MS * 3) + await done + expect(store.scanning).toBe(false) + expect(store.state?.lastRefresh).toBe('2026-10-02T12:00:00Z') + expect(calls[0]).toBe('GET /api/state') + expect(calls[1]).toBe('POST /refresh') + } finally { + vi.useRealTimers() + } + }) + + it('shows a notice, not an error, when a rescan is rate limited', async () => { + mockFetch(() => new Response('slow down', { status: 429 })) + const store = useTrackerStore() + await store.rescan() + expect(store.notice).toContain('once every 5 minutes') + expect(store.error).toBe('') + expect(store.scanning).toBe(false) + }) + + it('reports other rescan failures as errors', async () => { + mockFetch(() => new Response('boom', { status: 500 })) + const store = useTrackerStore() + await store.rescan() + expect(store.error).toContain('HTTP 500') + expect(store.notice).toBe('') + }) +}) diff --git a/src/stores/tracker.ts b/src/stores/tracker.ts new file mode 100644 index 0000000..f887ef2 --- /dev/null +++ b/src/stores/tracker.ts @@ -0,0 +1,199 @@ +import { defineStore } from 'pinia' +import { computed, ref } from 'vue' +import { fetchState, RescanLimitedError, requestRefresh } from '@/lib/api' +import { + activeFilterCount, + applyFilters, + emptyFilters, + facetCounts, + ownerOf, + repoMatches, + type Filters, +} from '@/lib/filters' +import { DATA_KINDS } from '@/lib/kinds' +import type { Block, Row, TrackerState } from '@/lib/types' + +/** While a rescan runs the server takes ~40s, so check back often instead of every minute. */ +export const SCAN_POLL_MS = 2000 +const SCAN_TIMEOUT_MS = 10 * 60 * 1000 + +const SEVERITY_RANK = { ok: 0, warn: 1, crit: 2 } as const + +/** Groups alerts into one row per target URL and one block per unhealthy repo. */ +export function buildBlocks(state: TrackerState): Block[] { + const rows = new Map() + const order = new Map() + for (const alert of state.alerts) { + if (alert.severity === 'ok') continue + const key = `${alert.repo}\n${alert.url}` + const existing = rows.get(key) + if (existing) { + existing.detail += ` · ${alert.detail}` + if (SEVERITY_RANK[alert.severity] > SEVERITY_RANK[existing.severity]) { + existing.severity = alert.severity + } + continue + } + rows.set(key, { + kind: alert.kind, + subject: alert.subject, + detail: alert.detail, + url: alert.url, + since: alert.since, + severity: alert.severity, + }) + order.set(alert.repo, [...(order.get(alert.repo) ?? []), key]) + } + const blocks: Block[] = [] + for (const repo of state.repos) { + const keys = order.get(repo.fullName) + if (!keys) continue + const list = keys.map((k) => rows.get(k)!) + list.sort((a, b) => SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity]) + blocks.push({ repo, rows: list }) + } + return blocks +} + +export interface RepoCounts { + crit: number + warn: number +} + +export interface Facts { + failingWorkflows: number + waitingPrs: number + failingChecks: number + staleIssues: number + dataProblems: number +} + +/** Counts the alerts of each severity per repo, for the fleet strip. */ +export function countByRepo(state: TrackerState): Map { + const out = new Map() + for (const a of state.alerts) { + if (a.severity === 'ok') continue + const c = out.get(a.repo) ?? { crit: 0, warn: 0 } + c[a.severity] += 1 + out.set(a.repo, c) + } + return out +} + +/** Headline numbers for the overview; issue counts come from the repos, not alert lines. */ +export function computeFacts(state: TrackerState): Facts { + const count = (kinds: string[]) => state.alerts.filter((a) => kinds.includes(a.kind)).length + return { + failingWorkflows: state.repos.reduce((n, r) => n + r.workflows.failing, 0), + waitingPrs: count(['pr_waiting']), + failingChecks: state.repos.reduce((n, r) => n + r.prs.failingChecks, 0), + staleIssues: state.repos.reduce((n, r) => n + r.issues.stale, 0), + dataProblems: count(DATA_KINDS), + } +} + +export const useTrackerStore = defineStore('tracker', () => { + const state = ref(null) + const error = ref('') + const loading = ref(false) + const requesting = ref(false) + const notice = ref('') + const skewMs = ref(0) + const tick = ref(Date.now()) + + /** Server time advanced by the local clock, so ages stay right when the clocks differ. */ + const nowMs = computed(() => tick.value + skewMs.value) + /** True from the click until the server finished the new scan. */ + const scanning = computed(() => requesting.value || (state.value?.refreshing ?? false)) + const blocks = computed(() => (state.value ? buildBlocks(state.value) : [])) + const filters = ref(emptyFilters()) + const visibleBlocks = computed(() => applyFilters(blocks.value, filters.value)) + const owners = computed(() => [ + ...new Set((state.value?.repos ?? []).map((r) => ownerOf(r.fullName))), + ]) + const facets = computed(() => facetCounts(blocks.value, filters.value, owners.value)) + /** Repos that match by name or version, plus repos owning an item the search matched. */ + const visibleRepos = computed(() => { + const withItems = new Set(visibleBlocks.value.map((b) => b.repo.fullName)) + return (state.value?.repos ?? []).filter( + (r) => + repoMatches(r, filters.value) || + (filters.value.query.trim() !== '' && withItems.has(r.fullName)), + ) + }) + const activeFilters = computed(() => activeFilterCount(filters.value)) + const repoCounts = computed(() => (state.value ? countByRepo(state.value) : new Map())) + const facts = computed(() => (state.value ? computeFacts(state.value) : null)) + + function toggleRepo(fullName: string): void { + filters.value.repo = filters.value.repo === fullName ? null : fullName + } + + function clearFilters(): void { + filters.value = emptyFilters() + } + + function advanceClock(): void { + tick.value = Date.now() + } + + async function load(): Promise { + loading.value = true + try { + const next = await fetchState() + skewMs.value = Date.parse(next.now) - Date.now() + tick.value = Date.now() + state.value = next + error.value = '' + } catch (e) { + error.value = e instanceof Error ? e.message : String(e) + } finally { + loading.value = false + } + } + + /** Asks the server to rescan every repository, then follows it until the new data lands. */ + async function rescan(): Promise { + const before = state.value?.lastRefresh ?? '' + requesting.value = true + notice.value = '' + try { + await requestRefresh() + error.value = '' + const deadline = Date.now() + SCAN_TIMEOUT_MS + do { + await load() + if (state.value && state.value.lastRefresh !== before && !state.value.refreshing) break + await new Promise((resolve) => setTimeout(resolve, SCAN_POLL_MS)) + } while (Date.now() < deadline) + } catch (e) { + if (e instanceof RescanLimitedError) notice.value = e.message + else error.value = e instanceof Error ? e.message : String(e) + } finally { + requesting.value = false + } + } + + return { + state, + error, + loading, + notice, + scanning, + nowMs, + blocks, + filters, + owners, + facets, + visibleRepos, + activeFilters, + visibleBlocks, + repoCounts, + facts, + toggleRepo, + clearFilters, + load, + rescan, + advanceClock, + } +}) diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..3005af2 --- /dev/null +++ b/src/style.css @@ -0,0 +1,82 @@ +@import 'tailwindcss'; + +@custom-variant dark (@media (prefers-color-scheme: dark)); + +:root { + --background: #f3f1ea; + --surface: #fbfaf6; + --foreground: #1b1a17; + --muted-foreground: #66625a; + --border: #dcd7ca; + --link: #0b5c8a; + --crit: #c4291c; + --warn: #8f5b00; + --ok: #17794d; + --primary: #0b5c8a; + --primary-foreground: #ffffff; + --secondary: #e7e3d9; + --secondary-foreground: #1b1a17; + --muted: #ebe8df; + --accent: #e7e3d9; + --accent-foreground: #1b1a17; + --destructive: #c4291c; + --input: #cdc8bb; + --ring: #0b5c8a; +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #0d0d0c; + --surface: #151513; + --foreground: #eceae3; + --muted-foreground: #98948a; + --border: #272622; + --link: #7dbdf0; + --crit: #ff6a5c; + --warn: #f2b43a; + --ok: #4cc38a; + --primary: #7dbdf0; + --primary-foreground: #0b1620; + --secondary: #201f1c; + --secondary-foreground: #eceae3; + --muted: #1b1a18; + --accent: #22211e; + --accent-foreground: #eceae3; + --destructive: #ff6a5c; + --input: #38362f; + --ring: #7dbdf0; + } +} + +@theme inline { + --color-background: var(--background); + --color-surface: var(--surface); + --color-foreground: var(--foreground); + --color-muted-foreground: var(--muted-foreground); + --color-border: var(--border); + --color-link: var(--link); + --color-crit: var(--crit); + --color-warn: var(--warn); + --color-ok: var(--ok); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-input: var(--input); + --color-ring: var(--ring); + --font-sans: 'Schibsted Grotesk Variable', 'Avenir Next', system-ui, sans-serif; + --font-mono: 'JetBrains Mono Variable', ui-monospace, 'SF Mono', Menlo, monospace; +} + +@layer base { + body { + @apply bg-background text-foreground font-sans antialiased; + } + :focus-visible { + @apply outline-2 outline-offset-2 outline-link; + } +} diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts new file mode 100644 index 0000000..ef7e506 --- /dev/null +++ b/src/test/fixtures.ts @@ -0,0 +1,104 @@ +import type { Alert, Repo, TrackerState } from '@/lib/types' + +export const NOW = '2026-10-02T12:00:00Z' +export const hoursAgo = (h: number) => new Date(Date.parse(NOW) - h * 3600_000).toISOString() + +export function repo(over: Partial = {}): Repo { + return { + fullName: 'o/r', + url: 'https://github.com/o/r', + releasesUrl: 'https://github.com/o/r/releases', + description: '', + health: 'ok', + error: '', + workflows: { total: 0, failing: 0, running: 0 }, + prs: { open: 0, waiting: 0, failingChecks: 0, oldestSeconds: 0 }, + issues: { open: 0, stale: 0 }, + archived: false, + up: true, + refreshedAt: hoursAgo(1), + release: { + tag: 'v1.0.0', + url: 'https://github.com/o/r/releases/tag/v1.0.0', + publishedAt: hoursAgo(72), + prerelease: false, + fromTag: false, + }, + ...over, + } +} + +export function alert(over: Partial = {}): Alert { + return { + repo: 'o/r', + kind: 'workflow_failed', + severity: 'crit', + subject: 'CI', + detail: 'failure on main (push)', + url: 'https://github.com/o/r/actions/runs/1', + since: hoursAgo(2), + ...over, + } +} + +export function state(over: Partial = {}): TrackerState { + return { + now: NOW, + lastRefresh: hoursAgo(1), + intervalSeconds: 21600, + loaded: true, + refreshing: false, + manualRefresh: false, + counts: { crit: 0, warn: 0 }, + thresholds: { + prWarnSeconds: 172800, + prCritSeconds: 604800, + issueStaleSeconds: 2592000, + workflowStuckSeconds: 21600, + workflowDormantSeconds: 2592000, + }, + repos: [], + alerts: [], + ...over, + } +} + +/** A state with one failing repo (two alerts on one PR) and one healthy repo. */ +export function mixedState(): TrackerState { + return state({ + counts: { crit: 2, warn: 1 }, + repos: [ + repo({ + health: 'crit', + workflows: { total: 3, failing: 1, running: 0 }, + prs: { open: 2, waiting: 1, failingChecks: 1, oldestSeconds: 777600 }, + issues: { open: 4, stale: 2 }, + }), + repo({ + fullName: 'o/fine', + url: 'https://github.com/o/fine', + release: null, + releasesUrl: 'https://github.com/o/fine/releases', + }), + ], + alerts: [ + alert(), + alert({ + kind: 'pr_waiting', + severity: 'crit', + subject: '#5 bold title', + detail: 'open 9d', + url: 'https://github.com/o/r/pull/5', + since: hoursAgo(216), + }), + alert({ + kind: 'pr_checks_failing', + severity: 'warn', + subject: '#5 bold title', + detail: 'checks failing: build', + url: 'https://github.com/o/r/pull/5', + since: hoursAgo(10), + }), + ], + }) +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d953d0a --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noEmit": true, + "skipLibCheck": true, + "isolatedModules": true, + "verbatimModuleSyntax": true, + "jsx": "preserve", + "types": ["vite/client", "node"], + "baseUrl": ".", + "paths": { "@/*": ["src/*"] } + }, + "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..beef90d --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,12 @@ +import { fileURLToPath, URL } from 'node:url' +import tailwindcss from '@tailwindcss/vite' +import vue from '@vitejs/plugin-vue' +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + plugins: [vue(), tailwindcss()], + resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } }, + build: { outDir: 'dist', emptyOutDir: false }, + server: { proxy: { '/api': 'http://127.0.0.1:8080', '/refresh': 'http://127.0.0.1:8080' } }, + test: { environment: 'happy-dom', include: ['src/**/*.test.ts'] }, +}) diff --git a/workflow-prepare.sh b/workflow-prepare.sh new file mode 100755 index 0000000..70bb822 --- /dev/null +++ b/workflow-prepare.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Run by the shared release workflow before the tests, the version calculation and +# GoReleaser. The binary embeds dist/, so the UI must be built first. +set -euo pipefail + +PNPM_VERSION=11.21.0 # keep in sync with packageManager in package.json +NODE_VERSION=22.23.3 +NODE_SHA256_LINUX_X64=df450af89261115ef9f9e3830c3eeb2cc9213b63c720b1af623cb5dcbe2e02de +NODE_MIN="22.13.0" # pnpm 11 needs Node 22.13 or newer + +node_ok() { + command -v node >/dev/null 2>&1 && + [ "$(printf '%s\n%s\n' "$NODE_MIN" "$(node -p 'process.versions.node')" | sort -V | head -n1)" = "$NODE_MIN" ] +} + +install_node() { + if [ "$(uname -s)-$(uname -m)" != "Linux-x86_64" ]; then + echo "Node >= $NODE_MIN is required; install it and run again." >&2 + exit 1 + fi + local dir="${RUNNER_TEMP:-/tmp}/node-v${NODE_VERSION}" + local archive="node-v${NODE_VERSION}-linux-x64.tar.xz" + mkdir -p "$dir" + curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/${archive}" -o "$dir/$archive" + echo "${NODE_SHA256_LINUX_X64} $dir/$archive" | sha256sum -c - + tar -xJf "$dir/$archive" -C "$dir" --strip-components=1 + export PATH="$dir/bin:$PATH" + if [ -n "${GITHUB_PATH:-}" ]; then echo "$dir/bin" >> "$GITHUB_PATH"; fi +} + +node_ok || install_node + +npx --yes "pnpm@${PNPM_VERSION}" install --frozen-lockfile +npx --yes "pnpm@${PNPM_VERSION}" build