diff --git a/docs/ci-runners/design.md b/docs/ci-runners/design.md index ed4623b..1cf28fe 100644 --- a/docs/ci-runners/design.md +++ b/docs/ci-runners/design.md @@ -181,7 +181,7 @@ detected toolchain profile is described in | `ci.listRuns` | proxied workflow runs for a repo (status, conclusion, commit, timing) | | `ci.listJobs` | jobs + step status for a run (live-ish via polling) | | `ci.getJobLogs` | full log text after job completion | -| `ci.getRunnerStatus` | per-repo runner slots, container ids, backoff/error state | +| `ci.getRunnerStatus` | per-repo runner slots, the live runner containers, backoff/error state | PAT CRUD reuses the existing `credentials.*` module — note in the UI that CI needs a **fine-grained PAT** with Administration RW + Actions R + Metadata/Contents R (unlike the @@ -192,6 +192,14 @@ ghcr.io classic-PAT caveat documented on `Credential`). New module `src/watchtower-web/src/modules/ci/` (contribution model): - Repos page: enabled repos, runner slot status, add-repo dialog with repo picker. +- Runner containers table (on the product's CI tab): one row per live runner — container name + and short id, Docker state, uptime, image, and the runner's id at GitHub, linked to the + repository's Actions runner settings. A runner still on superseded settings is badged + "settings changed" from the spec-hash comparison, so a saved change that has not reached the + running runner yet is visible rather than mysterious. Watchtower stores no runner table (the + containers are the state), so the rows come off the host's containers and their labels; the + list is the orchestrator's last reconcile pass, which is why a just-spawned runner can trail + the slot count by one interval. - Builds view per repo: run list → job list with step progress (poll while running) → log viewer (fetched on completion). - Generated RPC client from `rpc-schema.json` as everywhere else. diff --git a/rpc-schema.json b/rpc-schema.json index 3715ad1..fb4ee04 100644 --- a/rpc-schema.json +++ b/rpc-schema.json @@ -1705,6 +1705,55 @@ "null" ], "format": "date-time" + }, + "runners": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "image": { + "type": "string" + }, + "state": { + "type": "string" + }, + "status": { + "type": "string" + }, + "startedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "gitHubRunnerId": { + "type": [ + "integer", + "null" + ] + }, + "stale": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "image", + "state", + "status", + "startedAt", + "gitHubRunnerId", + "stale" + ] + } } }, "required": [ @@ -1713,7 +1762,8 @@ "totalSpawned", "lastError", "lastErrorAt", - "backoffUntil" + "backoffUntil", + "runners" ] }, "toolchain": { @@ -1939,6 +1989,55 @@ "null" ], "format": "date-time" + }, + "runners": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "image": { + "type": "string" + }, + "state": { + "type": "string" + }, + "status": { + "type": "string" + }, + "startedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "gitHubRunnerId": { + "type": [ + "integer", + "null" + ] + }, + "stale": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "image", + "state", + "status", + "startedAt", + "gitHubRunnerId", + "stale" + ] + } } }, "required": [ @@ -1947,7 +2046,8 @@ "totalSpawned", "lastError", "lastErrorAt", - "backoffUntil" + "backoffUntil", + "runners" ] }, "toolchain": { @@ -2173,6 +2273,55 @@ "null" ], "format": "date-time" + }, + "runners": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "image": { + "type": "string" + }, + "state": { + "type": "string" + }, + "status": { + "type": "string" + }, + "startedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "gitHubRunnerId": { + "type": [ + "integer", + "null" + ] + }, + "stale": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "image", + "state", + "status", + "startedAt", + "gitHubRunnerId", + "stale" + ] + } } }, "required": [ @@ -2181,7 +2330,8 @@ "totalSpawned", "lastError", "lastErrorAt", - "backoffUntil" + "backoffUntil", + "runners" ] }, "toolchain": { @@ -2421,6 +2571,55 @@ "null" ], "format": "date-time" + }, + "runners": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "image": { + "type": "string" + }, + "state": { + "type": "string" + }, + "status": { + "type": "string" + }, + "startedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "gitHubRunnerId": { + "type": [ + "integer", + "null" + ] + }, + "stale": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "image", + "state", + "status", + "startedAt", + "gitHubRunnerId", + "stale" + ] + } } }, "required": [ @@ -2429,7 +2628,8 @@ "totalSpawned", "lastError", "lastErrorAt", - "backoffUntil" + "backoffUntil", + "runners" ] }, "toolchain": { @@ -2654,6 +2854,55 @@ "null" ], "format": "date-time" + }, + "runners": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "image": { + "type": "string" + }, + "state": { + "type": "string" + }, + "status": { + "type": "string" + }, + "startedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "gitHubRunnerId": { + "type": [ + "integer", + "null" + ] + }, + "stale": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "image", + "state", + "status", + "startedAt", + "gitHubRunnerId", + "stale" + ] + } } }, "required": [ @@ -2662,7 +2911,8 @@ "totalSpawned", "lastError", "lastErrorAt", - "backoffUntil" + "backoffUntil", + "runners" ] } }, @@ -2784,6 +3034,55 @@ "null" ], "format": "date-time" + }, + "runners": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "image": { + "type": "string" + }, + "state": { + "type": "string" + }, + "status": { + "type": "string" + }, + "startedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "gitHubRunnerId": { + "type": [ + "integer", + "null" + ] + }, + "stale": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "image", + "state", + "status", + "startedAt", + "gitHubRunnerId", + "stale" + ] + } } }, "required": [ @@ -2792,7 +3091,8 @@ "totalSpawned", "lastError", "lastErrorAt", - "backoffUntil" + "backoffUntil", + "runners" ] }, "toolchain": { @@ -3105,6 +3405,55 @@ "null" ], "format": "date-time" + }, + "runners": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "image": { + "type": "string" + }, + "state": { + "type": "string" + }, + "status": { + "type": "string" + }, + "startedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "gitHubRunnerId": { + "type": [ + "integer", + "null" + ] + }, + "stale": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "image", + "state", + "status", + "startedAt", + "gitHubRunnerId", + "stale" + ] + } } }, "required": [ @@ -3113,7 +3462,8 @@ "totalSpawned", "lastError", "lastErrorAt", - "backoffUntil" + "backoffUntil", + "runners" ] }, "toolchain": { @@ -3382,6 +3732,55 @@ "null" ], "format": "date-time" + }, + "runners": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "image": { + "type": "string" + }, + "state": { + "type": "string" + }, + "status": { + "type": "string" + }, + "startedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "gitHubRunnerId": { + "type": [ + "integer", + "null" + ] + }, + "stale": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "image", + "state", + "status", + "startedAt", + "gitHubRunnerId", + "stale" + ] + } } }, "required": [ @@ -3390,7 +3789,8 @@ "totalSpawned", "lastError", "lastErrorAt", - "backoffUntil" + "backoffUntil", + "runners" ] }, "toolchain": { @@ -3695,6 +4095,55 @@ "null" ], "format": "date-time" + }, + "runners": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "image": { + "type": "string" + }, + "state": { + "type": "string" + }, + "status": { + "type": "string" + }, + "startedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "gitHubRunnerId": { + "type": [ + "integer", + "null" + ] + }, + "stale": { + "type": "boolean" + } + }, + "required": [ + "id", + "name", + "image", + "state", + "status", + "startedAt", + "gitHubRunnerId", + "stale" + ] + } } }, "required": [ @@ -3703,7 +4152,8 @@ "totalSpawned", "lastError", "lastErrorAt", - "backoffUntil" + "backoffUntil", + "runners" ] }, "toolchain": { diff --git a/src/Watchtower.Application.Tests/CiRunnerContainerSpecTests.cs b/src/Watchtower.Application.Tests/CiRunnerContainerSpecTests.cs index 3457560..cc47951 100644 --- a/src/Watchtower.Application.Tests/CiRunnerContainerSpecTests.cs +++ b/src/Watchtower.Application.Tests/CiRunnerContainerSpecTests.cs @@ -104,6 +104,53 @@ public void SpecHash_IsStableForUnchangedSettings() => CiRunnerOrchestrator.ComputeSpecHash(Repo(allowDockerSocket: true), "runner:latest"), CiRunnerOrchestrator.ComputeSpecHash(Repo(allowDockerSocket: true), "runner:latest")); + private static DockerContainerInfo Container(string? specHash, string? runnerId = "12345") { + var labels = new Dictionary(); + if (specHash is not null) labels[CiRunnerOrchestrator.SpecHashLabel] = specHash; + if (runnerId is not null) labels[CiRunnerOrchestrator.RunnerIdLabel] = runnerId; + return new DockerContainerInfo { + Id = "abcdef0123456789abcdef0123456789", + Names = ["/watchtower-box-widgets-1a2b3c4d"], + Image = "runner:latest", + State = "running", + Status = "Up 3 minutes", + Created = 1_700_000_000, + Labels = labels, + }; + } + + [Fact] + public void Describe_ReadsTheRunnerBackOffTheContainerForTheUi() { + var described = CiRunnerOrchestrator.Describe( + Container(CiRunnerOrchestrator.ComputeSpecHash(Repo(), "runner:latest")), + CiRunnerOrchestrator.ComputeSpecHash(Repo(), "runner:latest")); + + Assert.Equal("abcdef012345", described.Id); + Assert.Equal("watchtower-box-widgets-1a2b3c4d", described.Name); + Assert.Equal(12345, described.GitHubRunnerId); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_700_000_000), described.StartedAt); + Assert.False(described.Stale); + } + + /// + /// The badge the operator reads to know their saved change has not reached this runner yet. + /// An unlabelled container — spawned before the label existed — counts as stale too: the + /// reconcile loop is going to retire it, so the table has to say so. + /// + [Theory] + [InlineData("a-hash-from-older-settings")] + [InlineData(null)] + public void Describe_MarksARunnerSpawnedUnderOtherSettingsAsStale(string? containerHash) { + var described = CiRunnerOrchestrator.Describe( + Container(containerHash), CiRunnerOrchestrator.ComputeSpecHash(Repo(), "runner:latest")); + + Assert.True(described.Stale); + } + + [Fact] + public void Describe_LeavesTheGitHubIdNullWhenTheContainerPredatesTheLabel() => + Assert.Null(CiRunnerOrchestrator.Describe(Container(specHash: null, runnerId: null), "hash").GitHubRunnerId); + [Fact] public void VolumeInitContainer_ChownsBothCacheVolumesAsRoot() { var repo = Repo(); diff --git a/src/Watchtower.Application/Modules/Ci/CiContracts.cs b/src/Watchtower.Application/Modules/Ci/CiContracts.cs index 120287b..21cc5c0 100644 --- a/src/Watchtower.Application/Modules/Ci/CiContracts.cs +++ b/src/Watchtower.Application/Modules/Ci/CiContracts.cs @@ -39,13 +39,38 @@ public sealed record CiRegistrySyncDto(string Status, DateTimeOffset? SyncedAt, public sealed record CiReleaseSecretsSyncDto(string Status, DateTimeOffset? SyncedAt, string? Error); /// Live orchestrator state for one repo's runner slots. +/// +/// The individual runner containers behind , as of the last +/// reconcile pass. Empty until that pass has run — the counts move first, the table follows. +/// public sealed record CiRunnerStatusDto( int DesiredRunners, int RunningRunners, long TotalSpawned, string? LastError, DateTimeOffset? LastErrorAt, - DateTimeOffset? BackoffUntil); + DateTimeOffset? BackoffUntil, + CiRunnerContainerDto[] Runners); + +/// +/// One live runner container. Watchtower keeps no runner table in the database (the containers are +/// the state), so these are read back off the host's containers and their Watchtower labels. +/// +/// Short container id (12 chars), the form docker commands accept. +/// Docker's own status line, e.g. "Up 3 minutes". +/// The runner's id at GitHub; null if the container predates the label. +/// +/// Spawned under settings that have since changed — the orchestrator retires it once it is idle. +/// +public sealed record CiRunnerContainerDto( + string Id, + string Name, + string Image, + string State, + string Status, + DateTimeOffset? StartedAt, + long? GitHubRunnerId, + bool Stale); /// One detected toolchain of a CI repo ("dotnet 10.0 from workflow"). public sealed record CiToolchainDto(string Kind, string Version, string Source); @@ -124,7 +149,9 @@ internal static class CiMapping { status.TotalSpawned, status.LastError, status.LastErrorAt, - status.BackoffUntil); + status.BackoffUntil, + [.. status.Runners.Select(r => new CiRunnerContainerDto( + r.Id, r.Name, r.Image, r.State, r.Status, r.StartedAt, r.GitHubRunnerId, r.Stale))]); /// /// Projects the persisted sync columns into the wire DTO. The orchestrator's hash compare is diff --git a/src/Watchtower.Application/Modules/Ci/CiJsonContext.cs b/src/Watchtower.Application/Modules/Ci/CiJsonContext.cs index 1caa953..ecd70b2 100644 --- a/src/Watchtower.Application/Modules/Ci/CiJsonContext.cs +++ b/src/Watchtower.Application/Modules/Ci/CiJsonContext.cs @@ -10,6 +10,7 @@ namespace Watchtower.Application.Modules.Ci; UseStringEnumConverter = true)] [JsonSerializable(typeof(CiRepoDto))] [JsonSerializable(typeof(CiRunnerStatusDto))] +[JsonSerializable(typeof(CiRunnerContainerDto))] [JsonSerializable(typeof(CiRegistrySyncDto))] [JsonSerializable(typeof(CiReleaseSecretsSyncDto))] [JsonSerializable(typeof(CiToolchainDto))] diff --git a/src/Watchtower.Application/Services/CiRunnerOrchestrator.cs b/src/Watchtower.Application/Services/CiRunnerOrchestrator.cs index 0a9adc4..dd11698 100644 --- a/src/Watchtower.Application/Services/CiRunnerOrchestrator.cs +++ b/src/Watchtower.Application/Services/CiRunnerOrchestrator.cs @@ -134,6 +134,11 @@ private async Task ReconcileAsync(CancellationToken ct) { logger.LogError(ex, "Reconcile failed for CI repo {Repo}", repo.FullName); RecordFailure(repo.Id, ex.Message); } + // After the pass, not from the listing that opened it: this one reflects what the pass + // itself reaped, retired and spawned, so the operator's next poll shows the runner that + // was just created rather than the one that is already gone. Outside the try above so a + // failed reconcile still leaves the UI an accurate picture of what is actually there. + await SnapshotRunnersAsync(repo, ct); try { // Both Actions-config contributors (registry credentials, release configuration). Each // is already isolated inside the service; this catch is the backstop for anything that @@ -181,7 +186,7 @@ private async Task ReconcileRepoAsync(CiRepo repo, IReadOnlyList + /// Records the repo's runner containers on its status for the UI's runner table. Read from + /// Docker rather than from Watchtower state on purpose — the containers are the state + /// (docs/ci-runners/design.md), so a runner started by a previous Watchtower process, or one + /// that died in a way the loop has not reaped yet, shows up exactly as it is. + /// A snapshot failure is not a reconcile failure: it leaves the previous list in place. + /// + private async Task SnapshotRunnersAsync(CiRepo repo, CancellationToken ct) { + try { + var containers = await docker.ListContainersByLabelsAsync( + [$"{ManagedLabel}={ManagedLabelValue}", $"{RepoIdLabel}={repo.Id}"], ct); + var specHash = ComputeSpecHash(repo, ResolveImage(repo, options.CurrentValue.Ci)); + _status.GetOrAdd(repo.Id, _ => new CiRepoRunnerStatus()) + .UpdateRunners(containers.Where(IsRunning).Select(c => Describe(c, specHash)).ToList()); + } catch (OperationCanceledException) when (ct.IsCancellationRequested) { + throw; + } catch (Exception ex) { + logger.LogDebug(ex, "Could not snapshot runner containers for {Repo}", repo.FullName); + } + } + + /// Projects one runner container onto what the UI shows about it. + internal static CiRunnerContainer Describe(DockerContainerInfo container, string specHash) => new( + container.Id[..Math.Min(12, container.Id.Length)], + container.Names.FirstOrDefault()?.TrimStart('/') ?? "(unnamed)", + container.Image, + container.State, + container.Status, + container.Created > 0 ? DateTimeOffset.FromUnixTimeSeconds(container.Created) : null, + long.TryParse(container.Labels.GetValueOrDefault(RunnerIdLabel), out var runnerId) ? runnerId : null, + container.Labels.GetValueOrDefault(SpecHashLabel) != specHash); + + private static string ResolveImage(CiRepo repo, CiOptions ci) => + string.IsNullOrWhiteSpace(repo.RunnerImage) ? ci.RunnerImage : repo.RunnerImage; + private async Task SpawnRunnerAsync(CiRepo repo, string image, CiOptions ci, CancellationToken ct) { var instance = ci.ResolveInstanceName(); var runnerName = $"watchtower-{instance}-{Slug(repo.Name)}-{Guid.NewGuid().ToString("N")[..8]}"; @@ -552,6 +592,31 @@ private static string Slug(string value) { } } +/// +/// One runner container of a repo, as of the last reconcile pass. The orchestrator's own view of +/// what is on the host — Watchtower keeps no runner table in the database, so this is the only +/// place the individual containers are visible outside docker ps. +/// +/// Short container id (12 chars), the form docker commands accept. +/// Docker's human-readable status line, e.g. "Up 3 minutes". +/// +/// The runner's id at GitHub, for finding it under the repository's Actions runner settings. Null +/// for a container spawned before the id was labelled. +/// +/// +/// The container was spawned under settings that have since changed. It keeps whatever job it is +/// running and is retired once idle — see . +/// +public sealed record CiRunnerContainer( + string Id, + string Name, + string Image, + string State, + string Status, + DateTimeOffset? StartedAt, + long? GitHubRunnerId, + bool Stale); + /// Mutable per-repo runner state surfaced through ci.getRunnerStatus. public sealed class CiRepoRunnerStatus { private int _consecutiveFailures; @@ -566,6 +631,15 @@ public sealed class CiRepoRunnerStatus { /// True while a toolcache warmer container for this repo is running. public bool WarmerRunning { get; private set; } + /// + /// The repo's runner containers as of the last reconcile pass — empty until the first one has + /// run. Whole-list replacement rather than mutation, so a reader always sees one consistent + /// pass rather than a half-updated table. + /// + public IReadOnlyList Runners { get; private set; } = []; + + internal void UpdateRunners(IReadOnlyList runners) => Runners = runners; + /// /// True once the cache volumes were chowned to the runner user this orchestrator lifetime /// (in-memory: a restart re-runs the idempotent chown once). diff --git a/src/Watchtower.Application/Services/DockerEngineClient.cs b/src/Watchtower.Application/Services/DockerEngineClient.cs index 6ab121e..4f1c36a 100644 --- a/src/Watchtower.Application/Services/DockerEngineClient.cs +++ b/src/Watchtower.Application/Services/DockerEngineClient.cs @@ -1032,6 +1032,11 @@ public sealed record DockerContainerInfo { public string ImageId { get; init; } = string.Empty; public required string State { get; init; } public required string Status { get; init; } + /// + /// Creation time in Unix seconds (the API field is Created, an integer here — unlike the + /// RFC3339 string the inspect endpoint returns). 0 when the daemon omitted it. + /// + public long Created { get; init; } public required Dictionary Labels { get; init; } /// /// Mounts attached to the container, as returned by GET /containers/json (all states). diff --git a/src/watchtower-web/src/lib/types.ts b/src/watchtower-web/src/lib/types.ts index 9c0378d..b7cef2e 100644 --- a/src/watchtower-web/src/lib/types.ts +++ b/src/watchtower-web/src/lib/types.ts @@ -1597,6 +1597,31 @@ export interface CiRunnerStatus { lastError: string | null lastErrorAt: string | null backoffUntil: string | null + /** + * The individual containers behind `runningRunners`, as of the orchestrator's last reconcile + * pass. Empty until that pass has run — the counts move first, the table follows. + */ + runners: CiRunnerContainer[] +} + +/** + * One live runner container. Watchtower keeps no runner table in the database (the containers are + * the state), so these are read back off the host's containers and their Watchtower labels. + */ +export interface CiRunnerContainer { + /** Short container id (12 chars), the form `docker` commands accept. */ + id: string + name: string + image: string + /** Docker's container state: 'running', 'created' or 'restarting'. */ + state: string + /** Docker's own status line, e.g. "Up 3 minutes". */ + status: string + startedAt: string | null + /** The runner's id at GitHub; null if the container predates the label. */ + gitHubRunnerId: number | null + /** Spawned under settings that have since changed — the orchestrator retires it once idle. */ + stale: boolean } /** diff --git a/src/watchtower-web/src/modules/ci/ProductCiTab.tsx b/src/watchtower-web/src/modules/ci/ProductCiTab.tsx index fecec6f..67d10ae 100644 --- a/src/watchtower-web/src/modules/ci/ProductCiTab.tsx +++ b/src/watchtower-web/src/modules/ci/ProductCiTab.tsx @@ -2,12 +2,13 @@ import { useState } from 'react' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Boxes, Flame, Github, Hammer, KeyRound, Play } from 'lucide-react' import { api } from '@/lib/api' -import { absoluteTitle, timeAgo } from '@/lib/format' +import { absoluteTitle, formatUptime, timeAgo } from '@/lib/format' import type { CiLink, CiRegistrySync, CiReleaseSecretsSync, CiRepo, + CiRunnerContainer, CiToolchainProfile, Product, } from '@/lib/types' @@ -15,6 +16,7 @@ import { Badge } from '@/components/ui/badge' import { Banner } from '@/components/ui/banner' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' +import { DataList, type DataListColumn } from '@/components/ui/data-list' import { EmptyState } from '@/components/ui/empty-state' import { Field } from '@/components/ui/field' import { Input } from '@/components/ui/input' @@ -77,6 +79,140 @@ function WarmBadge({ profile }: { profile: CiToolchainProfile }) { } } +/** One runner container as a card, for the DataList's <768px layout. */ +function RunnerCard({ repo, runner }: { repo: CiRepo; runner: CiRunnerContainer }) { + return ( +
+
+
+
{runner.name}
+
{runner.id}
+
+ +
+
+ + {runner.startedAt ? `up ${formatUptime(runner.startedAt)}` : runner.status} + + {runner.image} + +
+
+ ) +} + +function RunnerStateBadges({ runner }: { runner: CiRunnerContainer }) { + return ( +
+ + {runner.state} + + {runner.stale && ( + + settings changed + + )} +
+ ) +} + +/** The runner as GitHub knows it — the same id the repository's runner settings list shows. */ +function RunnerGitHubLink({ repo, runner }: { repo: CiRepo; runner: CiRunnerContainer }) { + if (runner.gitHubRunnerId == null) return + return ( + + #{runner.gitHubRunnerId} + + ) +} + +/** + * The live runner containers behind the slot count. Watchtower keeps no runner table in the + * database — the containers *are* the state (docs/ci-runners/design.md) — so this is the only place + * they are visible outside `docker ps` on the host. The list is the orchestrator's last reconcile + * pass, which is why a runner spawned seconds ago can trail the count above it by one interval. + */ +function RunnerContainersCard({ repo }: { repo: CiRepo }) { + const runners = repo.runnerStatus?.runners ?? [] + + const columns: DataListColumn[] = [ + { + key: 'container', + header: 'Container', + cell: (r) => ( +
+
{r.name}
+
{r.id}
+
+ ), + }, + { + key: 'state', + header: 'State', + cell: (r) => , + }, + { + key: 'uptime', + header: 'Uptime', + cell: (r) => ( + + {r.startedAt ? formatUptime(r.startedAt) : r.status} + + ), + }, + { + key: 'image', + header: 'Image', + cell: (r) => {r.image}, + }, + { + key: 'github', + header: 'GitHub', + align: 'right', + cell: (r) => ( + + + + ), + }, + ] + + return ( + + +
+ Runner containers + + Ephemeral — each takes one job, exits, and is replaced. + +
+ r.id} + columns={columns} + renderCard={(r) => } + emptyState={ +

+ No runner containers on this host yet — the next reconcile pass starts them. +

+ } + aria-label="Runner containers" + /> +
+
+ ) +} + export function ProductCiTab({ product }: { product: Product }) { const { data: ci, @@ -320,6 +456,12 @@ function CiRepoPanel({ product, ci, repo }: { product: Product; ci: CiLink; repo + {/* The containers behind the slot count. Also rendered while CI is off if any are still + being torn down, so "disabled" does not look like "already gone". */} + {(repo.enabled || (repo.runnerStatus?.runners.length ?? 0) > 0) && ( + + )} + {/* Registry sync (docs/ci-runners/design.md, Secrets §1) */} update.mutate({ syncRegistryUrl: url })} />