Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/server/src/lib/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/server/src/routes/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down
24 changes: 24 additions & 0 deletions packages/web/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
WorkflowJob,
RunArtifact,
RepositoryScore,
SelfHostedRunner,
RunStatus,
RunConclusion,
} from "../types/index.js"
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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<AuthConfig> {
Expand Down Expand Up @@ -300,6 +317,13 @@ export async function cancelRun(owner: string, repo: string, runId: number): Pro
)
}

export async function getRepoRunners(owner: string, repo: string): Promise<SelfHostedRunner[]> {
const res = await fetchApi<{ runners: GHRunner[] }>(
`/proxy/repos/${owner}/${repo}/actions/runners`
)
return (res.runners ?? []).map(normalizeRunner)
}

// ── Scoring ───────────────────────────────────────────────────────────────────

export async function getRepoScore(
Expand Down
101 changes: 94 additions & 7 deletions packages/web/src/components/WorkflowHealthSection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -89,17 +89,39 @@ 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(<WorkflowHealthSection watchWorkflows={["publish", "scan"]} repoRuns={repoRuns} />)
expect(screen.getByText("Workflow Health: publish, scan")).toBeInTheDocument()
expect(screen.getByText("Workflow Health")).toBeInTheDocument()
})

it("renders owner and repo name inside the card", () => {
it("renders workflow names as a subtitle on a separate element", () => {
const repoRuns = [makeRepoRuns("owner/repo", [makeRun({ workflowName: "publish" })])]
render(<WorkflowHealthSection watchWorkflows={["publish", "scan"]} repoRuns={repoRuns} />)
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(<WorkflowHealthSection watchWorkflows={["publish", "scan"]} repoRuns={repoRuns} />)
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(<WorkflowHealthSection watchWorkflows={["publish"]} repoRuns={repoRuns} />)
const header = container.querySelector(".card-header") as HTMLElement
expect(header.style.alignItems).toBe("stretch")
})

it("shows only repo short-name when there is no name collision", () => {
const repoRuns = [makeRepoRuns("owner/repo", [makeRun({ workflowName: "publish" })])]
render(<WorkflowHealthSection watchWorkflows={["publish"]} repoRuns={repoRuns} />)
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", () => {
Expand Down Expand Up @@ -160,8 +182,8 @@ describe("WorkflowHealthSection", () => {
const run1 = makeRun({ workflowName: "publish" })
const run2 = makeRun({ workflowName: "publish" })
const repoRuns = [makeRepoRuns("owner/repo", [run1, run2])]
render(<WorkflowHealthSection watchWorkflows={["publish"]} repoRuns={repoRuns} />)
expect(screen.getAllByText("publish")).toHaveLength(1)
const { container } = render(<WorkflowHealthSection watchWorkflows={["publish"]} repoRuns={repoRuns} />)
expect(container.querySelectorAll(".latest-run-card")).toHaveLength(1)
})

it("does not render non-matching workflow runs", () => {
Expand All @@ -174,4 +196,69 @@ describe("WorkflowHealthSection", () => {
render(<WorkflowHealthSection watchWorkflows={["publish"]} repoRuns={repoRuns} />)
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(<WorkflowHealthSection watchWorkflows={["publish", "scan"]} repoRuns={repoRuns} />)
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(<WorkflowHealthSection watchWorkflows={["publish"]} repoRuns={repoRuns} />)
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(<WorkflowHealthSection watchWorkflows={["publish"]} repoRuns={repoRuns} />)
// "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")
})
})
53 changes: 46 additions & 7 deletions packages/web/src/components/WorkflowHealthSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>): { 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"] }) {
Expand All @@ -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<string>
}) {
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 (
<div
Expand All @@ -58,12 +81,12 @@ function HealthRunCard({ run, fullName }: { run: WorkflowRun; fullName: string }
title={fullName}
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
<span className="health-repo-label-owner">{owner}/</span>{repo}
{ownerPrefix && <span className="health-repo-label-owner">{ownerPrefix}/</span>}{repo}
</div>
<div className="flex-center gap-2" style={{ justifyContent: "space-between", minWidth: 0 }}>
<Link
to="/runs/$owner/$repo/$runId"
params={{ owner: owner!, repo: repo!, runId: String(run.id) }}
params={{ owner: ownerRaw, repo, runId: String(run.id) }}
className="latest-run-workflow latest-run-card-link truncate"
>
{run.workflowName ?? run.name}
Expand Down Expand Up @@ -148,14 +171,30 @@ 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<string, Set<string>>()
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 (
<Card>
<div className="card-header">
<TruncatingTitle prefix="Workflow Health" items={watchWorkflows} className="card-title" />
<div className="card-header" style={{ flexDirection: "column", alignItems: "stretch", gap: "0.2rem" }}>
<span className="card-title">Workflow Health</span>
<TruncatingTitle items={watchWorkflows} className="text-muted text-small" />
</div>
<div className="latest-runs-grid">
{cards.map(({ fullName, run }) => (
<HealthRunCard key={`${fullName}/${run.workflowId}`} run={run} fullName={fullName} />
<HealthRunCard key={`${fullName}/${run.workflowId}`} run={run} fullName={fullName} duplicateRepoNames={duplicateRepoNames} />
))}
</div>
</Card>
Expand Down
18 changes: 18 additions & 0 deletions packages/web/src/components/ui/TruncatingTitle.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<TruncatingTitle items={["publish", "scan"]} />)
expect(screen.getByText("publish, scan")).toBeInTheDocument()
expect(screen.queryByText(/:/)).not.toBeInTheDocument()
})

it("truncates correctly with no prefix", () => {
render(
<TruncatingTitle
items={["publish", "scan", "deploy"]}
_isOverflowing={() => true}
/>
)
expect(screen.getByText("publish +2 more")).toBeInTheDocument()
})

// ── ResizeObserver lifecycle ────────────────────────────────────────────────

it("sets up a ResizeObserver on mount", () => {
Expand Down
13 changes: 8 additions & 5 deletions packages/web/src/components/ui/TruncatingTitle.tsx
Original file line number Diff line number Diff line change
@@ -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
/**
Expand All @@ -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,
Expand All @@ -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
}
}
Expand Down Expand Up @@ -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}`}
</span>
)
}
Loading
Loading