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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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.**

Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
91 changes: 91 additions & 0 deletions packages/server/src/routes/config.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>) => {
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)
})
})
19 changes: 19 additions & 0 deletions packages/server/src/routes/config.ts
Original file line number Diff line number Diff line change
@@ -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<AuthEnv>()

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
5 changes: 5 additions & 0 deletions packages/web/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { fetchApi, fetchApiText, ApiError } from "./client.js"
import type {
SessionUser,
AuthConfig,
AppConfig,
Organization,
Repository,
Workflow,
Expand Down Expand Up @@ -138,6 +139,10 @@ export async function getAuthConfig(): Promise<AuthConfig> {
return fetchApi<AuthConfig>("/auth/config")
}

export async function getAppConfig(): Promise<AppConfig> {
return fetchApi<AppConfig>("/api/config")
}

export async function getMe(): Promise<SessionUser> {
return fetchApi<SessionUser>("/auth/me")
}
Expand Down
177 changes: 177 additions & 0 deletions packages/web/src/components/WorkflowHealthSection.test.tsx
Original file line number Diff line number Diff line change
@@ -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) => <a href={to} {...rest}>{children}</a>,
}))

// 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<WorkflowRun> & { 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(
<WorkflowHealthSection watchWorkflows={[]} repoRuns={[]} />
)
expect(container).toBeEmptyDOMElement()
})

it("returns nothing when watchWorkflows is empty even with runs", () => {
const repoRuns = [makeRepoRuns("owner/repo", [makeRun({ workflowName: "publish" })])]
const { container } = render(
<WorkflowHealthSection watchWorkflows={[]} repoRuns={repoRuns} />
)
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(
<WorkflowHealthSection watchWorkflows={["publish", "scan"]} repoRuns={repoRuns} />
)
expect(container).toBeEmptyDOMElement()
})

it("returns nothing when repoRuns is empty", () => {
const { container } = render(
<WorkflowHealthSection watchWorkflows={["publish"]} repoRuns={[]} />
)
expect(container).toBeEmptyDOMElement()
})

it("renders the section title with configured workflow names", () => {
const repoRuns = [makeRepoRuns("owner/repo", [makeRun({ workflowName: "publish" })])]
render(<WorkflowHealthSection watchWorkflows={["publish", "scan"]} repoRuns={repoRuns} />)
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(<WorkflowHealthSection watchWorkflows={["publish"]} repoRuns={repoRuns} />)
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(<WorkflowHealthSection watchWorkflows={["publish", "scan"]} repoRuns={repoRuns} />)
expect(screen.getByText("publish")).toBeInTheDocument()
expect(screen.getByText("scan")).toBeInTheDocument()
})

it("matching is case-insensitive", () => {
const repoRuns = [makeRepoRuns("owner/repo", [makeRun({ workflowName: "Publish" })])]
render(<WorkflowHealthSection watchWorkflows={["publish"]} repoRuns={repoRuns} />)
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(<WorkflowHealthSection watchWorkflows={["publish"]} repoRuns={repoRuns} />)
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(
<WorkflowHealthSection watchWorkflows={["publish"]} repoRuns={repoRuns} />
)
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(<WorkflowHealthSection watchWorkflows={["publish"]} repoRuns={repoRuns} />)
// 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(<WorkflowHealthSection watchWorkflows={["publish"]} repoRuns={repoRuns} />)
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(<WorkflowHealthSection watchWorkflows={["publish"]} repoRuns={repoRuns} />)
expect(screen.queryByText("ci")).not.toBeInTheDocument()
})
})
Loading
Loading