diff --git a/.github/workflows/manual-deploy-actions-collector.yml b/.github/workflows/manual-deploy-actions-collector.yml new file mode 100644 index 00000000000..22de91e89e2 --- /dev/null +++ b/.github/workflows/manual-deploy-actions-collector.yml @@ -0,0 +1,44 @@ +name: Manual Deploy [actions-collector] + +# Builds and deploys the GitHub Actions queue collector +# (packages/observability/collectors/actions-queue.ts), whose output feeds the +# "GitHub Actions Queue" Grafana dashboard. +# +# Deliberately not triggered by CI: this samples the Actions queue, so running +# its own deploy through a busy queue is the situation it exists to measure. + +on: + workflow_dispatch: + inputs: + environment: + description: Deployment environment + required: false + default: staging + +permissions: + contents: read + deployments: write + id-token: write + +jobs: + build-actions-collector: + name: Build actions-collector Docker image + uses: cardstack/gh-actions/.github/workflows/docker-ecr.yml@main + secrets: inherit + with: + repository: "boxel-actions-collector-${{ inputs.environment || 'staging' }}" + environment: ${{ inputs.environment || 'staging' }} + dockerfile: "packages/observability/collectors/Dockerfile" + + deploy-actions-collector: + needs: [build-actions-collector] + name: Deploy actions-collector to AWS ECS + uses: cardstack/gh-actions/.github/workflows/ecs-deploy.yml@main + secrets: inherit + with: + container-name: "boxel-actions-collector" + environment: ${{ inputs.environment || 'staging' }} + cluster: ${{ inputs.environment || 'staging' }} + service-name: "boxel-actions-collector-${{ inputs.environment || 'staging' }}" + image: ${{ needs.build-actions-collector.outputs.image }} + wait-for-service-stability: false diff --git a/packages/observability/README.md b/packages/observability/README.md index af3ca6da876..fffc7d01aa0 100644 --- a/packages/observability/README.md +++ b/packages/observability/README.md @@ -41,6 +41,10 @@ provisioning/ # mounted into Grafana at /etc/grafana/provisioning/ alerting/ # alert rule groups, contact points, notification policies local-only/ # local-dev overrides — bind-mounted file-by-file over # `datasources/`. apply-datasources.sh ignores this dir. +collectors/ + actions-queue.ts # samples the GitHub Actions REST API and prints one JSON + # line per observation on channel `boxel:actions-queue`; + # feeds the "GitHub Actions Queue" dashboard alloy/ config.alloy # local log scraper config — discovers Docker containers # and ships their stdout into Loki @@ -69,6 +73,79 @@ docker-compose.yml # local Grafana 12.4.3 + Loki 3.4.4 + Alloy 1.10.0 # + Prometheus 3.0.0 (scrapes synapse) ``` +## Collectors + +Some signals have no service to emit them, so a collector samples an external +API and prints the result as JSON log lines on stdout — the same shape every +other service logs in, so the existing Alloy (local) and FireLens (hosted) +pipelines carry it to Loki with no special handling. + +### actions-queue + +Samples GitHub Actions queue depth, per-job wait times and runner consumption. + +```bash +GITHUB_TOKEN=$(gh auth token) node collectors/actions-queue.ts --once +GITHUB_TOKEN=$(gh auth token) node collectors/actions-queue.ts # loops, default 60s +``` + +It emits four event types, all on channel `boxel:actions-queue`: + +| `event_type` | one line per | carries | +| ---------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------- | +| `job` | queued or running job | branch, actor, workflow, runner labels, `queued_seconds`, `running_seconds`, `current_step` | +| `group` | dimension × key | `dimension` (branch / actor / workflow), `key`, `queued`, `running` | +| `snapshot` | sample | `queued_jobs`, `running_jobs`, `active_runs`, `rate_limit_remaining` | +| `collector-error`, `collector-throttled` | failure or skipped sample | why the series has a gap | + +Grouped depth is emitted as its own lines rather than nested on the snapshot +because LogQL flattens nested JSON into one label per key, which for dynamic +keys like branch names produces a label per branch instead of a series that can +be grouped by branch. + +Two constraints are load-bearing: + +- **It must not run as a scheduled GitHub Actions workflow.** It would queue + behind the backlog it measures and go blind during the incident it exists + for. Hosted, it belongs on a schedule outside Actions. +- **A sample costs one request per active run, plus two.** The hourly spend + therefore scales with how busy the repository is, against a token's 5,000 + requests an hour. At the observed peak of ~50 concurrent runs a 60-second + interval spends around 3,200 an hour, which is comfortable; 30 seconds + exceeds the budget above roughly forty concurrent runs, so higher resolution + needs the per-sample cost reduced rather than the interval shortened. + Sampling stops rather than reporting partial depth when the remaining budget + nears its reserve. + +The dashboard reads `{service="actions-collector", env="$env"}`, so a hosted +deployment needs to log under that service name. + +#### Hosted + +Runs as a small ECS service — `cardstack/infra:configs/boxel-actions-collector` +— rather than a scheduled task: the signal is standing state sampled every +couple of minutes, and a per-invocation Fargate task would spend longer +starting than working. FireLens applies `service=actions-collector`, which is +what the dashboard selects on. + +Before the first apply, put a GitHub token at +`//boxel/ACTIONS_COLLECTOR_GITHUB_TOKEN` as a `SecureString`. It is +deliberately not managed by Terraform, like the other secrets on that path. The +collector accepts either that name or a plain `GITHUB_TOKEN`, which is the +convenient one locally. + +The token's only job is the rate limit: these endpoints are readable without +authentication on a public repository, but unauthenticated callers get 60 +requests an hour against the 5,000 an authenticated one gets, and a single +sample can cost sixty. So the token needs **no permissions** while `boxel` is +public — a fine-grained token scoped to _Public repositories (read-only)_, or +even a classic token with no scopes ticked, is enough. Were the repository ever +made private, it would need Actions: Read-only on it. + +Build and deploy with the **Manual Deploy [actions-collector]** workflow. It is +`workflow_dispatch` only — putting the deploy of a queue sampler on a CI +trigger would make it queue behind the backlog it reports on. + ## Local workflow ```sh diff --git a/packages/observability/collectors/Dockerfile b/packages/observability/collectors/Dockerfile new file mode 100644 index 00000000000..65b1343d9d3 --- /dev/null +++ b/packages/observability/collectors/Dockerfile @@ -0,0 +1,12 @@ +ARG NODE_VERSION=24.17.0 +FROM node:${NODE_VERSION}-slim + +# The collector is a single file that uses nothing beyond the Node runtime and +# global fetch, so there is no package install step and no lockfile to copy — +# the image is the runtime plus one source file. +WORKDIR /app +COPY packages/observability/collectors/actions-queue.ts . + +# Runs in its default looping mode. Repository, sample interval and token come +# from the task environment; see packages/observability/README.md. +ENTRYPOINT ["node", "actions-queue.ts"] diff --git a/packages/observability/collectors/actions-queue.ts b/packages/observability/collectors/actions-queue.ts new file mode 100644 index 00000000000..72f4a9670f6 --- /dev/null +++ b/packages/observability/collectors/actions-queue.ts @@ -0,0 +1,426 @@ +// Samples the GitHub Actions queue and emits one JSON log line per observation, +// which Alloy (local) or FireLens (hosted) ships to Loki for the "GitHub Actions +// Queue" dashboard to read with `| json`. +// +// Why a poller rather than webhooks: the questions this answers are about +// *standing* state — how deep is the queue right now, how long has this job been +// waiting, which branches are holding runners. Webhooks deliver transitions, so +// reconstructing depth from them means keeping state and healing missed +// deliveries. A periodic snapshot is inherently self-correcting. +// +// This must not run as a scheduled GitHub Actions workflow: it would queue +// behind the very backlog it measures and go blind during the incident it +// exists for. + +const CHANNEL = 'boxel:actions-queue'; + +// One sample costs two run-list requests plus one per active run, so the hourly +// spend scales with how busy the repository is, against the 5,000 requests an +// hour a token is allowed. Measured over this collector's own history: 4 active +// runs at the median, 43 at p95, 51 at peak. +// +// active runs at 60s at 30s +// 4 360/hr 720/hr +// 43 2,700/hr 5,400/hr +// 51 3,180/hr 6,360/hr +// +// A minute is comfortable across that range and doubles the resolution. Thirty +// seconds exceeds the budget above roughly forty concurrent runs — which is +// exactly when the queue is worth watching — so it needs the per-sample cost +// reduced first, not the interval shortened. +const DEFAULT_INTERVAL_SECONDS = 60; + +// Requests deliberately left unspent, so a burst of runs can never take the +// token to zero and lock out other consumers. +const RATE_LIMIT_RESERVE = 750; + +interface Job { + id: number; + run_id: number; + run_attempt: number; + workflow_name: string | null; + head_branch: string | null; + name: string; + status: string; + conclusion: string | null; + created_at: string; + started_at: string | null; + runner_name: string | null; + labels: string[]; + steps?: { name: string; status: string; started_at: string | null }[]; +} + +interface Run { + id: number; + name: string | null; + head_branch: string | null; + run_attempt: number; + created_at: string; + status: string; + actor?: { login?: string } | null; + triggering_actor?: { login?: string } | null; + event?: string; +} + +interface Options { + repo: string; + token: string; + intervalMs: number; + once: boolean; +} + +function usage(): never { + console.error( + `usage: node actions-queue.ts [--repo owner/name] [--interval seconds] [--once] + + --repo Repository to sample. Default $GITHUB_REPOSITORY, else cardstack/boxel. + --interval Seconds between samples. Default ${DEFAULT_INTERVAL_SECONDS}. + --once Take a single sample and exit, rather than looping. + + Requires a token only to raise the API rate limit from 60 to 5,000 requests + an hour. Against a public repository it needs no permissions at all; against + a private one it needs Actions: Read-only.`, + ); + process.exit(2); +} + +function parseArgs(argv: string[]): Options { + let repo = process.env.GITHUB_REPOSITORY || 'cardstack/boxel'; + let intervalSeconds = Number( + process.env.ACTIONS_QUEUE_INTERVAL || DEFAULT_INTERVAL_SECONDS, + ); + let once = false; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--once') { + once = true; + } else if (arg === '--repo') { + repo = argv[++i] ?? usage(); + } else if (arg === '--interval') { + intervalSeconds = Number(argv[++i]); + } else { + usage(); + } + } + + // The hosted task reads a dedicated parameter rather than a bare + // GITHUB_TOKEN, because its SSM path is shared with every other boxel + // service and a generic name there would be ambiguous. Locally the plain + // name is the convenient one, so both are accepted. + const token = + process.env.ACTIONS_COLLECTOR_GITHUB_TOKEN || + process.env.GITHUB_TOKEN || + ''; + if (!token) { + console.error( + 'set ACTIONS_COLLECTOR_GITHUB_TOKEN or GITHUB_TOKEN — any token raises the\n' + + 'rate limit from 60 to 5,000 requests an hour; see --help', + ); + process.exit(2); + } + if (!/^[^/]+\/[^/]+$/.test(repo)) { + console.error(`--repo must be owner/name, got "${repo}"`); + process.exit(2); + } + if (!Number.isFinite(intervalSeconds) || intervalSeconds < 10) { + console.error('--interval must be a number of seconds, at least 10'); + process.exit(2); + } + return { repo, token, intervalMs: intervalSeconds * 1000, once }; +} + +// Emitted on its own line so a log-shipping pipeline sees one JSON object per +// line. Anything non-serialisable would corrupt the stream, so values are kept +// to primitives and arrays of primitives by construction. +function emit(record: Record): void { + process.stdout.write(JSON.stringify({ channel: CHANNEL, ...record }) + '\n'); +} + +let rateLimitRemaining: number | null = null; + +async function gh(url: string, token: string): Promise { + const res = await fetch(url, { + headers: { + authorization: `Bearer ${token}`, + accept: 'application/vnd.github+json', + 'x-github-api-version': '2022-11-28', + 'user-agent': 'boxel-actions-queue-collector', + }, + }); + const remaining = res.headers.get('x-ratelimit-remaining'); + if (remaining !== null) { + rateLimitRemaining = Number(remaining); + } + if (!res.ok) { + // GitHub distinguishes its refusals only in the response body: a rejected + // credential, a token whose repository access omits this one, and an + // exhausted rate limit are all 403. Carrying the body and the remaining + // budget into the error means the emitted collector-error line says which, + // rather than leaving it to be inferred. + let detail = ''; + try { + detail = (await res.text()).slice(0, 300).replace(/\s+/g, ' ').trim(); + } catch { + // A body that cannot be read is not worth failing over; the status still + // carries most of the signal. + } + const err = new Error( + `GET ${url} → ${res.status} ${res.statusText}` + + (detail ? ` — ${detail}` : '') + + ` (rate limit remaining: ${res.headers.get('x-ratelimit-remaining') ?? 'unknown'})`, + ) as Error & { status?: number }; + err.status = res.status; + throw err; + } + return (await res.json()) as T; +} + +// The REST list endpoints cap at 100 per page. Callers here are bounded by how +// much work is in flight, not by repository history, so a small page cap is a +// backstop against a runaway loop rather than a real limit. +async function paginate( + url: string, + token: string, + extract: (page: any) => T[], + maxPages = 10, +): Promise { + const items: T[] = []; + for (let page = 1; page <= maxPages; page++) { + const body = await gh( + `${url}${url.includes('?') ? '&' : '?'}per_page=100&page=${page}`, + token, + ); + const batch = extract(body); + items.push(...batch); + if (batch.length < 100) break; + } + return items; +} + +function seconds(from: string | null | undefined, to: number): number | null { + if (!from) return null; + const t = Date.parse(from); + return Number.isFinite(t) ? Math.max(0, Math.round((to - t) / 1000)) : null; +} + +async function sample(opts: Options): Promise { + const base = `https://api.github.com/repos/${opts.repo}/actions`; + const observedAt = Date.now(); + const observed_at = new Date(observedAt).toISOString(); + + // Only queued and in_progress runs can hold or want a runner, so completed + // history is deliberately not fetched — it would dominate the request budget + // without informing the queue. + const runs: Run[] = []; + for (const status of ['queued', 'in_progress']) { + runs.push( + ...(await paginate( + `${base}/runs?status=${status}`, + opts.token, + (b) => b.workflow_runs ?? [], + )), + ); + } + + const runById = new Map(runs.map((r) => [r.id, r])); + + // Fetching one job list per run is the expensive half of a sample. Skipping + // the whole sample keeps the series honest: a partial sample would understate + // queue depth, which is worse than a visible gap. + if ( + rateLimitRemaining !== null && + rateLimitRemaining - runs.length < RATE_LIMIT_RESERVE + ) { + emit({ + event_type: 'collector-throttled', + observed_at, + repo: opts.repo, + active_runs: runs.length, + rate_limit_remaining: rateLimitRemaining, + }); + return; + } + + let queuedJobs = 0; + let runningJobs = 0; + // Grouped depth is emitted as its own lines rather than as nested objects on + // the snapshot, because LogQL flattens nested JSON into one label per key — + // which for dynamic keys like branch names yields an unqueryable label per + // branch instead of a series grouped by branch. + const groups: Record< + string, + Map + > = { + workflow: new Map(), + branch: new Map(), + actor: new Map(), + }; + + for (const run of runs) { + let jobs: Job[]; + try { + jobs = await paginate( + `${base}/runs/${run.id}/jobs`, + opts.token, + (b) => b.jobs ?? [], + ); + } catch (e) { + const status = (e as { status?: number }).status; + emit({ + event_type: 'collector-error', + observed_at, + repo: opts.repo, + run_id: run.id, + message: e instanceof Error ? e.message : String(e), + }); + // A run that completed and was reaped between the listing and this fetch + // answers 404, and has no jobs left to count — omitting it understates + // nothing, so the sample continues. + if (status === 404) continue; + // Anything else means this run's jobs are unknown rather than absent. + // Publishing the totals anyway would put a confidently low queue depth + // on the dashboard at precisely the moment the API is failing, which is + // worse than the visible gap a skipped sample leaves — the same reason + // the rate-limit reserve above abandons the sample rather than trimming + // it. + emit({ + event_type: 'collector-aborted', + observed_at, + repo: opts.repo, + active_runs: runs.length, + reason: `job listing failed for run ${run.id} with status ${status ?? 'unknown'}`, + }); + return; + } + + for (const job of jobs) { + if (job.status !== 'queued' && job.status !== 'in_progress') continue; + + const meta = runById.get(job.run_id); + const actor = meta?.triggering_actor?.login ?? meta?.actor?.login ?? null; + const branch = job.head_branch ?? meta?.head_branch ?? null; + const workflow = job.workflow_name ?? meta?.name ?? null; + + // While a job is queued GitHub reports started_at equal to created_at, so + // it cannot be used to tell waiting from running. Queue wait is therefore + // measured from created_at, and only a job that has actually left the + // queue reports a running duration. + const queued_seconds = + job.status === 'queued' + ? seconds(job.created_at, observedAt) + : seconds( + job.created_at, + Date.parse(job.started_at ?? job.created_at), + ); + const running_seconds = + job.status === 'in_progress' + ? seconds(job.started_at, observedAt) + : null; + + const step = job.steps?.find((s) => s.status === 'in_progress'); + + if (job.status === 'queued') queuedJobs++; + else runningJobs++; + + const bump = (dimension: string, key: string | null) => { + if (!key) return; + const m = groups[dimension]; + const entry = m.get(key) ?? { queued: 0, running: 0 }; + if (job.status === 'queued') entry.queued++; + else entry.running++; + m.set(key, entry); + }; + bump('workflow', workflow); + bump('branch', branch); + bump('actor', actor); + + emit({ + event_type: 'job', + observed_at, + repo: opts.repo, + workflow, + run_id: job.run_id, + run_attempt: job.run_attempt, + job_id: job.id, + job: job.name, + status: job.status, + head_branch: branch, + actor, + event: meta?.event ?? null, + runner_name: job.runner_name || null, + runner_labels: job.labels ?? [], + created_at: job.created_at, + started_at: job.started_at, + queued_seconds, + running_seconds, + current_step: step?.name ?? null, + current_step_seconds: seconds(step?.started_at ?? null, observedAt), + }); + } + } + + for (const [dimension, m] of Object.entries(groups)) { + for (const [key, counts] of m) { + emit({ + event_type: 'group', + observed_at, + repo: opts.repo, + dimension, + key, + queued: counts.queued, + running: counts.running, + total: counts.queued + counts.running, + }); + } + } + + // A standalone total, so queue depth is one field rather than a count over + // the per-job lines — the per-job series is subject to Loki's retention and + // to sampling gaps, and depth should survive both. + emit({ + event_type: 'snapshot', + observed_at, + repo: opts.repo, + active_runs: runs.length, + queued_jobs: queuedJobs, + running_jobs: runningJobs, + total_jobs: queuedJobs + runningJobs, + rate_limit_remaining: rateLimitRemaining, + }); +} + +async function main(): Promise { + const opts = parseArgs(process.argv.slice(2)); + let stopping = false; + for (const sig of ['SIGINT', 'SIGTERM'] as const) { + process.on(sig, () => { + stopping = true; + }); + } + + for (;;) { + const startedAt = Date.now(); + try { + await sample(opts); + } catch (e) { + // One failed sample should not end a long-running collector; the next tick + // re-reads the whole state anyway. + emit({ + event_type: 'collector-error', + observed_at: new Date().toISOString(), + repo: opts.repo, + message: e instanceof Error ? e.message : String(e), + }); + if (opts.once) process.exitCode = 1; + } + if (opts.once || stopping) return; + const elapsed = Date.now() - startedAt; + await new Promise((r) => + setTimeout(r, Math.max(0, opts.intervalMs - elapsed)), + ); + if (stopping) return; + } +} + +await main(); diff --git a/packages/observability/grafanactl/resources/dashboards/boxel-status/actions-queue.json b/packages/observability/grafanactl/resources/dashboards/boxel-status/actions-queue.json new file mode 100644 index 00000000000..8cfee2af7e7 --- /dev/null +++ b/packages/observability/grafanactl/resources/dashboards/boxel-status/actions-queue.json @@ -0,0 +1,1285 @@ +{ + "apiVersion": "dashboard.grafana.app/v1beta1", + "kind": "Dashboard", + "metadata": { + "annotations": { + "grafana.app/folder": "defd2d156sav4d" + }, + "name": "boxel-actions-queue" + }, + "spec": { + "annotations": { + "list": [] + }, + "description": "GitHub Actions queue depth, wait times and runner consumption, sampled from the Actions REST API and shipped as JSON log lines on channel boxel:actions-queue.", + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "id": 2, + "type": "row", + "title": "Queue depth", + "collapsed": false, + "gridPos": { + "x": 0, + "y": 0, + "w": 24, + "h": 1 + }, + "panels": [] + }, + { + "id": 3, + "type": "stat", + "title": "Jobs queued now", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "x": 0, + "y": 1, + "w": 4, + "h": 4 + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "last_over_time({service=\"actions-collector\", env=\"$env\"} |= \"boxel:actions-queue\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:actions-queue\" | event_type=\"snapshot\" | unwrap queued_jobs [$__interval]) by ()", + "refId": "A", + "queryType": "range", + "legendFormat": "queued" + } + ], + "fieldConfig": { + "defaults": { + "custom": {} + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "description": "Jobs waiting for a runner at the most recent sample.", + "interval": "5m" + }, + { + "id": 4, + "type": "stat", + "title": "Jobs running now", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "x": 4, + "y": 1, + "w": 4, + "h": 4 + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "last_over_time({service=\"actions-collector\", env=\"$env\"} |= \"boxel:actions-queue\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:actions-queue\" | event_type=\"snapshot\" | unwrap running_jobs [$__interval]) by ()", + "refId": "A", + "queryType": "range", + "legendFormat": "running" + } + ], + "fieldConfig": { + "defaults": { + "custom": {} + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "description": "Jobs currently holding a runner.", + "interval": "5m" + }, + { + "id": 5, + "type": "stat", + "title": "Active runs", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "x": 8, + "y": 1, + "w": 4, + "h": 4 + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "last_over_time({service=\"actions-collector\", env=\"$env\"} |= \"boxel:actions-queue\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:actions-queue\" | event_type=\"snapshot\" | unwrap active_runs [$__interval]) by ()", + "refId": "A", + "queryType": "range", + "legendFormat": "runs" + } + ], + "fieldConfig": { + "defaults": { + "custom": {} + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "description": "Workflow runs in queued or in_progress state.", + "interval": "5m" + }, + { + "id": 6, + "type": "timeseries", + "title": "Queue depth over time", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "x": 12, + "y": 1, + "w": 12, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "max_over_time({service=\"actions-collector\", env=\"$env\"} |= \"boxel:actions-queue\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:actions-queue\" | event_type=\"snapshot\" | unwrap queued_jobs [$__interval]) by ()", + "refId": "A", + "queryType": "range", + "legendFormat": "queued" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "max_over_time({service=\"actions-collector\", env=\"$env\"} |= \"boxel:actions-queue\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:actions-queue\" | event_type=\"snapshot\" | unwrap running_jobs [$__interval]) by ()", + "refId": "B", + "queryType": "range", + "legendFormat": "running" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "max_over_time({service=\"actions-collector\", env=\"$env\"} |= \"boxel:actions-queue\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:actions-queue\" | event_type=\"snapshot\" | unwrap active_runs [$__interval]) by ()", + "refId": "C", + "queryType": "range", + "legendFormat": "active runs" + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "spanNulls": true + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull", + "max" + ], + "sortBy": "Last *", + "sortDesc": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "description": "Queued depth rising while running stays flat means the pool is saturated rather than slow.\n\nCarries the same three values as the stat tiles beside it, because a stat panel has no hover tooltip — read the headline there, hover here.", + "interval": "5m" + }, + { + "id": 7, + "type": "stat", + "title": "Longest current wait", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "x": 0, + "y": 5, + "w": 12, + "h": 4 + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "max_over_time({service=\"actions-collector\", env=\"$env\"} |= \"boxel:actions-queue\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:actions-queue\" | event_type=\"job\" | status=\"queued\" | unwrap queued_seconds [$__interval]) by ()", + "refId": "A", + "queryType": "range", + "legendFormat": "longest wait" + } + ], + "fieldConfig": { + "defaults": { + "custom": {}, + "unit": "s" + }, + "overrides": [] + }, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "textMode": "auto", + "wideLayout": true + }, + "description": "Age of the oldest job still waiting for a runner.", + "interval": "5m" + }, + { + "id": 8, + "type": "row", + "title": "Wait times", + "collapsed": false, + "gridPos": { + "x": 0, + "y": 18, + "w": 24, + "h": 1 + }, + "panels": [] + }, + { + "id": 9, + "type": "timeseries", + "title": "Queue wait percentiles", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "x": 0, + "y": 19, + "w": 24, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "quantile_over_time(0.5, {service=\"actions-collector\", env=\"$env\"} |= \"boxel:actions-queue\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:actions-queue\" | event_type=\"job\" | status=\"queued\" | unwrap queued_seconds [$__interval]) by ()", + "refId": "A", + "queryType": "range", + "legendFormat": "p50" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "quantile_over_time(0.9, {service=\"actions-collector\", env=\"$env\"} |= \"boxel:actions-queue\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:actions-queue\" | event_type=\"job\" | status=\"queued\" | unwrap queued_seconds [$__interval]) by ()", + "refId": "B", + "queryType": "range", + "legendFormat": "p90" + }, + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "max_over_time({service=\"actions-collector\", env=\"$env\"} |= \"boxel:actions-queue\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:actions-queue\" | event_type=\"job\" | status=\"queued\" | unwrap queued_seconds [$__interval]) by ()", + "refId": "C", + "queryType": "range", + "legendFormat": "max" + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "spanNulls": true + }, + "unit": "s" + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull", + "max" + ], + "sortBy": "Last *", + "sortDesc": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "description": "How long jobs have been waiting, measured from job creation.", + "interval": "5m" + }, + { + "id": 10, + "type": "row", + "title": "Who is consuming the pool", + "collapsed": false, + "gridPos": { + "x": 0, + "y": 27, + "w": 24, + "h": 1 + }, + "panels": [] + }, + { + "id": 11, + "type": "timeseries", + "title": "Queued jobs by branch", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "x": 0, + "y": 28, + "w": 8, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "topk(10, sum by (key) (max_over_time({service=\"actions-collector\", env=\"$env\"} |= \"boxel:actions-queue\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:actions-queue\" | event_type=\"group\" | dimension=\"branch\" | unwrap queued [$__interval])))", + "refId": "A", + "queryType": "range", + "legendFormat": "{{key}}" + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "spanNulls": true + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull", + "max" + ], + "sortBy": "Last *", + "sortDesc": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "description": "Queued jobs grouped by branch, from the collector's per-group snapshot lines.", + "interval": "5m" + }, + { + "id": 12, + "type": "timeseries", + "title": "Queued jobs by author", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "x": 8, + "y": 28, + "w": 8, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "topk(10, sum by (key) (max_over_time({service=\"actions-collector\", env=\"$env\"} |= \"boxel:actions-queue\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:actions-queue\" | event_type=\"group\" | dimension=\"actor\" | unwrap queued [$__interval])))", + "refId": "A", + "queryType": "range", + "legendFormat": "{{key}}" + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "spanNulls": true + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull", + "max" + ], + "sortBy": "Last *", + "sortDesc": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "description": "Queued jobs grouped by actor, from the collector's per-group snapshot lines.", + "interval": "5m" + }, + { + "id": 13, + "type": "timeseries", + "title": "Queued jobs by workflow", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "x": 16, + "y": 28, + "w": 8, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "topk(10, sum by (key) (max_over_time({service=\"actions-collector\", env=\"$env\"} |= \"boxel:actions-queue\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:actions-queue\" | event_type=\"group\" | dimension=\"workflow\" | unwrap queued [$__interval])))", + "refId": "A", + "queryType": "range", + "legendFormat": "{{key}}" + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "spanNulls": true + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull", + "max" + ], + "sortBy": "Last *", + "sortDesc": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "description": "Queued jobs grouped by workflow, from the collector's per-group snapshot lines.", + "interval": "5m" + }, + { + "id": 14, + "type": "timeseries", + "title": "Running jobs by branch", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "x": 0, + "y": 36, + "w": 8, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "topk(10, sum by (key) (max_over_time({service=\"actions-collector\", env=\"$env\"} |= \"boxel:actions-queue\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:actions-queue\" | event_type=\"group\" | dimension=\"branch\" | unwrap running [$__interval])))", + "refId": "A", + "queryType": "range", + "legendFormat": "{{key}}" + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "spanNulls": true + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull", + "max" + ], + "sortBy": "Last *", + "sortDesc": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "description": "Runners currently held, grouped by branch.", + "interval": "5m" + }, + { + "id": 15, + "type": "timeseries", + "title": "Running jobs by author", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "x": 8, + "y": 36, + "w": 8, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "topk(10, sum by (key) (max_over_time({service=\"actions-collector\", env=\"$env\"} |= \"boxel:actions-queue\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:actions-queue\" | event_type=\"group\" | dimension=\"actor\" | unwrap running [$__interval])))", + "refId": "A", + "queryType": "range", + "legendFormat": "{{key}}" + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "spanNulls": true + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull", + "max" + ], + "sortBy": "Last *", + "sortDesc": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "description": "Runners currently held, grouped by actor.", + "interval": "5m" + }, + { + "id": 16, + "type": "timeseries", + "title": "Running jobs by workflow", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "x": 16, + "y": 36, + "w": 8, + "h": 8 + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "topk(10, sum by (key) (max_over_time({service=\"actions-collector\", env=\"$env\"} |= \"boxel:actions-queue\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:actions-queue\" | event_type=\"group\" | dimension=\"workflow\" | unwrap running [$__interval])))", + "refId": "A", + "queryType": "range", + "legendFormat": "{{key}}" + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "spanNulls": true + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull", + "max" + ], + "sortBy": "Last *", + "sortDesc": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "description": "Runners currently held, grouped by workflow.", + "interval": "5m" + }, + { + "id": 17, + "type": "row", + "title": "Long-running steps", + "collapsed": false, + "gridPos": { + "x": 0, + "y": 44, + "w": 24, + "h": 1 + }, + "panels": [] + }, + { + "id": 18, + "type": "table", + "title": "In-progress jobs by current step duration", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "x": 0, + "y": 45, + "w": 24, + "h": 10 + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "topk(25, max by (job, head_branch, workflow, actor, current_step, run_id, job_id) (last_over_time({service=\"actions-collector\", env=\"$env\"} |= \"boxel:actions-queue\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:actions-queue\" | event_type=\"job\" | status=\"in_progress\" | unwrap current_step_seconds [4m])))", + "refId": "A", + "queryType": "instant" + } + ], + "fieldConfig": { + "defaults": { + "custom": {}, + "unit": "s" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "job" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Open this job on GitHub", + "url": "https://github.com/cardstack/boxel/actions/runs/${__data.fields.run_id}/job/${__data.fields.job_id}", + "targetBlank": true + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "head_branch" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Actions runs for this branch", + "url": "https://github.com/cardstack/boxel/actions?query=branch%3A${__value.raw}", + "targetBlank": true + } + ] + } + ] + }, + { + "matcher": { + "id": "byName", + "options": "actor" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "GitHub profile", + "url": "https://github.com/${__value.raw}", + "targetBlank": true + } + ] + } + ] + } + ] + }, + "options": {}, + "description": "Ranked by how long a job's current step has been running. A job whose step has run far longer than the same step on its siblings is the wedge signal — compare before concluding it is stuck.\n\nThe window is four minutes against a two-minute sample interval, so a row can be up to that stale: a job that finished moments ago may still appear, and a job that just changed step may briefly show both. Neither distorts what this panel is for, since a stall worth acting on is measured in hours.", + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "run_id": true, + "job_id": true + }, + "renameByName": { + "Value": "in step", + "Value #A": "in step" + } + } + } + ] + }, + { + "id": 19, + "type": "row", + "title": "Collector health", + "collapsed": false, + "gridPos": { + "x": 0, + "y": 55, + "w": 24, + "h": 1 + }, + "panels": [] + }, + { + "id": 20, + "type": "timeseries", + "title": "GitHub API requests remaining", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "x": 0, + "y": 56, + "w": 12, + "h": 6 + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "min_over_time({service=\"actions-collector\", env=\"$env\"} |= \"boxel:actions-queue\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:actions-queue\" | event_type=\"snapshot\" | unwrap rate_limit_remaining [$__interval]) by ()", + "refId": "A", + "queryType": "range", + "legendFormat": "remaining" + } + ], + "fieldConfig": { + "defaults": { + "custom": { + "spanNulls": true + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true, + "calcs": [ + "lastNotNull", + "max" + ], + "sortBy": "Last *", + "sortDesc": true + }, + "tooltip": { + "mode": "multi", + "sort": "desc" + } + }, + "description": "Sampling stops rather than reporting partial depth when this nears the reserve.", + "interval": "5m" + }, + { + "id": 21, + "type": "logs", + "title": "Collector errors and throttling", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "x": 12, + "y": 56, + "w": 12, + "h": 6 + }, + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "expr": "{service=\"actions-collector\", env=\"$env\"} |= \"boxel:actions-queue\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:actions-queue\" | event_type=~\"collector-error|collector-throttled\"", + "refId": "A", + "queryType": "range" + } + ], + "fieldConfig": { + "defaults": { + "custom": {} + }, + "overrides": [] + }, + "options": {}, + "description": "Gaps in the series above should have a matching line here." + }, + { + "id": 1, + "type": "text", + "title": "About this dashboard", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "gridPos": { + "x": 0, + "y": 62, + "w": 24, + "h": 4 + }, + "targets": [], + "fieldConfig": { + "defaults": { + "custom": {} + }, + "overrides": [] + }, + "options": { + "mode": "markdown", + "content": "Sampled from the GitHub Actions REST API by `packages/observability/collectors/actions-queue.ts`, which emits one JSON line per observation on channel `boxel:actions-queue`.\n\nThe collector runs **outside** GitHub Actions on purpose — a scheduled workflow would queue behind the backlog it measures and go blind during the incident it exists for.\n\nDepth and grouped depth come from periodic snapshots, so each point is a sample rather than a continuous measure; gaps mean the collector was down or throttled (see *Collector health*)." + } + }, + { + "id": 40, + "type": "row", + "title": "Right now", + "collapsed": false, + "gridPos": { + "x": 0, + "y": 9, + "w": 24, + "h": 1 + }, + "panels": [] + }, + { + "id": 41, + "type": "table", + "title": "Queued now, by branch", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "A point-in-time count, unlike the timeseries above — a branch whose jobs have finished disappears from this table instead of leaving a line behind.", + "gridPos": { + "x": 0, + "y": 10, + "w": 8, + "h": 8 + }, + "interval": "5m", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "refId": "A", + "queryType": "instant", + "expr": "sum by (key) (last_over_time({service=\"actions-collector\", env=\"$env\"} |= \"boxel:actions-queue\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:actions-queue\" | event_type=\"group\" | dimension=\"branch\" | unwrap queued [5m])) > 0" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true + }, + "renameByName": { + "key": "branch", + "Value": "jobs", + "Value #A": "jobs" + } + } + }, + { + "id": "sortBy", + "options": { + "fields": {}, + "sort": [ + { + "field": "jobs", + "desc": true + } + ] + } + } + ], + "fieldConfig": { + "defaults": { + "custom": {} + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "branch" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Actions runs for this branch", + "url": "https://github.com/cardstack/boxel/actions?query=branch%3A${__value.raw}", + "targetBlank": true + } + ] + } + ] + } + ] + }, + "options": {} + }, + { + "id": 42, + "type": "table", + "title": "Runners held now, by branch", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "Which branches are occupying the pool at this moment.", + "gridPos": { + "x": 8, + "y": 10, + "w": 8, + "h": 8 + }, + "interval": "5m", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "refId": "A", + "queryType": "instant", + "expr": "sum by (key) (last_over_time({service=\"actions-collector\", env=\"$env\"} |= \"boxel:actions-queue\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:actions-queue\" | event_type=\"group\" | dimension=\"branch\" | unwrap running [5m])) > 0" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true + }, + "renameByName": { + "key": "branch", + "Value": "jobs", + "Value #A": "jobs" + } + } + }, + { + "id": "sortBy", + "options": { + "fields": {}, + "sort": [ + { + "field": "jobs", + "desc": true + } + ] + } + } + ], + "fieldConfig": { + "defaults": { + "custom": {} + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "branch" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Actions runs for this branch", + "url": "https://github.com/cardstack/boxel/actions?query=branch%3A${__value.raw}", + "targetBlank": true + } + ] + } + ] + } + ] + }, + "options": {} + }, + { + "id": 43, + "type": "table", + "title": "Queued now, by author", + "datasource": { + "type": "loki", + "uid": "loki" + }, + "description": "The same instant count grouped by whoever triggered the run.", + "gridPos": { + "x": 16, + "y": 10, + "w": 8, + "h": 8 + }, + "interval": "5m", + "targets": [ + { + "datasource": { + "type": "loki", + "uid": "loki" + }, + "editorMode": "code", + "refId": "A", + "queryType": "instant", + "expr": "sum by (key) (last_over_time({service=\"actions-collector\", env=\"$env\"} |= \"boxel:actions-queue\" | json | line_format \"{{ if .log }}{{ .log }}{{ else }}{{ __line__ }}{{ end }}\" | json | channel=\"boxel:actions-queue\" | event_type=\"group\" | dimension=\"actor\" | unwrap queued [5m])) > 0" + } + ], + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true + }, + "renameByName": { + "key": "author", + "Value": "jobs", + "Value #A": "jobs" + } + } + }, + { + "id": "sortBy", + "options": { + "fields": {}, + "sort": [ + { + "field": "jobs", + "desc": true + } + ] + } + } + ], + "fieldConfig": { + "defaults": { + "custom": {} + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "author" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "GitHub profile", + "url": "https://github.com/${__value.raw}", + "targetBlank": true + } + ] + } + ] + } + ] + }, + "options": {} + } + ], + "refresh": "1m", + "schemaVersion": 42, + "tags": [ + "ci", + "actions", + "forensics" + ], + "templating": { + "list": [ + { + "hide": 2, + "name": "env", + "query": "__ENV__", + "skipUrlSync": true, + "type": "constant" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "GitHub Actions Queue", + "weekStart": "" + } +}