From 9f8bb8dbec4341b6fe6544a41b16fab9b45da804 Mon Sep 17 00:00:00 2001 From: swimmesberger Date: Thu, 27 Aug 2026 21:41:01 +0200 Subject: [PATCH] feat(ci): show the live runner containers on the product's CI tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runner state was two numbers — "1 of 1 runner slot(s) live" — and nothing else. The containers behind them were only visible over SSH with `docker ps --filter label=watchtower.managed=ci-runner`, which is a poor answer to "is my runner actually up, and is it running the settings I just saved". (The design doc already promised container ids on `ci.getRunnerStatus`; they were never implemented.) `ci.getRunnerStatus` — and with it every response carrying a CiRepoDto — now includes the repo's runner containers: name, short id, Docker state and uptime, image, and the runner's id at GitHub. A container whose spec hash no longer matches the repo's settings is flagged stale, which the CI tab badges "settings changed": the operator can see that a saved change has not reached the running runner yet and that it is queued to be retired, instead of wondering why nothing happened. The rows are read off the host's containers and their Watchtower labels rather than from any new database state — the containers are the state (docs/ci-runners/design.md), so a runner left behind by a previous Watchtower process shows up exactly as it is. The snapshot is taken after each repo's reconcile pass rather than from the listing that opened it, so it reflects what that pass just reaped, retired and spawned. --- docs/ci-runners/design.md | 10 +- rpc-schema.json | 468 +++++++++++++++++- .../CiRunnerContainerSpecTests.cs | 47 ++ .../Modules/Ci/CiContracts.cs | 31 +- .../Modules/Ci/CiJsonContext.cs | 1 + .../Services/CiRunnerOrchestrator.cs | 76 ++- .../Services/DockerEngineClient.cs | 5 + src/watchtower-web/src/lib/types.ts | 25 + .../src/modules/ci/ProductCiTab.tsx | 144 +++++- 9 files changed, 793 insertions(+), 14 deletions(-) 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 })} />