From 330571842819bc7360d8b77248b4933d9e032511 Mon Sep 17 00:00:00 2001 From: Max Epperlein Date: Thu, 7 May 2026 11:16:06 +0200 Subject: [PATCH 1/3] feat: workflow names as subheading for workflow health --- .../components/WorkflowHealthSection.test.tsx | 30 ++++++++++++++++--- .../src/components/WorkflowHealthSection.tsx | 5 ++-- .../components/ui/TruncatingTitle.test.tsx | 18 +++++++++++ .../web/src/components/ui/TruncatingTitle.tsx | 13 ++++---- 4 files changed, 55 insertions(+), 11 deletions(-) diff --git a/packages/web/src/components/WorkflowHealthSection.test.tsx b/packages/web/src/components/WorkflowHealthSection.test.tsx index afb71cb..60fd8d4 100644 --- a/packages/web/src/components/WorkflowHealthSection.test.tsx +++ b/packages/web/src/components/WorkflowHealthSection.test.tsx @@ -89,10 +89,32 @@ describe("WorkflowHealthSection", () => { expect(container).toBeEmptyDOMElement() }) - it("renders the section title with configured workflow names", () => { + it("renders 'Workflow Health' as the section title", () => { const repoRuns = [makeRepoRuns("owner/repo", [makeRun({ workflowName: "publish" })])] render() - expect(screen.getByText("Workflow Health: publish, scan")).toBeInTheDocument() + expect(screen.getByText("Workflow Health")).toBeInTheDocument() + }) + + it("renders workflow names as a subtitle on a separate element", () => { + const repoRuns = [makeRepoRuns("owner/repo", [makeRun({ workflowName: "publish" })])] + render() + expect(screen.getByText("publish, scan")).toBeInTheDocument() + }) + + it("title and subtitle are separate DOM elements", () => { + const repoRuns = [makeRepoRuns("owner/repo", [makeRun({ workflowName: "publish" })])] + const { container } = render() + const title = container.querySelector(".card-title") + const subtitle = container.querySelector(".card-header .text-muted.text-small") + expect(title?.textContent).toBe("Workflow Health") + expect(subtitle?.textContent).toBe("publish, scan") + }) + + it("card-header uses stretch alignment so the subtitle span is width-constrained", () => { + const repoRuns = [makeRepoRuns("owner/repo", [makeRun({ workflowName: "publish" })])] + const { container } = render() + const header = container.querySelector(".card-header") as HTMLElement + expect(header.style.alignItems).toBe("stretch") }) it("renders owner and repo name inside the card", () => { @@ -160,8 +182,8 @@ describe("WorkflowHealthSection", () => { const run1 = makeRun({ workflowName: "publish" }) const run2 = makeRun({ workflowName: "publish" }) const repoRuns = [makeRepoRuns("owner/repo", [run1, run2])] - render() - expect(screen.getAllByText("publish")).toHaveLength(1) + const { container } = render() + expect(container.querySelectorAll(".latest-run-card")).toHaveLength(1) }) it("does not render non-matching workflow runs", () => { diff --git a/packages/web/src/components/WorkflowHealthSection.tsx b/packages/web/src/components/WorkflowHealthSection.tsx index 47de7b5..d9c89cf 100644 --- a/packages/web/src/components/WorkflowHealthSection.tsx +++ b/packages/web/src/components/WorkflowHealthSection.tsx @@ -150,8 +150,9 @@ export function WorkflowHealthSection({ watchWorkflows, repoRuns }: WorkflowHeal return ( -
- +
+ Workflow Health +
{cards.map(({ fullName, run }) => ( diff --git a/packages/web/src/components/ui/TruncatingTitle.test.tsx b/packages/web/src/components/ui/TruncatingTitle.test.tsx index 98ab020..4c2797a 100644 --- a/packages/web/src/components/ui/TruncatingTitle.test.tsx +++ b/packages/web/src/components/ui/TruncatingTitle.test.tsx @@ -146,6 +146,24 @@ describe("TruncatingTitle", () => { expect(screen.getByText("X: a +2 more")).toBeInTheDocument() }) + // ── Prefix-less mode ─────────────────────────────────────────────────────── + + it("renders items without a prefix or colon when prefix is omitted", () => { + render() + expect(screen.getByText("publish, scan")).toBeInTheDocument() + expect(screen.queryByText(/:/)).not.toBeInTheDocument() + }) + + it("truncates correctly with no prefix", () => { + render( + true} + /> + ) + expect(screen.getByText("publish +2 more")).toBeInTheDocument() + }) + // ── ResizeObserver lifecycle ──────────────────────────────────────────────── it("sets up a ResizeObserver on mount", () => { diff --git a/packages/web/src/components/ui/TruncatingTitle.tsx b/packages/web/src/components/ui/TruncatingTitle.tsx index 92e7146..90106f0 100644 --- a/packages/web/src/components/ui/TruncatingTitle.tsx +++ b/packages/web/src/components/ui/TruncatingTitle.tsx @@ -1,7 +1,8 @@ import { useState, useRef, useLayoutEffect, useEffect } from "react" interface TruncatingTitleProps { - prefix: string + /** Optional label prepended as `prefix: items`. Omit to render items only. */ + prefix?: string items: string[] className?: string /** @@ -28,7 +29,7 @@ const defaultIsOverflowing = (el: HTMLSpanElement) => el.scrollWidth > el.client * side-effects from empty-string → non-empty transitions. */ export function TruncatingTitle({ - prefix, + prefix = "", items, className, _isOverflowing = defaultIsOverflowing, @@ -53,13 +54,15 @@ export function TruncatingTitle({ // Greedy search: reduce count until truncated text fits (min 1). // useLayoutEffect + setState is intentional: fires before paint so the // browser never renders the intermediate probe strings. + const fmt = (text: string) => prefix ? `${prefix}: ${text}` : text + let count = items.length - probe(`${prefix}: ${items.join(", ")}`) + probe(fmt(items.join(", "))) if (_isOverflowing(el)) { while (count > 1) { count-- const hidden = items.length - count - probe(`${prefix}: ${items.slice(0, count).join(", ")} +${hidden} more`) + probe(fmt(`${items.slice(0, count).join(", ")} +${hidden} more`)) if (!_isOverflowing(el)) break } } @@ -87,7 +90,7 @@ export function TruncatingTitle({ className={className} style={{ whiteSpace: "nowrap", overflow: "hidden", display: "block", minWidth: 0, flex: 1 }} > - {`${prefix}: ${displayed}${suffix}`} + {prefix ? `${prefix}: ${displayed}${suffix}` : `${displayed}${suffix}`} ) } From e492d19b20fed967c49cc5426bcd30663f502bf9 Mon Sep 17 00:00:00 2001 From: Max Epperlein Date: Thu, 7 May 2026 18:20:09 +0200 Subject: [PATCH 2/3] feat: repository detail runner section --- packages/server/src/lib/cache.ts | 1 + packages/server/src/routes/proxy.ts | 1 + packages/web/src/api/index.ts | 24 ++++++ .../_app/-repositories.$owner.$repo.test.tsx | 80 ++++++++++++++++++- .../_app/-runs_.$owner.$repo.$runId.test.tsx | 16 +++- .../routes/_app/repositories.$owner.$repo.tsx | 55 ++++++++++++- .../routes/_app/runs_.$owner.$repo.$runId.tsx | 13 ++- packages/web/src/types/index.ts | 9 +++ 8 files changed, 192 insertions(+), 7 deletions(-) diff --git a/packages/server/src/lib/cache.ts b/packages/server/src/lib/cache.ts index ec8e8b3..509e88c 100644 --- a/packages/server/src/lib/cache.ts +++ b/packages/server/src/lib/cache.ts @@ -9,6 +9,7 @@ export const TTL = { workflows: 10 * 60 * 1000, repos: 15 * 60 * 1000, orgs: 30 * 60 * 1000, + runners: 5 * 60 * 1000, score: 24 * 60 * 60 * 1000, yaml: 7 * 24 * 60 * 60 * 1000, logs: 7 * 24 * 60 * 60 * 1000, diff --git a/packages/server/src/routes/proxy.ts b/packages/server/src/routes/proxy.ts index 04d3978..1073f9d 100644 --- a/packages/server/src/routes/proxy.ts +++ b/packages/server/src/routes/proxy.ts @@ -233,6 +233,7 @@ function resolveTtl(path: string): number { if (path.match(/\/repos\/[^/]+\/[^/]+\/check-runs/)) return TTL.annotations if (path.match(/\/repos\/[^/]+\/[^/]+\/git\/trees/)) return TTL.yaml if (path.match(/\/repos\/[^/]+\/[^/]+\/actions\/jobs\/[^/]+\/logs/)) return TTL.logs + if (path.match(/\/repos\/[^/]+\/[^/]+\/actions\/runners/)) return TTL.runners if ( path.match(/\/repos\/[^/]+\/[^/]+\/community\/profile/) || path.match(/\/repos\/[^/]+\/[^/]+\/branches\/[^/]+\/protection/) diff --git a/packages/web/src/api/index.ts b/packages/web/src/api/index.ts index ece2e4f..8594c66 100644 --- a/packages/web/src/api/index.ts +++ b/packages/web/src/api/index.ts @@ -10,6 +10,7 @@ import type { WorkflowJob, RunArtifact, RepositoryScore, + SelfHostedRunner, RunStatus, RunConclusion, } from "../types/index.js" @@ -50,6 +51,11 @@ interface GHJob { interface GHArtifact { id: number; name: string; size_in_bytes: number; expired: boolean } +interface GHRunner { + id: number; name: string; os: string + status: "online" | "offline"; busy: boolean + labels: Array<{ id: number; name: string; type: string }> +} // ── Normalizers ─────────────────────────────────────────────────────────────── @@ -133,6 +139,17 @@ function normalizeJob(j: GHJob): WorkflowJob { } } +function normalizeRunner(r: GHRunner): SelfHostedRunner { + return { + id: r.id, + name: r.name, + os: r.os, + status: r.status, + busy: r.busy, + labels: r.labels.map((l) => l.name), + } +} + // ── Auth ────────────────────────────────────────────────────────────────────── export async function getAuthConfig(): Promise { @@ -300,6 +317,13 @@ export async function cancelRun(owner: string, repo: string, runId: number): Pro ) } +export async function getRepoRunners(owner: string, repo: string): Promise { + const res = await fetchApi<{ runners: GHRunner[] }>( + `/proxy/repos/${owner}/${repo}/actions/runners` + ) + return (res.runners ?? []).map(normalizeRunner) +} + // ── Scoring ─────────────────────────────────────────────────────────────────── export async function getRepoScore( diff --git a/packages/web/src/routes/_app/-repositories.$owner.$repo.test.tsx b/packages/web/src/routes/_app/-repositories.$owner.$repo.test.tsx index 2512c18..b691d76 100644 --- a/packages/web/src/routes/_app/-repositories.$owner.$repo.test.tsx +++ b/packages/web/src/routes/_app/-repositories.$owner.$repo.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest" import { render, screen } from "@testing-library/react" -import type { WorkflowRun, Workflow } from "../../types/index.js" +import type { WorkflowRun, Workflow, SelfHostedRunner } from "../../types/index.js" // ── Mocks (must precede imports of the module under test) ───────────────────── @@ -27,6 +27,7 @@ vi.mock("../../api/index.js", () => ({ getRepoScore: vi.fn(), getWorkflows: vi.fn(), getRuns: vi.fn(), + getRepoRunners: vi.fn(), })) vi.mock("../../components/charts/RunCharts.js", () => ({ @@ -72,7 +73,7 @@ vi.mock("../../components/ui/EventBadge.js", () => ({ import React from "react" import { useQuery } from "@tanstack/react-query" -import { Route, triggersFromRuns, WorkflowsCard, RecentRunsCard } from "./repositories.$owner.$repo.js" +import { Route, triggersFromRuns, WorkflowsCard, RecentRunsCard, RunnersCard } from "./repositories.$owner.$repo.js" // ── Test helpers ─────────────────────────────────────────────────────────────── @@ -109,6 +110,18 @@ function makeRun(overrides: Partial = {}): WorkflowRun { } } +function makeRunner(overrides: Partial = {}): SelfHostedRunner { + return { + id: ++_id, + name: "runner-1", + os: "linux", + status: "online", + busy: false, + labels: ["self-hosted", "linux"], + ...overrides, + } +} + function makeWorkflow(overrides: Partial = {}): Workflow { const id = ++_id return { @@ -421,3 +434,66 @@ describe("RepositoryDetail page", () => { expect(container.querySelector(".health-repo-label-owner")).not.toBeNull() }) }) + +// ── RunnersCard ─────────────────────────────────────────────────────────────── + +describe("RunnersCard", () => { + it("shows nothing while loading", () => { + mockUseQuery.mockReturnValue({ data: undefined, isLoading: true } as ReturnType) + render() + expect(screen.queryByText("No self-hosted runners configured.")).not.toBeInTheDocument() + }) + + it("shows empty state when runners data is undefined and not loading", () => { + mockUseQuery.mockReturnValue({ data: undefined, isLoading: false } as ReturnType) + render() + expect(screen.getByText("No self-hosted runners configured.")).toBeInTheDocument() + }) + + it("shows empty state when runners array is empty", () => { + mockUseQuery.mockReturnValue({ data: [], isLoading: false } as ReturnType) + render() + expect(screen.getByText("No self-hosted runners configured.")).toBeInTheDocument() + }) + + it("renders a row per runner", () => { + mockUseQuery.mockReturnValue({ data: [makeRunner(), makeRunner({ name: "runner-2" })] } as ReturnType) + const { container } = render() + expect(container.querySelectorAll("tbody tr")).toHaveLength(2) + }) + + it("shows runner name, OS, status, and labels", () => { + mockUseQuery.mockReturnValue({ + data: [makeRunner({ name: "my-runner", os: "linux", status: "online", labels: ["self-hosted", "x64"] })], + } as ReturnType) + render() + expect(screen.getByText("my-runner")).toBeInTheDocument() + expect(screen.getByText("linux")).toBeInTheDocument() + expect(screen.getByText("online")).toBeInTheDocument() + expect(screen.getByText("self-hosted, x64")).toBeInTheDocument() + }) + + it("shows 'busy' indicator when runner is busy", () => { + mockUseQuery.mockReturnValue({ + data: [makeRunner({ status: "online", busy: true })], + } as ReturnType) + render() + expect(screen.getByText("online · busy")).toBeInTheDocument() + }) + + it("shows offline status for offline runner", () => { + mockUseQuery.mockReturnValue({ + data: [makeRunner({ status: "offline", busy: false })], + } as ReturnType) + render() + expect(screen.getByText("offline")).toBeInTheDocument() + }) + + it("shows '—' in labels cell when runner has no labels", () => { + mockUseQuery.mockReturnValue({ + data: [makeRunner({ labels: [] })], + } as ReturnType) + render() + expect(screen.getByText("—")).toBeInTheDocument() + }) +}) diff --git a/packages/web/src/routes/_app/-runs_.$owner.$repo.$runId.test.tsx b/packages/web/src/routes/_app/-runs_.$owner.$repo.$runId.test.tsx index f103e4f..b2f4a73 100644 --- a/packages/web/src/routes/_app/-runs_.$owner.$repo.$runId.test.tsx +++ b/packages/web/src/routes/_app/-runs_.$owner.$repo.$runId.test.tsx @@ -262,11 +262,23 @@ describe("JobsCard", () => { expect(screen.getByText("ubuntu-22.04")).toBeInTheDocument() }) - it("shows '—' when runner is null", () => { - render() + it("shows '—' when runner is null and labels are empty", () => { + render() expect(screen.getByText("—")).toBeInTheDocument() }) + it("shows labels when runnerName is null", () => { + render() + expect(screen.getByText("ubuntu-latest")).toBeInTheDocument() + expect(screen.queryByText("—")).not.toBeInTheDocument() + }) + + it("shows runnerName and labels together", () => { + render() + expect(screen.getByText("runner-1")).toBeInTheDocument() + expect(screen.getByText("(self-hosted, linux)")).toBeInTheDocument() + }) + it("shows job count in card header", () => { render() expect(screen.getByTestId("card-header")).toHaveTextContent("Jobs (3)") diff --git a/packages/web/src/routes/_app/repositories.$owner.$repo.tsx b/packages/web/src/routes/_app/repositories.$owner.$repo.tsx index 98667fa..d0904ee 100644 --- a/packages/web/src/routes/_app/repositories.$owner.$repo.tsx +++ b/packages/web/src/routes/_app/repositories.$owner.$repo.tsx @@ -1,6 +1,6 @@ import { createFileRoute, Link, Outlet, useChildMatches } from "@tanstack/react-router" import { useQuery } from "@tanstack/react-query" -import { getRepo, getRepoScore, getWorkflows, getRuns } from "../../api/index.js" +import { getRepo, getRepoScore, getWorkflows, getRuns, getRepoRunners } from "../../api/index.js" import { StatusBadge, TierBadge } from "../../components/ui/Badge.js" import { Button } from "../../components/ui/Button.js" import { Card, CardHeader } from "../../components/ui/Card.js" @@ -8,7 +8,7 @@ import { PageSpinner } from "../../components/ui/Spinner.js" import { BuildTrendChart, DurationChart } from "../../components/charts/RunCharts.js" import { EventBadge } from "../../components/ui/EventBadge.js" import { formatRelativeTime, formatDuration, formatDateTime } from "../../lib/utils.js" -import type { RepositoryScore, WorkflowRun } from "../../types/index.js" +import type { RepositoryScore, SelfHostedRunner, WorkflowRun } from "../../types/index.js" export const Route = createFileRoute("/_app/repositories/$owner/$repo")({ component: RepositoryDetail, @@ -55,6 +55,7 @@ function RepositoryDetail() {
+
@@ -242,6 +243,56 @@ function BuildChartsCard({ owner, repo }: { owner: string; repo: string }) { ) } +export function RunnersCard({ owner, repo }: { owner: string; repo: string }) { + const { data: runners, isLoading } = useQuery({ + queryKey: ["runners", owner, repo], + queryFn: () => getRepoRunners(owner, repo), + staleTime: 5 * 60 * 1000, + }) + + return ( + + + {isLoading ? null : !runners || runners.length === 0 ? ( +

No self-hosted runners configured.

+ ) : ( + + )} +
+ ) +} + +function RunnersTable({ runners }: { runners: SelfHostedRunner[] }) { + return ( +
+ + + + + + + + + + + {runners.map((r) => ( + + + + + + + ))} + +
NameOSStatusLabels
{r.name}{r.os} + + {r.status}{r.busy ? " · busy" : ""} + + {r.labels.length > 0 ? r.labels.join(", ") : "—"}
+
+ ) +} + export function RecentRunsCard({ owner, repo }: { owner: string; repo: string }) { const { data: runsData } = useQuery({ queryKey: ["runs", `${owner}/${repo}`, "recent"], diff --git a/packages/web/src/routes/_app/runs_.$owner.$repo.$runId.tsx b/packages/web/src/routes/_app/runs_.$owner.$repo.$runId.tsx index b6c735a..07fdb68 100644 --- a/packages/web/src/routes/_app/runs_.$owner.$repo.$runId.tsx +++ b/packages/web/src/routes/_app/runs_.$owner.$repo.$runId.tsx @@ -280,7 +280,18 @@ export function JobsCard({ {job.steps.filter((s) => s.conclusion === "success").length} / {job.steps.length} {formatDuration(job.startedAt, job.completedAt)} - {job.runnerName ?? "—"} + + {job.runnerName + ? <> + {job.runnerName} + {job.labels.length > 0 && ( + ({job.labels.join(", ")}) + )} + + : job.labels.length > 0 + ? {job.labels.join(", ")} + : "—"} + {expanded.has(job.id) && ( diff --git a/packages/web/src/types/index.ts b/packages/web/src/types/index.ts index e8dbaf2..bc7c5cc 100644 --- a/packages/web/src/types/index.ts +++ b/packages/web/src/types/index.ts @@ -127,6 +127,15 @@ export interface RunArtifact { expired: boolean } +export interface SelfHostedRunner { + id: number + name: string + os: string + status: "online" | "offline" + busy: boolean + labels: string[] +} + // ── Scoring ─────────────────────────────────────────────────────────────────── export interface CheckResult { From 6a2bf3a61904ca81defe9e60fd249d1a19c08815 Mon Sep 17 00:00:00 2001 From: Max Epperlein Date: Thu, 7 May 2026 18:31:44 +0200 Subject: [PATCH 3/3] feat: workflow health section use reponame without owner as default, show only when different owners with same reponame --- .../components/WorkflowHealthSection.test.tsx | 71 ++++++++++++++++++- .../src/components/WorkflowHealthSection.tsx | 48 +++++++++++-- 2 files changed, 111 insertions(+), 8 deletions(-) diff --git a/packages/web/src/components/WorkflowHealthSection.test.tsx b/packages/web/src/components/WorkflowHealthSection.test.tsx index 60fd8d4..d51fcf1 100644 --- a/packages/web/src/components/WorkflowHealthSection.test.tsx +++ b/packages/web/src/components/WorkflowHealthSection.test.tsx @@ -17,7 +17,7 @@ afterEach(() => { vi.clearAllMocks() }) -import { WorkflowHealthSection } from "./WorkflowHealthSection.js" +import { WorkflowHealthSection, repoDisplayLabel } from "./WorkflowHealthSection.js" import type { WorkflowRun } from "../types/index.js" // ── Helpers ─────────────────────────────────────────────────────────────────── @@ -117,11 +117,11 @@ describe("WorkflowHealthSection", () => { expect(header.style.alignItems).toBe("stretch") }) - it("renders owner and repo name inside the card", () => { + it("shows only repo short-name when there is no name collision", () => { const repoRuns = [makeRepoRuns("owner/repo", [makeRun({ workflowName: "publish" })])] render() - expect(screen.getByText("owner/")).toBeInTheDocument() expect(screen.getByText("repo")).toBeInTheDocument() + expect(screen.queryByText(/owner/)).not.toBeInTheDocument() }) it("renders a run card for each matched workflow", () => { @@ -196,4 +196,69 @@ describe("WorkflowHealthSection", () => { render() expect(screen.queryByText("ci")).not.toBeInTheDocument() }) + + it("does not show owner prefix when the same repo has multiple workflow cards", () => { + const repoRuns = [ + makeRepoRuns("owner/api", [ + makeRun({ workflowName: "publish" }), + makeRun({ workflowName: "scan" }), + ]), + ] + render() + expect(screen.queryByText(/…\//)).not.toBeInTheDocument() + expect(screen.getAllByText("api")).toHaveLength(2) + }) + + it("shows abbreviated owner prefix when two repos share the same short-name", () => { + const repoRuns = [ + makeRepoRuns("manifold/api", [makeRun({ workflowName: "publish" })]), + makeRepoRuns("acmecorp/api", [makeRun({ workflowName: "publish" })]), + ] + render() + expect(screen.getByText("man…/")).toBeInTheDocument() + expect(screen.getByText("acm…/")).toBeInTheDocument() + expect(screen.getAllByText("api")).toHaveLength(2) + }) + + it("does not show owner prefix for repos whose short-name is unique", () => { + const repoRuns = [ + makeRepoRuns("manifold/api", [makeRun({ workflowName: "publish" })]), + makeRepoRuns("acmecorp/api", [makeRun({ workflowName: "publish" })]), + makeRepoRuns("acmecorp/website", [makeRun({ workflowName: "publish" })]), + ] + render() + // "api" collides — both get prefix + expect(screen.getByText("man…/")).toBeInTheDocument() + expect(screen.getByText("acm…/")).toBeInTheDocument() + // "website" is unique — no prefix + expect(screen.getByText("website")).toBeInTheDocument() + expect(screen.queryByText(/acm…\/.*website/)).not.toBeInTheDocument() + }) +}) + +// ── repoDisplayLabel ────────────────────────────────────────────────────────── + +describe("repoDisplayLabel", () => { + it("returns null owner when repo name is unique", () => { + const result = repoDisplayLabel("acme/api", new Set()) + expect(result).toEqual({ owner: null, repo: "api" }) + }) + + it("returns abbreviated owner when repo name is in the duplicate set", () => { + const result = repoDisplayLabel("manifold/api", new Set(["api"])) + expect(result).toEqual({ owner: "man…", repo: "api" }) + }) + + it("abbreviation uses exactly the first 3 characters of the owner", () => { + const result = repoDisplayLabel("ab/api", new Set(["api"])) + expect(result.owner).toBe("ab…") + + const result2 = repoDisplayLabel("abcdefgh/api", new Set(["api"])) + expect(result2.owner).toBe("abc…") + }) + + it("extracts the repo part correctly for names containing slashes only once", () => { + const result = repoDisplayLabel("my-org/my-repo", new Set()) + expect(result.repo).toBe("my-repo") + }) }) diff --git a/packages/web/src/components/WorkflowHealthSection.tsx b/packages/web/src/components/WorkflowHealthSection.tsx index d9c89cf..a8c1cba 100644 --- a/packages/web/src/components/WorkflowHealthSection.tsx +++ b/packages/web/src/components/WorkflowHealthSection.tsx @@ -24,6 +24,22 @@ function pickRun(runs: WorkflowRun[]): WorkflowRun | undefined { return runs.find((r) => r.status === "in_progress" || r.status === "queued") ?? runs[0] } +/** + * Returns the display label for a repo within a set of cards. + * Normally just the repo short-name; when another card shares that + * short-name (different owner), prefixes the first 3 chars of the + * owner followed by "…/" to disambiguate — e.g. "man…/api". + */ +export function repoDisplayLabel(fullName: string, duplicateRepoNames: Set): { owner: string | null; repo: string } { + const slash = fullName.indexOf("/") + const owner = fullName.slice(0, slash) + const repo = fullName.slice(slash + 1) + if (duplicateRepoNames.has(repo)) { + return { owner: `${owner.slice(0, 3)}…`, repo } + } + return { owner: null, repo } +} + // ── Run dot ─────────────────────────────────────────────────────────────────── function RunDot({ status, conclusion }: { status: WorkflowRun["status"]; conclusion: WorkflowRun["conclusion"] }) { @@ -38,13 +54,20 @@ function RunDot({ status, conclusion }: { status: WorkflowRun["status"]; conclus // ── Single run card ─────────────────────────────────────────────────────────── -function HealthRunCard({ run, fullName }: { run: WorkflowRun; fullName: string }) { - const [owner, repo] = fullName.split("/") +function HealthRunCard({ run, fullName, duplicateRepoNames }: { + run: WorkflowRun + fullName: string + duplicateRepoNames: Set +}) { + const slash = fullName.indexOf("/") + const ownerRaw = fullName.slice(0, slash) + const repo = fullName.slice(slash + 1) const isActive = run.status === "in_progress" || run.status === "queued" const commitUrl = `https://github.com/${fullName}/commit/${run.headSha}` const duration = formatDuration(run.runStartedAt, isActive ? null : run.updatedAt) const variant = runStatusVariant(run.status, run.conclusion) const color = VARIANT_COLOR[variant] + const { owner: ownerPrefix } = repoDisplayLabel(fullName, duplicateRepoNames) return (
- {owner}/{repo} + {ownerPrefix && {ownerPrefix}/}{repo}
{run.workflowName ?? run.name} @@ -148,6 +171,21 @@ export function WorkflowHealthSection({ watchWorkflows, repoRuns }: WorkflowHeal if (cards.length === 0) return null + // Detect repo short-names that appear under more than one distinct owner + const repoOwnerMap = new Map>() + for (const { fullName } of cards) { + const slash = fullName.indexOf("/") + const owner = fullName.slice(0, slash) + const repo = fullName.slice(slash + 1) + if (!repoOwnerMap.has(repo)) repoOwnerMap.set(repo, new Set()) + repoOwnerMap.get(repo)!.add(owner) + } + const duplicateRepoNames = new Set( + [...repoOwnerMap.entries()] + .filter(([, owners]) => owners.size > 1) + .map(([repo]) => repo) + ) + return (
@@ -156,7 +194,7 @@ export function WorkflowHealthSection({ watchWorkflows, repoRuns }: WorkflowHeal
{cards.map(({ fullName, run }) => ( - + ))}