diff --git a/README.md b/README.md index f89febc..fc34cb0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Dashlight -A self-hosted GitHub Actions dashboard. See workflow runs, job status, and per-repository health scores across personal and organisation repos in one view. Filter by branch or status, drill into job logs, and track run trends over time. Sign in with your own GitHub account (OAuth) or share a single Personal Access Token across your team (PAT mode, optionally password-protected). +A self-hosted GitHub Actions dashboard. See workflow runs, job status, and per-repository health scores across personal and organisation repos in one view. Pin specific workflows in a **Workflow Health** section for an at-a-glance view of your most important pipelines across all repos. Filter by branch or status, drill into job logs, and track run trends over time. Sign in with your own GitHub account (OAuth) or share a single Personal Access Token across your team (PAT mode, optionally password-protected). **No database. Two Docker containers. Your GitHub token never touches the browser.** @@ -218,6 +218,7 @@ All variables are set in `.env` (copy from `env.example`). |---|---|---| | `GITHUB_ORG` | — | Show only repos in this org | | `GITHUB_REPOS` | — | Show only these repos (comma-separated `owner/repo`). Takes precedence over `GITHUB_ORG` | +| `WATCH_WORKFLOWS` | — | Comma-separated workflow names to highlight in the **Workflow Health** section at the top of the dashboard (e.g. `publish,security-scan,deploy`). Matching is case-insensitive. The section is hidden when this variable is not set or no matching runs exist in any repo. | | `GITHUB_SCOPE` | — | OAuth scopes to request (leave blank for the built-in default). Ignored in PAT mode. | | `WEB_PORT` | `5174` | Host port for the nginx container | | `FRONTEND_URL` | `http://localhost:5174` | Public URL of the app — must match where users open it | @@ -445,6 +446,7 @@ Hono Server (Node 22, internal only) ├── /auth/* — GitHub OAuth flow, session management ├── /proxy/* — Authenticated GitHub API proxy (LRU cache) ├── /api/score — Repository health scoring (7 categories) + ├── /api/config — Server-side configuration exposed to the frontend (e.g. WATCH_WORKFLOWS) └── /system/* — Health check │ ▼ diff --git a/env.example b/env.example index 73ae0f4..198cc90 100644 --- a/env.example +++ b/env.example @@ -14,7 +14,7 @@ GITHUB_TOKEN= # When set, users must enter this password before accessing the dashboard. # When not set, the dashboard is open to anyone who can reach the server. # Requires SESSION_SECRET when set. -APP_PASSWORD=orR$T:eGdHH +APP_PASSWORD= # ── OAuth mode (per-user GitHub accounts) ───────────────────────────────────── # GitHub OAuth App credentials (create at github.com/settings/developers) @@ -55,6 +55,15 @@ GITHUB_REPOS= # ---- +# Workflows to highlight in the "Workflow Health" section at the top of the dashboard. +# Comma-separated workflow names (case-insensitive). When set, the section shows the +# latest run for each matching workflow across all displayed repositories. +# When not set, or when no matching runs exist in any repo, the section is hidden. +# e.g. publish,security-scan,deploy +WATCH_WORKFLOWS= + +# ---- + # In-memory LRU cache max size in MB CACHE_MAX_SIZE_MB=128 diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index a2a62fd..1b8eb16 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -14,6 +14,7 @@ import proxyRoutes from "./routes/proxy.js" import reposRoutes from "./routes/repos.js" import scoreRoutes from "./routes/score.js" import systemRoutes from "./routes/system.js" +import configRoutes from "./routes/config.js" import { authMiddleware } from "./middleware/auth.js" import { cacheInvalidateUser } from "./lib/cache.js" import type { AuthEnv } from "./middleware/auth.js" @@ -117,6 +118,7 @@ async function bootstrap() { app.route("/proxy", proxyRoutes) app.route("/api/repos", reposRoutes) app.route("/api/score", scoreRoutes) + app.route("/api/config", configRoutes) app.route("/system", systemRoutes) // Clear server-side cache for the authenticated user diff --git a/packages/server/src/routes/config.test.ts b/packages/server/src/routes/config.test.ts new file mode 100644 index 0000000..6565245 --- /dev/null +++ b/packages/server/src/routes/config.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { Hono } from "hono" + +// ── Module mocks (hoisted) ──────────────────────────────────────────────────── + +vi.mock("../middleware/auth.js", () => ({ + authMiddleware: vi.fn(async (c: import("hono").Context, next: () => Promise) => { + c.set("session", { sub: "u1", login: "octocat", name: "Octocat", avatarUrl: "", sessionId: "s1" }) + c.set("githubToken", "tok") + await next() + }), +})) + +import configRouter from "./config.js" + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeApp() { + const app = new Hono() + app.route("/api/config", configRouter) + return app +} + +beforeEach(() => { + delete process.env["WATCH_WORKFLOWS"] +}) + +afterEach(() => { + delete process.env["WATCH_WORKFLOWS"] +}) + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("GET /api/config", () => { + it("returns empty watchWorkflows when WATCH_WORKFLOWS is not set", async () => { + const res = await makeApp().request("/api/config") + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ watchWorkflows: [] }) + }) + + it("returns empty watchWorkflows when WATCH_WORKFLOWS is an empty string", async () => { + process.env["WATCH_WORKFLOWS"] = "" + const res = await makeApp().request("/api/config") + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ watchWorkflows: [] }) + }) + + it("returns a single workflow name", async () => { + process.env["WATCH_WORKFLOWS"] = "publish" + const res = await makeApp().request("/api/config") + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ watchWorkflows: ["publish"] }) + }) + + it("parses comma-separated workflow names", async () => { + process.env["WATCH_WORKFLOWS"] = "publish,security-scan,deploy" + const res = await makeApp().request("/api/config") + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ watchWorkflows: ["publish", "security-scan", "deploy"] }) + }) + + it("trims whitespace around workflow names", async () => { + process.env["WATCH_WORKFLOWS"] = " publish , scan , deploy " + const res = await makeApp().request("/api/config") + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ watchWorkflows: ["publish", "scan", "deploy"] }) + }) + + it("filters out empty entries from trailing/double commas", async () => { + process.env["WATCH_WORKFLOWS"] = "publish,,scan," + const res = await makeApp().request("/api/config") + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ watchWorkflows: ["publish", "scan"] }) + }) + + it("requires authentication — rejects unauthenticated requests", async () => { + // Temporarily override the mock to simulate an auth failure + const { authMiddleware } = await import("../middleware/auth.js") + vi.mocked(authMiddleware).mockImplementationOnce(async (c, _next) => { + return c.json({ error: "Unauthorized" }, 401) + }) + const res = await makeApp().request("/api/config") + expect(res.status).toBe(401) + }) +}) diff --git a/packages/server/src/routes/config.ts b/packages/server/src/routes/config.ts new file mode 100644 index 0000000..82300e5 --- /dev/null +++ b/packages/server/src/routes/config.ts @@ -0,0 +1,19 @@ +import { Hono } from "hono" +import { authMiddleware } from "../middleware/auth.js" +import type { AuthEnv } from "../middleware/auth.js" + +const config = new Hono() + +config.use("/*", authMiddleware) + +/** + * GET /api/config + * Returns static server configuration that the frontend needs at runtime. + */ +config.get("/", (c) => { + const raw = process.env["WATCH_WORKFLOWS"] ?? "" + const watchWorkflows = raw.split(",").map((s) => s.trim()).filter(Boolean) + return c.json({ watchWorkflows }) +}) + +export default config diff --git a/packages/web/src/api/index.ts b/packages/web/src/api/index.ts index 9ee7d05..ece2e4f 100644 --- a/packages/web/src/api/index.ts +++ b/packages/web/src/api/index.ts @@ -2,6 +2,7 @@ import { fetchApi, fetchApiText, ApiError } from "./client.js" import type { SessionUser, AuthConfig, + AppConfig, Organization, Repository, Workflow, @@ -138,6 +139,10 @@ export async function getAuthConfig(): Promise { return fetchApi("/auth/config") } +export async function getAppConfig(): Promise { + return fetchApi("/api/config") +} + export async function getMe(): Promise { return fetchApi("/auth/me") } diff --git a/packages/web/src/components/WorkflowHealthSection.test.tsx b/packages/web/src/components/WorkflowHealthSection.test.tsx new file mode 100644 index 0000000..afb71cb --- /dev/null +++ b/packages/web/src/components/WorkflowHealthSection.test.tsx @@ -0,0 +1,177 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { render, screen } from "@testing-library/react" + +// ── Module mocks (hoisted) ──────────────────────────────────────────────────── + +vi.mock("@tanstack/react-router", () => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Link: ({ children, to, params: _params, ...rest }: any) => {children}, +})) + +// ResizeObserver is used by TruncatingTitle — stub globally (class, not arrow fn) +beforeEach(() => { + vi.stubGlobal("ResizeObserver", class { observe = vi.fn(); disconnect = vi.fn() }) +}) +afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() +}) + +import { WorkflowHealthSection } from "./WorkflowHealthSection.js" +import type { WorkflowRun } from "../types/index.js" + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +let _id = 0 +function makeRun(overrides: Partial & { workflowName: string }): WorkflowRun { + return { + id: ++_id, + name: overrides.workflowName, + status: "completed", + conclusion: "success", + headBranch: "main", + headSha: "abc1234", + runNumber: 1, + event: "push", + workflowId: ++_id, + workflowPath: null, + workflowName: overrides.workflowName, + repository: "owner/repo", + createdAt: "2024-01-01T10:00:00Z", + updatedAt: "2024-01-01T10:05:00Z", + runStartedAt: "2024-01-01T10:00:00Z", + runAttempt: 1, + url: "https://github.com", + htmlUrl: "https://github.com", + actor: null, + displayTitle: overrides.workflowName, + ...overrides, + } +} + +function makeRepoRuns( + fullName: string, + runs: WorkflowRun[] +): { name: string; fullName: string; runs: WorkflowRun[] } { + return { name: fullName.split("/")[1]!, fullName, runs } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("WorkflowHealthSection", () => { + it("returns nothing when watchWorkflows is empty", () => { + const { container } = render( + + ) + expect(container).toBeEmptyDOMElement() + }) + + it("returns nothing when watchWorkflows is empty even with runs", () => { + const repoRuns = [makeRepoRuns("owner/repo", [makeRun({ workflowName: "publish" })])] + const { container } = render( + + ) + expect(container).toBeEmptyDOMElement() + }) + + it("returns nothing when no runs match the configured workflow names", () => { + const repoRuns = [makeRepoRuns("owner/repo", [makeRun({ workflowName: "ci" })])] + const { container } = render( + + ) + expect(container).toBeEmptyDOMElement() + }) + + it("returns nothing when repoRuns is empty", () => { + const { container } = render( + + ) + expect(container).toBeEmptyDOMElement() + }) + + it("renders the section title with configured workflow names", () => { + const repoRuns = [makeRepoRuns("owner/repo", [makeRun({ workflowName: "publish" })])] + render() + expect(screen.getByText("Workflow Health: publish, scan")).toBeInTheDocument() + }) + + it("renders owner and repo name inside the card", () => { + const repoRuns = [makeRepoRuns("owner/repo", [makeRun({ workflowName: "publish" })])] + render() + expect(screen.getByText("owner/")).toBeInTheDocument() + expect(screen.getByText("repo")).toBeInTheDocument() + }) + + it("renders a run card for each matched workflow", () => { + const repoRuns = [ + makeRepoRuns("owner/repo", [ + makeRun({ workflowName: "publish" }), + makeRun({ workflowName: "scan" }), + ]), + ] + render() + expect(screen.getByText("publish")).toBeInTheDocument() + expect(screen.getByText("scan")).toBeInTheDocument() + }) + + it("matching is case-insensitive", () => { + const repoRuns = [makeRepoRuns("owner/repo", [makeRun({ workflowName: "Publish" })])] + render() + expect(screen.getByText("Publish")).toBeInTheDocument() + }) + + it("excludes repos that have no matching workflows", () => { + const repoRuns = [ + makeRepoRuns("owner/repo-a", [makeRun({ workflowName: "publish" })]), + makeRepoRuns("owner/repo-b", [makeRun({ workflowName: "ci" })]), + ] + render() + expect(screen.getByText("repo-a")).toBeInTheDocument() + expect(screen.queryByText("repo-b")).not.toBeInTheDocument() + }) + + it("shows a card for each matching repo in a single flat grid", () => { + const repoRuns = [ + makeRepoRuns("owner/repo-a", [makeRun({ workflowName: "publish" })]), + makeRepoRuns("owner/repo-b", [makeRun({ workflowName: "publish" })]), + ] + const { container } = render( + + ) + expect(screen.getByText("repo-a")).toBeInTheDocument() + expect(screen.getByText("repo-b")).toBeInTheDocument() + // Both cards in a single grid — no nested sub-grids + expect(container.querySelectorAll(".latest-runs-grid")).toHaveLength(1) + expect(container.querySelectorAll(".latest-run-card")).toHaveLength(2) + }) + + it("prefers in-progress run over completed when both exist", () => { + const completed = makeRun({ workflowName: "publish", status: "completed", conclusion: "success" }) + const inProgress = makeRun({ workflowName: "publish", status: "in_progress", conclusion: null }) + const repoRuns = [makeRepoRuns("owner/repo", [completed, inProgress])] + render() + // in_progress run should have the pulse dot + const dots = document.querySelectorAll(".run-dot-pulse") + expect(dots.length).toBeGreaterThan(0) + }) + + it("only shows one card per workflow name per repo", () => { + // Two runs with the same workflow name — only one card should appear + const run1 = makeRun({ workflowName: "publish" }) + const run2 = makeRun({ workflowName: "publish" }) + const repoRuns = [makeRepoRuns("owner/repo", [run1, run2])] + render() + expect(screen.getAllByText("publish")).toHaveLength(1) + }) + + it("does not render non-matching workflow runs", () => { + const repoRuns = [ + makeRepoRuns("owner/repo", [ + makeRun({ workflowName: "publish" }), + makeRun({ workflowName: "ci" }), + ]), + ] + render() + expect(screen.queryByText("ci")).not.toBeInTheDocument() + }) +}) diff --git a/packages/web/src/components/WorkflowHealthSection.tsx b/packages/web/src/components/WorkflowHealthSection.tsx new file mode 100644 index 0000000..47de7b5 --- /dev/null +++ b/packages/web/src/components/WorkflowHealthSection.tsx @@ -0,0 +1,163 @@ +import { Link } from "@tanstack/react-router" +import { Card } from "./ui/Card.js" +import { TruncatingTitle } from "./ui/TruncatingTitle.js" +import { formatDuration, formatRelativeTime, runStatusVariant, VARIANT_COLOR } from "../lib/utils.js" +import type { WorkflowRun } from "../types/index.js" + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface RepoRunEntry { + name: string + fullName: string + runs: WorkflowRun[] +} + +interface WorkflowHealthSectionProps { + watchWorkflows: string[] + repoRuns: RepoRunEntry[] +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** Prioritise in-progress/queued; fall back to most recent. */ +function pickRun(runs: WorkflowRun[]): WorkflowRun | undefined { + return runs.find((r) => r.status === "in_progress" || r.status === "queued") ?? runs[0] +} + +// ── Run dot ─────────────────────────────────────────────────────────────────── + +function RunDot({ status, conclusion }: { status: WorkflowRun["status"]; conclusion: WorkflowRun["conclusion"] }) { + const isActive = status === "in_progress" || status === "queued" + let colorClass = "run-dot-neutral" + if (conclusion === "success") colorClass = "run-dot-success" + else if (conclusion === "failure" || conclusion === "timed_out") colorClass = "run-dot-failure" + else if (conclusion === "cancelled") colorClass = "run-dot-cancelled" + else if (isActive) colorClass = "run-dot-running" + return +} + +// ── Single run card ─────────────────────────────────────────────────────────── + +function HealthRunCard({ run, fullName }: { run: WorkflowRun; fullName: string }) { + const [owner, repo] = fullName.split("/") + 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] + + return ( +
+ {/* Repo name — sits above the stretched link, purely informational */} +
+ {owner}/{repo} +
+
+ + {run.workflowName ?? run.name} + + +
+ +
+ + {duration} + · + {formatRelativeTime(run.runStartedAt ?? run.createdAt)} +
+
+ ) +} + +// ── Icons (inline SVG — same as used elsewhere in the dashboard) ────────────── + +function ClockIcon() { + return ( + + + + + ) +} + +function BranchIcon() { + return ( + + + + + ) +} + +function CommitIcon() { + return ( + + + + ) +} + +// ── Section ─────────────────────────────────────────────────────────────────── + +export function WorkflowHealthSection({ watchWorkflows, repoRuns }: WorkflowHealthSectionProps) { + if (watchWorkflows.length === 0) return null + + // Flatten: one entry per (repo × matched workflow), preserving watchWorkflows order + const cards: { fullName: string; run: WorkflowRun }[] = [] + for (const { fullName, runs } of repoRuns) { + for (const wfName of watchWorkflows) { + const matching = runs.filter( + (r) => r.workflowName.toLowerCase() === wfName.toLowerCase() + ) + const run = pickRun(matching) + if (run) cards.push({ fullName, run }) + } + } + + if (cards.length === 0) return null + + return ( + +
+ +
+
+ {cards.map(({ fullName, run }) => ( + + ))} +
+
+ ) +} diff --git a/packages/web/src/components/charts/RunCharts.test.tsx b/packages/web/src/components/charts/RunCharts.test.tsx new file mode 100644 index 0000000..ee86430 --- /dev/null +++ b/packages/web/src/components/charts/RunCharts.test.tsx @@ -0,0 +1,152 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { render, screen } from "@testing-library/react" +import { RepoActivityChart, paletteColor } from "./RunCharts.js" +import type { WorkflowRun } from "../../types/index.js" + +// ── Recharts mock ───────────────────────────────────────────────────────────── + +// Recharts relies on browser layout APIs unavailable in jsdom. Stub the +// components used by RepoActivityChart so tests focus on our logic. +vi.mock("recharts", () => ({ + ResponsiveContainer: ({ children }: { children: React.ReactNode }) =>
{children}
, + LineChart: ({ children }: { children: React.ReactNode }) =>
{children}
, + Line: () => null, + CartesianGrid: () => null, + XAxis: () => null, + YAxis: () => null, + Tooltip: () => null, +})) + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +let _id = 0 +beforeEach(() => { _id = 0 }) +afterEach(() => { vi.clearAllMocks() }) + +function makeRun(overrides: Partial & { createdAt?: string } = {}): WorkflowRun { + return { + id: ++_id, + name: "CI", + status: "completed", + conclusion: "success", + headBranch: "main", + headSha: "abc1234", + runNumber: 1, + event: "push", + workflowId: 1, + workflowPath: null, + workflowName: "CI", + repository: "owner/repo", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + runStartedAt: new Date().toISOString(), + runAttempt: 1, + url: "https://github.com", + htmlUrl: "https://github.com", + actor: null, + displayTitle: "CI", + ...overrides, + } +} + +function makeRepoRuns(name: string, runCount = 1) { + const fullName = `owner/${name}` + return { + name, + fullName, + runs: Array.from({ length: runCount }, () => makeRun()), + } +} + +// ── paletteColor ────────────────────────────────────────────────────────────── + +describe("paletteColor", () => { + it("returns a non-empty string for index 0", () => { + expect(paletteColor(0)).toBeTruthy() + }) + + it("wraps around when index exceeds palette length", () => { + expect(paletteColor(0)).toBe(paletteColor(8)) + }) + + it("returns distinct colors for adjacent indices", () => { + expect(paletteColor(0)).not.toBe(paletteColor(1)) + }) +}) + +// ── RepoActivityChart — empty state ────────────────────────────────────────── + +describe("RepoActivityChart — empty state", () => { + it("shows a no-activity message when all repos have no runs", () => { + render() + expect(screen.getByText(/No run activity/)).toBeInTheDocument() + }) + + it("shows a no-activity message when repoRuns is empty", () => { + render() + expect(screen.getByText(/No run activity/)).toBeInTheDocument() + }) +}) + +// ── RepoActivityChart — legend ──────────────────────────────────────────────── + +describe("RepoActivityChart — legend", () => { + it("renders a legend item for each active repo", () => { + const repoRuns = [ + makeRepoRuns("alpha", 1), + makeRepoRuns("beta", 1), + makeRepoRuns("gamma", 1), + ] + render() + expect(screen.getByText("alpha")).toBeInTheDocument() + expect(screen.getByText("beta")).toBeInTheDocument() + expect(screen.getByText("gamma")).toBeInTheDocument() + }) + + it("renders the legend outside the ResponsiveContainer wrapper", () => { + const repoRuns = [makeRepoRuns("my-repo", 1)] + const { container } = render() + const legend = container.querySelector(".activity-chart-legend") + expect(legend).toBeInTheDocument() + // The legend must NOT be a descendant of the ResponsiveContainer div + // (which is the first child div — the chart wrapper) + const chartWrapper = container.firstElementChild?.firstElementChild + expect(chartWrapper?.contains(legend)).toBe(false) + }) + + it("does not render a legend item for repos with no runs", () => { + const repoRuns = [ + makeRepoRuns("active", 1), + makeRepoRuns("inactive", 0), + ] + render() + expect(screen.getByText("active")).toBeInTheDocument() + expect(screen.queryByText("inactive")).not.toBeInTheDocument() + }) + + it("renders with many repos and long names without breaking layout classes", () => { + const names = Array.from({ length: 10 }, (_, i) => + `organisation_with_very_long_name_repo_${i + 1}` + ) + const repoRuns = names.map((n) => makeRepoRuns(n, 1)) + const { container } = render() + const legend = container.querySelector(".activity-chart-legend") + expect(legend).toBeInTheDocument() + const items = container.querySelectorAll(".activity-chart-legend-item") + expect(items).toHaveLength(10) + // Every item text is present + for (const name of names) { + expect(screen.getByText(name)).toBeInTheDocument() + } + }) + + it("assigns a distinct color circle to each legend item", () => { + const repoRuns = [makeRepoRuns("a", 1), makeRepoRuns("b", 1)] + const { container } = render() + const circles = container.querySelectorAll(".activity-chart-legend-item circle") + const fills = Array.from(circles).map((c) => c.getAttribute("fill")) + expect(fills[0]).toBe(paletteColor(0)) + expect(fills[1]).toBe(paletteColor(1)) + expect(fills[0]).not.toBe(fills[1]) + }) +}) diff --git a/packages/web/src/components/charts/RunCharts.tsx b/packages/web/src/components/charts/RunCharts.tsx index 1e9d930..f5ec333 100644 --- a/packages/web/src/components/charts/RunCharts.tsx +++ b/packages/web/src/components/charts/RunCharts.tsx @@ -10,7 +10,6 @@ import { CartesianGrid, LineChart, Line, - Legend, } from "recharts" import type { WorkflowRun } from "../../types/index.js" @@ -103,55 +102,62 @@ export function RepoActivityChart({ repoRuns }: { repoRuns: RepoRunEntry[] }) { const xInterval = Math.floor(30 / 5) return ( - - - - - - [value as number, name as string]} - /> - - {activeRepos.map(({ name }, i) => ( - + + + + + + [value as number, name as string]} /> + {activeRepos.map(({ name }, i) => ( + + ))} + + +
+ {activeRepos.map(({ name }, i) => ( + + + {name} + ))} - - +
+ ) } diff --git a/packages/web/src/components/ui/TruncatingTitle.test.tsx b/packages/web/src/components/ui/TruncatingTitle.test.tsx new file mode 100644 index 0000000..98ab020 --- /dev/null +++ b/packages/web/src/components/ui/TruncatingTitle.test.tsx @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { render, screen } from "@testing-library/react" +import { TruncatingTitle } from "./TruncatingTitle.js" + +// ── ResizeObserver mock ─────────────────────────────────────────────────────── + +// Arrow functions cannot be constructors — use a class so `new ResizeObserver()` works. +const mockObserve = vi.fn() +const mockDisconnect = vi.fn() + +beforeEach(() => { + vi.stubGlobal("ResizeObserver", class { + observe = mockObserve + disconnect = mockDisconnect + }) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() +}) + +// ── Overflow predicate helpers ──────────────────────────────────────────────── + +/** Overflow predicate: overflows while the candidate text contains `needle`. */ +const overflowsWhile = (needle: string) => (el: HTMLSpanElement) => + Boolean(el.textContent?.includes(needle)) + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe("TruncatingTitle", () => { + // ── No-overflow cases ─────────────────────────────────────────────────────── + + it("renders all items when none overflow", () => { + render() + expect(screen.getByText("Health: publish, scan, deploy")).toBeInTheDocument() + }) + + it("renders a single item without a +N suffix", () => { + render() + expect(screen.getByText("Health: publish")).toBeInTheDocument() + expect(screen.queryByText(/more/)).not.toBeInTheDocument() + }) + + it("applies the className to the span", () => { + const { container } = render( + + ) + expect(container.querySelector(".card-title")).toBeInTheDocument() + }) + + it("does not show +N suffix when the explicit predicate returns false", () => { + render( + false} + /> + ) + expect(screen.queryByText(/more/)).not.toBeInTheDocument() + expect(screen.getByText("Health: publish, scan")).toBeInTheDocument() + }) + + // ── Overflow reduction (single-pass, synchronous) ─────────────────────────── + + it("reduces to 1 item + +N more when always overflowing", () => { + render( + true} + /> + ) + expect(screen.getByText("Health: publish +2 more")).toBeInTheDocument() + }) + + it("stops reducing when overflow is resolved", () => { + // Overflowing while the full third-item string ", deploy" is present + render( + + ) + // "Health: publish, scan +1 more" — no longer contains ", deploy" → stops + expect(screen.getByText("Health: publish, scan +1 more")).toBeInTheDocument() + }) + + it("shows the correct +N count", () => { + render( + true} + /> + ) + expect(screen.getByText("X: a +4 more")).toBeInTheDocument() + }) + + it("stops at 1 item minimum and does not show +0 suffix", () => { + // With a single item, there is nothing to truncate further; + // the while loop never executes and we end up showing the 1 item without suffix. + render( + true} + /> + ) + expect(screen.getByText("X: only")).toBeInTheDocument() + expect(screen.queryByText(/more/)).not.toBeInTheDocument() + }) + + // ── Items change ──────────────────────────────────────────────────────────── + + it("expands back to full when items change and new set fits", () => { + const { rerender } = render( + true} + /> + ) + expect(screen.getByText("Health: publish +1 more")).toBeInTheDocument() + + rerender( + false} + /> + ) + expect(screen.getByText("Health: alpha, beta")).toBeInTheDocument() + }) + + it("re-applies reduction after items change when still overflowing", () => { + const { rerender } = render( + false} /> + ) + expect(screen.getByText("X: a")).toBeInTheDocument() + + rerender( + true} /> + ) + expect(screen.getByText("X: a +2 more")).toBeInTheDocument() + }) + + // ── ResizeObserver lifecycle ──────────────────────────────────────────────── + + it("sets up a ResizeObserver on mount", () => { + render() + expect(mockObserve).toHaveBeenCalled() + }) + + it("disconnects the ResizeObserver on unmount", () => { + const { unmount } = render() + unmount() + expect(mockDisconnect).toHaveBeenCalled() + }) +}) diff --git a/packages/web/src/components/ui/TruncatingTitle.tsx b/packages/web/src/components/ui/TruncatingTitle.tsx new file mode 100644 index 0000000..92e7146 --- /dev/null +++ b/packages/web/src/components/ui/TruncatingTitle.tsx @@ -0,0 +1,93 @@ +import { useState, useRef, useLayoutEffect, useEffect } from "react" + +interface TruncatingTitleProps { + prefix: string + items: string[] + className?: string + /** + * Override the overflow predicate — intended for tests only. + * Receives the span element after its textContent has been set to the + * candidate string, so implementations can inspect textContent directly. + * Default: el.scrollWidth > el.clientWidth + */ + _isOverflowing?: (el: HTMLSpanElement) => boolean +} + +const defaultIsOverflowing = (el: HTMLSpanElement) => el.scrollWidth > el.clientWidth + +/** + * Renders `prefix: item1, item2 …` on a single line. When the text would + * overflow the container, trailing items are hidden and replaced with a + * "+N more" suffix. Re-expands when the container grows (ResizeObserver). + * + * Uses a single-pass measurement strategy: mutates the span's single text + * node via nodeValue (in-place, preserving React's stateNode reference) to + * probe each candidate string, then commits the final visibleCount in one + * setState call (no cascading re-renders). The span always renders a single + * text expression so React reconciles exactly one text node — no placement + * side-effects from empty-string → non-empty transitions. + */ +export function TruncatingTitle({ + prefix, + items, + className, + _isOverflowing = defaultIsOverflowing, +}: TruncatingTitleProps) { + const [visibleCount, setVisibleCount] = useState(items.length) + // Incrementing this triggers a re-evaluation after a container resize + const [sizeTrigger, setSizeTrigger] = useState(0) + const ref = useRef(null) + + useLayoutEffect(() => { + const el = ref.current + if (!el) return + + // Mutate the existing text node in-place so React's stateNode reference + // stays valid. Using el.textContent = X would replace the DOM child, + // detaching the node React tracks and causing reconciliation artefacts. + const textNode = el.childNodes[0] as Text | undefined + if (!textNode) return + const probe = (s: string) => { textNode.nodeValue = s } + + // Check if all items fit — also handles re-expansion after resize. + // 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. + let count = items.length + probe(`${prefix}: ${items.join(", ")}`) + if (_isOverflowing(el)) { + while (count > 1) { + count-- + const hidden = items.length - count + probe(`${prefix}: ${items.slice(0, count).join(", ")} +${hidden} more`) + if (!_isOverflowing(el)) break + } + } + + // eslint-disable-next-line react-hooks/set-state-in-effect -- useLayoutEffect+setState is the recommended React pattern for pre-paint DOM measurement + setVisibleCount(count) + }, [prefix, items, _isOverflowing, sizeTrigger]) + + // Re-evaluate on container resize so newly gained space is used + useEffect(() => { + const el = ref.current + if (!el || typeof ResizeObserver === "undefined") return + const ro = new ResizeObserver(() => setSizeTrigger((t) => t + 1)) + ro.observe(el.parentElement ?? el) + return () => ro.disconnect() + }, []) + + const hidden = items.length - visibleCount + const displayed = items.slice(0, visibleCount).join(", ") + const suffix = hidden > 0 ? ` +${hidden} more` : "" + + return ( + + {`${prefix}: ${displayed}${suffix}`} + + ) +} diff --git a/packages/web/src/lib/utils.test.ts b/packages/web/src/lib/utils.test.ts index 4b4a59b..4839599 100644 --- a/packages/web/src/lib/utils.test.ts +++ b/packages/web/src/lib/utils.test.ts @@ -10,6 +10,7 @@ import { truncate, tierLabel, tierColor, + VARIANT_COLOR, } from "./utils.js" // ── formatDuration ──────────────────────────────────────────────────────────── @@ -157,6 +158,27 @@ describe("runStatusVariant", () => { }) }) +// ── VARIANT_COLOR ───────────────────────────────────────────────────────────── + +describe("VARIANT_COLOR", () => { + it("has an entry for every StatusVariant", () => { + const variants = ["success", "failure", "running", "pending", "cancelled", "neutral"] as const + for (const v of variants) { + expect(VARIANT_COLOR[v]).toBeTruthy() + } + }) + + it("each value references a CSS custom property", () => { + for (const color of Object.values(VARIANT_COLOR)) { + expect(color).toMatch(/^var\(--/) + } + }) + + it("success and failure map to distinct colors", () => { + expect(VARIANT_COLOR.success).not.toBe(VARIANT_COLOR.failure) + }) +}) + // ── runStatusLabel ──────────────────────────────────────────────────────────── describe("runStatusLabel", () => { diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index 7126697..d1d1612 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -45,6 +45,15 @@ export function formatDateTime(dateStr: string | null): string { export type StatusVariant = "success" | "failure" | "running" | "pending" | "cancelled" | "neutral" +export const VARIANT_COLOR: Record = { + success: "var(--color-success)", + failure: "var(--color-failure)", + running: "var(--color-running)", + cancelled: "var(--color-cancelled)", + neutral: "var(--color-neutral)", + pending: "var(--color-neutral)", +} + export function runStatusVariant(status: RunStatus, conclusion: RunConclusion): StatusVariant { if (status === "in_progress" || status === "queued" || status === "waiting") return "running" if (status === "completed") { diff --git a/packages/web/src/main.tsx b/packages/web/src/main.tsx index 72f2173..4da0d63 100644 --- a/packages/web/src/main.tsx +++ b/packages/web/src/main.tsx @@ -34,12 +34,15 @@ ReactDOM.createRoot(root).render( // dehydrateOptions filter was added (which excluded auth queries). // Without this, users who loaded the app before the fix would still // have ["auth", "me"] in IndexedDB and bypass the password check on reload. - buster: "v1", + buster: "v2", // Auth state must never be restored from IndexedDB — always verify with // the server on page load so that adding APP_PASSWORD or changing auth // mode takes effect immediately without requiring a manual cache clear. dehydrateOptions: { - shouldDehydrateQuery: (query) => query.queryKey[0] !== "auth", + // "auth" must never be restored (avoids bypassing password check on reload). + // "config" must never be restored (WATCH_WORKFLOWS may change between sessions). + shouldDehydrateQuery: (query) => + query.queryKey[0] !== "auth" && query.queryKey[0] !== "config", }, }} > diff --git a/packages/web/src/routes/_app/-index.test.tsx b/packages/web/src/routes/_app/-index.test.tsx index f106df6..81d0e34 100644 --- a/packages/web/src/routes/_app/-index.test.tsx +++ b/packages/web/src/routes/_app/-index.test.tsx @@ -45,6 +45,7 @@ import { HealthTable, BuildTrendsCard, } from "./index.js" +import { VARIANT_COLOR } from "../../lib/utils.js" // ── Test helpers ────────────────────────────────────────────────────────────── @@ -266,6 +267,48 @@ describe("RepoRunCards", () => { const link = screen.getByRole("link", { name: /aabbccd/ }) expect(link).toHaveAttribute("href", "https://github.com/acme/api/commit/aabbccddee112233") }) + + // ── Colored left border ───────────────────────────────────────────────────── + + it("success run card has a green left border", () => { + const run = makeRun({ status: "completed", conclusion: "success" }) + const { container } = render() + const card = container.querySelector(".latest-run-card") as HTMLElement + expect(card.style.borderLeft).toContain(VARIANT_COLOR.success) + }) + + it("failure run card has a red left border", () => { + const run = makeRun({ status: "completed", conclusion: "failure" }) + const { container } = render() + const card = container.querySelector(".latest-run-card") as HTMLElement + expect(card.style.borderLeft).toContain(VARIANT_COLOR.failure) + }) + + it("in_progress run card has a running-color left border", () => { + const run = makeRun({ status: "in_progress", conclusion: null }) + const { container } = render() + const card = container.querySelector(".latest-run-card") as HTMLElement + expect(card.style.borderLeft).toContain(VARIANT_COLOR.running) + }) + + it("cancelled run card has a cancelled-color left border", () => { + const run = makeRun({ status: "completed", conclusion: "cancelled" }) + const { container } = render() + const card = container.querySelector(".latest-run-card") as HTMLElement + expect(card.style.borderLeft).toContain(VARIANT_COLOR.cancelled) + }) + + it("each card gets its own status-appropriate border color", () => { + const runs = [ + makeRun({ workflowId: 1, status: "completed", conclusion: "success" }), + makeRun({ workflowId: 2, status: "completed", conclusion: "failure" }), + ] + const { container } = render() + const cards = container.querySelectorAll(".latest-run-card") as NodeListOf + const borders = Array.from(cards).map((c) => c.style.borderLeft) + expect(borders[0]).toContain(VARIANT_COLOR.success) + expect(borders[1]).toContain(VARIANT_COLOR.failure) + }) }) // ── ActivityCard ────────────────────────────────────────────────────────────── @@ -485,6 +528,54 @@ describe("HealthTable", () => { expect(link).not.toHaveAttribute("href", "/repositories/$owner/$repo/runs") expect(link).toHaveAttribute("href", "/runs/$owner/$repo/$runId") }) + + // ── Truncation and layout ─────────────────────────────────────────────────── + + it("wraps workflow name in health-workflow-name-cell for truncation", () => { + const repo = makeRepo({ fullName: "owner/repo" }) + const repoRuns = [{ + name: "repo", fullName: "owner/repo", + runs: [makeRun({ workflowId: 1, workflowName: "npm_and_yarn in /microservices for @xmldom/xmldom - Update #1334106698" })], + }] + const { container } = render() + const wfRow = container.querySelector(".health-workflow-row")! + expect(wfRow.querySelector(".health-workflow-name-cell")).toBeInTheDocument() + }) + + it("workflow name link inside name-cell has truncate class", () => { + const repo = makeRepo({ fullName: "owner/repo" }) + const repoRuns = [{ + name: "repo", fullName: "owner/repo", + runs: [makeRun({ workflowId: 1, workflowName: "A Very Long Workflow Name That Should Be Truncated" })], + }] + const { container } = render() + const wfRow = container.querySelector(".health-workflow-row")! + const nameCell = wfRow.querySelector(".health-workflow-name-cell")! + const link = nameCell.querySelector("a") + expect(link?.classList.contains("truncate")).toBe(true) + }) + + it("workflow last-run cell has health-last-run class for no-wrap", () => { + const repo = makeRepo({ fullName: "owner/repo" }) + const repoRuns = [{ + name: "repo", fullName: "owner/repo", + runs: [makeRun({ workflowId: 1, workflowName: "CI" })], + }] + const { container } = render() + const wfRow = container.querySelector(".health-workflow-row")! + // Second td is the last-run cell + const lastRunTd = wfRow.querySelectorAll("td")[1] + expect(lastRunTd?.classList.contains("health-last-run")).toBe(true) + }) + + it("repo-level last-run cell has health-last-run class for no-wrap", () => { + const repo = makeRepo({ fullName: "owner/repo" }) + const repoRuns = [{ name: "repo", fullName: "owner/repo", runs: [makeRun()] }] + const { container } = render() + const repoRow = container.querySelector(".health-repo-row")! + const lastRunTd = repoRow.querySelectorAll("td")[1] + expect(lastRunTd?.classList.contains("health-last-run")).toBe(true) + }) }) // ── BuildTrendsCard ─────────────────────────────────────────────────────────── diff --git a/packages/web/src/routes/_app/-repositories.test.tsx b/packages/web/src/routes/_app/-repositories.test.tsx index d75d0eb..45f556d 100644 --- a/packages/web/src/routes/_app/-repositories.test.tsx +++ b/packages/web/src/routes/_app/-repositories.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest" -import { render, screen } from "@testing-library/react" +import { render, screen, fireEvent } from "@testing-library/react" import React from "react" import type { Repository } from "../../types/index.js" @@ -151,4 +151,63 @@ describe("Repositories list", () => { render() expect(screen.getByText("Actions not enabled")).toBeInTheDocument() }) + + // ── Alphabetical ordering ─────────────────────────────────────────────────── + + it("renders repos in alphabetical order by fullName", () => { + const repos = [ + makeRepo({ id: 1, fullName: "acme/zebra", name: "zebra" }), + makeRepo({ id: 2, fullName: "acme/alpha", name: "alpha" }), + makeRepo({ id: 3, fullName: "acme/middle", name: "middle" }), + ] + vi.mocked(useQuery) + .mockReturnValueOnce({ data: repos, isLoading: false } as never) + .mockReturnValue({ data: { runs: [], actionsDisabled: false }, isLoading: false } as never) + const { container } = render() + const links = container.querySelectorAll("tbody tr td a") + const names = Array.from(links).map((a) => a.textContent) + expect(names).toEqual(["alpha", "middle", "zebra"]) + }) + + it("sorting is stable regardless of the API response order", () => { + const repos = [ + makeRepo({ id: 3, fullName: "org/charlie", name: "charlie" }), + makeRepo({ id: 1, fullName: "org/alpha", name: "alpha" }), + makeRepo({ id: 2, fullName: "org/bravo", name: "bravo" }), + ] + vi.mocked(useQuery) + .mockReturnValueOnce({ data: repos, isLoading: false } as never) + .mockReturnValue({ data: { runs: [], actionsDisabled: false }, isLoading: false } as never) + const { container } = render() + const links = container.querySelectorAll("tbody tr td a") + const names = Array.from(links).map((a) => a.textContent) + expect(names).toEqual(["alpha", "bravo", "charlie"]) + }) + + it("alphabetical order is preserved after filter narrows the list", () => { + const repos = [ + makeRepo({ id: 1, fullName: "org/go-utils", name: "go-utils", language: "Go" }), + makeRepo({ id: 2, fullName: "org/go-server", name: "go-server", language: "Go" }), + makeRepo({ id: 3, fullName: "org/ts-client", name: "ts-client", language: "TypeScript" }), + ] + // Use mockImplementation so re-renders after fireEvent still get the right data per query + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(useQuery).mockImplementation((options: any) => { + if (options.queryKey[0] === "repos") { + return { data: repos, isLoading: false } as never + } + return { data: { runs: [], actionsDisabled: false }, isLoading: false } as never + }) + + const { container } = render() + // All three visible and sorted before filtering + let links = container.querySelectorAll("tbody tr td a") + expect(Array.from(links).map((a) => a.textContent)).toEqual(["go-server", "go-utils", "ts-client"]) + + // Type "go" — only the two go repos remain, still sorted + fireEvent.change(screen.getByPlaceholderText("Search here..."), { target: { value: "go" } }) + + links = container.querySelectorAll("tbody tr td a") + expect(Array.from(links).map((a) => a.textContent)).toEqual(["go-server", "go-utils"]) + }) }) diff --git a/packages/web/src/routes/_app/index.tsx b/packages/web/src/routes/_app/index.tsx index 990ad41..d66862f 100644 --- a/packages/web/src/routes/_app/index.tsx +++ b/packages/web/src/routes/_app/index.tsx @@ -1,13 +1,14 @@ import React, { useState } from "react" import { createFileRoute, Link } from "@tanstack/react-router" import { useQuery, useQueries } from "@tanstack/react-query" -import { getRepos, getRuns } from "../../api/index.js" +import { getRepos, getRuns, getAppConfig } from "../../api/index.js" import { PageSpinner } from "../../components/ui/Spinner.js" import { Card, CardHeader } from "../../components/ui/Card.js" import { RepoActivityChart, BuildTrendChart } from "../../components/charts/RunCharts.js" import type { RepoRunEntry } from "../../components/charts/RunCharts.js" import { SuccessSquares } from "../../components/ui/SuccessSquares.js" -import { formatRelativeTime, computeRunSummary, formatDuration } from "../../lib/utils.js" +import { formatRelativeTime, computeRunSummary, formatDuration, runStatusVariant, VARIANT_COLOR } from "../../lib/utils.js" +import { WorkflowHealthSection } from "../../components/WorkflowHealthSection.js" import type { Repository, WorkflowRun } from "../../types/index.js" export const Route = createFileRoute("/_app/")({ @@ -21,6 +22,12 @@ function Dashboard() { refetchInterval: 60_000, }) + const { data: appConfig } = useQuery({ + queryKey: ["config"], + queryFn: () => getAppConfig(), + staleTime: Infinity, + }) + const repoList = (repos ?? []).slice(0, 10) // useQueries handles a dynamic-length array without violating Rules of Hooks @@ -44,11 +51,14 @@ function Dashboard() { runs: runResults[i]?.data?.runs ?? [], })) + const watchWorkflows = appConfig?.watchWorkflows ?? [] + return (
- + +
@@ -95,11 +105,13 @@ export function RepoRunCards({ fullName, runs }: { fullName: string; runs: Workf 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 borderColor = VARIANT_COLOR[runStatusVariant(run.status, run.conclusion)] return (
- + {lastRun ? ( @@ -422,20 +434,22 @@ export function HealthTable({ repoRuns, repos }: { repoRuns: RepoRunEntry[]; rep return ( - - {wfLast ? ( - - {wf.name} - - ) : ( - {wf.name} - )} + + + {wfLast ? ( + + {wf.name} + + ) : ( + {wf.name} + )} + - + {wfLast ? ( diff --git a/packages/web/src/routes/_app/repositories.tsx b/packages/web/src/routes/_app/repositories.tsx index ce61ba4..94fa386 100644 --- a/packages/web/src/routes/_app/repositories.tsx +++ b/packages/web/src/routes/_app/repositories.tsx @@ -35,7 +35,7 @@ function Repositories() { } const q = filter.toLowerCase() - const visibleRepos = q + const visibleRepos = (q ? repos.filter((r) => r.fullName.toLowerCase().includes(q) || (r.description?.toLowerCase().includes(q) ?? false) || @@ -43,6 +43,7 @@ function Repositories() { r.topics.some((t) => t.toLowerCase().includes(q)) ) : repos + ).toSorted((a, b) => a.fullName.localeCompare(b.fullName)) const title = q ? `Repositories (${visibleRepos.length} of ${repos.length})` diff --git a/packages/web/src/styles/globals.css b/packages/web/src/styles/globals.css index ac326ba..5082356 100644 --- a/packages/web/src/styles/globals.css +++ b/packages/web/src/styles/globals.css @@ -75,6 +75,7 @@ a:hover { text-decoration: underline; } .main-content { margin-left: var(--sidebar-width); flex: 1; + min-width: 0; display: flex; flex-direction: column; } @@ -293,6 +294,17 @@ tr:hover td { background: var(--color-bg-secondary); } .trend-item { min-width: 0; } .trend-item-label { font-size: 12px; font-weight: 600; margin-bottom: 0.5rem; } +/* ── Build Activity chart legend ─────────────────────────────────────────── */ +.activity-chart-legend { + display: flex; flex-wrap: wrap; justify-content: center; gap: 0.5rem 1rem; + padding-top: 0.5rem; + font-size: 11px; color: var(--color-text-secondary); +} +.activity-chart-legend-item { + display: flex; align-items: center; gap: 0.35rem; + white-space: nowrap; +} + /* ── Build Activity stats ────────────────────────────────────────────────── */ .activity-stats { display: flex; flex-wrap: wrap; gap: 1.25rem; @@ -361,6 +373,11 @@ tr:hover td { background: var(--color-bg-secondary); } font-size: 12px; border-bottom: none; background: transparent; } +/* Allow the name cell to shrink and truncate rather than expanding the row */ +.health-workflow-row td:first-child { max-width: 0; overflow: hidden; } +.health-workflow-name-cell { display: flex; align-items: center; overflow: hidden; min-width: 0; } +/* Prevent date strings like "Nov 21" from wrapping across lines */ +.health-last-run { white-space: nowrap; } tr:has(+ .health-repo-label-row) td { padding-bottom: 0.875rem; } tr + .health-repo-label-row td { border-top: 1px solid var(--color-border); padding-top: 1.75rem; } .health-workflow-row:hover td { background: var(--color-bg-secondary); } diff --git a/packages/web/src/types/index.ts b/packages/web/src/types/index.ts index 88ecbe2..e8dbaf2 100644 --- a/packages/web/src/types/index.ts +++ b/packages/web/src/types/index.ts @@ -167,3 +167,9 @@ export interface ActivePipeline { run: WorkflowRun repository: string } + +// ── App config ──────────────────────────────────────────────────────────────── + +export interface AppConfig { + watchWorkflows: string[] +}