/** * The page behind the ci tile. The tile carries one headline and the handful * of rows that fit under it; this is every job the tile read, at full width: * which repository and workflow it is, what its deciding run concluded, how * long that run took, when it ran, and whether a run of it was going when the * tile last collected. Each repository's name links to its page (repo-page.ts). * * It renders the tile's own last collection rather than asking GitHub again, * so opening it costs nothing and shows exactly what the tile is showing. * The heading says when that collection was, since the two are the same age. * The page is live (live-page.ts), so an open copy follows the tile's * collections, in whatever order the reader has sorted it. */ import type { LivePageContent } from "./live-page.ts"; import { LIVE_PAGE_UPDATE } from "./live-page-client.ts"; // From the render values rather than lib.ts, so the browser test that drives // this page's own sorting can bundle it without the server-side half of the // package coming with it. import { compactSpan, escapeHtml, humanDuration, STATUS_DOT, STATUS_RANK, worstStatus, } from "./tile-render-values.ts"; import { repoPageHref } from "./repo-page-href.ts"; import { statusDotRules } from "./status-dot.ts"; import type { Status } from "./types.ts"; /** Where the page lives, and what the tile links to. */ export const CI_JOBS_PATH = "/ci"; /** One job the ci tile read, as both the tile and this page describe it. */ export interface Job { repo: string; // the repository's own name, without the owner workflow: string; // the workflow's name // The workflow's file, which no other workflow in its repository shares. path: string; pinned: boolean; // kept in the tile's body even when it is passing // How the job reads: red for a failure somebody can still act on, orange for // one that has been failing too long to be news and for a job nothing could // be read for, gray for one with no verdict at all. status: Status; // Whether the deciding run failed, whatever age has done to the color. failing: boolean; // What decided the job: a run's conclusion, or why there is no verdict. result: string; // What started the deciding run, as GitHub names it: `push`, `schedule`, // `workflow_dispatch`, and the rest. event?: string; startedAt?: number; // when the deciding run started ranMs?: number; // how long it ran href: string; // the deciding run, or the workflow's own runs page // The newest of the job's runs that is in progress, when one is. runningHref?: string; } /** A repository's own name, without the owner, as a `Job` names it. */ export function shortName(repo: string): string { return repo.slice(repo.indexOf("/") + 1); } /** What one collection of the ci tile saw. */ export interface CiJobs { jobs: readonly Job[]; // Every repository the collection read, by its own name without the owner. repos: readonly string[]; // Repositories whose workflow listing could not be read at all, so nothing // is known about the jobs behind them. unreadableRepos: readonly string[]; collectedAt: number; } /** * Every job in `collected`, and an orange row for each repository whose * workflows could not be listed, which stands for the jobs behind it that * nobody can see. */ export function ciJobRows({ jobs, unreadableRepos }: CiJobs): Job[] { return [ ...jobs, ...unreadableRepos.map((repo): Job => ({ repo: shortName(repo), workflow: "workflows", path: "", pinned: false, status: "warn", failing: false, result: "unreadable", href: `https://github.com/${repo}/actions`, })), ]; } /** * The color of the ci tile and of its page: the worst among the rows with a * verdict, or gray when no row has one. */ export function ciJobsStatus(collected: CiJobs): Status { const judged = ciJobRows(collected).filter((row) => row.status !== "unknown"); return judged.length === 0 ? "unknown" : worstStatus(judged.map((row) => row.status)); } const STYLES = ` .summary{display:flex;flex-wrap:wrap;gap:10px 34px;background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:12px 16px;margin-bottom:4px} .summary div{display:flex;flex-direction:column;gap:2px} .summary dt{font-size:11px;letter-spacing:.06em;text-transform:uppercase;color:var(--text-subtle)} .summary dd{margin:0;font-size:18px;font-weight:600;color:var(--text);font-variant-numeric:tabular-nums} /* The table keeps its own columns rather than shrinking them to nothing, so on a screen too narrow for all of them it scrolls inside this box and the page around it does not. */ .scroll{overflow-x:auto} table{border-collapse:collapse;width:100%;font-size:13px} th{text-align:left;font-weight:600;color:var(--text-subtle);font-size:11px;letter-spacing:.06em;text-transform:uppercase;white-space:nowrap;padding:0 12px 5px 0} td{padding:5px 12px 5px 0;border-top:1px solid var(--divider);color:var(--text-secondary);vertical-align:top} td.measure{font-variant-numeric:tabular-nums;white-space:nowrap;color:var(--text)} td.job{width:99%;word-break:break-word} /* Each row's links, drawn as the dashboard draws a link so they read as links: the repository's page, and the job's run or the workflow's runs on GitHub. */ td a{color:var(--accent);text-decoration:none} td a:hover{text-decoration:underline} td.repo{white-space:nowrap;color:var(--text)} /* The same dot the dashboard uses, shape and all, so a reader who cannot tell the colors apart reads the shapes here too. */ ${statusDotRules(9)} .dot{margin-right:7px;vertical-align:baseline} /* The running dot follows the workflow's name rather than leading the row. */ td.job .dot{margin:0 0 0 7px} th button{font:inherit;color:inherit;letter-spacing:inherit;text-transform:inherit;background:none;border:0;padding:0;cursor:pointer;display:inline-flex;align-items:baseline;gap:3px;white-space:nowrap} th button:hover{color:var(--text)} th button::after{content:"↕";opacity:.35;font-size:9px} th[aria-sort="ascending"] button::after{content:"↑";opacity:1} th[aria-sort="descending"] button::after{content:"↓";opacity:1} th[aria-sort="ascending"] button,th[aria-sort="descending"] button{color:var(--text)} /* The age says what the start time says, in far less room, so a narrow screen keeps the age and drops the timestamp rather than scrolling. */ @media(max-width:760px){.at{display:none}}`; /** An ISO 8601 time cut to the minute, which is the precision a reader wants. */ export function minutePrecision(at: number): string { return `${new Date(at).toISOString().slice(0, 16).replace("T", " ")} UTC`; } // A cell the reader can sort by carries the value to sort on, since what it // shows is written to be read: "17m 03s" and "6d ago" do not order as text. function cell(className: string, text: string, sortKey?: string): string { const key = sortKey === undefined ? "" : ` data-sort="${escapeHtml(sortKey)}"`; return `${escapeHtml(text)}`; } /** * The cell naming the repository `name`, linked to its page, after a dot of * `status`. It sorts by the name. */ function repoCell(name: string, status: Status): string { return `${ escapeHtml(name) }`; } /** * The workflow's name, linked to where `job` points, and after it a blue dot * linked to the run in progress, when one is. */ function workflowLink(job: Job): string { const running = job.runningHref === undefined ? "" : ``; return `${ escapeHtml(job.workflow) }${running}`; } function jobRow(job: Job, now: number): string { const when = job.startedAt === undefined ? { at: "—", ago: "—" } : { at: minutePrecision(job.startedAt), ago: `${compactSpan(now - job.startedAt)} ago`, }; // Worst first is the order the page opens in, so the result sorts on how bad // it is before it sorts on what it says. const severity = `${STATUS_RANK.bad - STATUS_RANK[job.status]} ${job.result}`; // A job with no measurement sorts to one end rather than among the measured. const missing = "-1"; return `${ repoCell(job.repo, job.status) }${ workflowLink(job) }${cell("measure", job.event ?? "—", job.event ?? "")}${ cell("measure", job.result, severity) }${ cell( "measure", job.ranMs === undefined ? "—" : humanDuration(job.ranMs), job.ranMs === undefined ? missing : String(job.ranMs), ) }${ cell( "measure at", when.at, job.startedAt === undefined ? missing : String(job.startedAt), ) }${ cell( "measure", when.ago, job.startedAt === undefined ? missing : String(job.startedAt), ) }`; } /** * A key for `job` that puts it where `ordered` does and that no other job * shares. A repository's name has no spaces in it. Each row carries its key, * which says nothing about the rows around it, so a row that has not changed * keeps its markup however the rows around it move. */ function servedKey(job: Job): string { const rank = STATUS_RANK.bad - STATUS_RANK[job.status]; return `${rank} ${job.repo} ${job.workflow} ${job.path}`; } /** Worst first, and within one status by repository and then by workflow. */ function ordered(jobs: readonly Job[]): Job[] { return [...jobs].sort((a, b) => servedKey(a).localeCompare(servedKey(b))); } const JOB_COLUMNS = [ "repository", "workflow", "trigger", "result", "ran for", "started", "age", ]; function jobHead(): string { return `${ JOB_COLUMNS.map((name, index) => `` ).join("") }`; } /** The parts of a table cell `sortTable()` reads. */ export interface SortableCell { getAttribute(name: string): string | null; readonly textContent: string | null; } /** The parts of a table row `sortTable()` reads. */ export interface SortableRow { getAttribute(name: string): string | null; readonly cells: ArrayLike; } /** The parts of a column heading's button the sorting functions use. */ export interface SortableHeading { getAttribute(name: string): string | null; readonly parentElement: { setAttribute(name: string, value: string): void; } | null; addEventListener(type: "click", listener: () => void): void; closest(selectors: "table"): SortableTable | null; } /** The parts of a table the sorting functions use. */ export interface SortableTable { readonly tBodies: ArrayLike<{ readonly rows: ArrayLike; appendChild(row: Row): unknown; }>; hasAttribute(name: string): boolean; querySelectorAll(selectors: string): ArrayLike>; } /** The parts of a page, or of a rendering of it, `followSorting()` reads. */ export interface SortableRoot { querySelectorAll(selectors: "table"): ArrayLike>; } /** The parts of a page `followSorting()` uses. */ export interface SortablePage extends SortableRoot { addEventListener( type: typeof LIVE_PAGE_UPDATE, listener: (event: { readonly detail: SortableRoot }) => void, ): void; } /** The column a reader sorted a table by, and which way. */ export interface SortOrder { readonly column: number; readonly descending: boolean; } /** * Puts the rows of `table` in `order` and marks its headings to say so. A cell * sorts on its `data-sort`, or on its text when it has none, as a number when * both sides read as one. Rows the column holds equal go in the order the page * is served in, by the key each row carries as its `data-served`. It reads only * the table it is given, which the page hands it and a test can fake, and the * page carries it serialized. */ export function sortTable( table: SortableTable, order: SortOrder, ): void { const body = table.tBodies[0]; if (body === undefined) throw new Error("a sortable table has a body"); const keyOf = (row: Row): string => { const cell = row.cells[order.column]; return cell.getAttribute("data-sort") ?? (cell.textContent ?? "").trim(); }; const served = (row: Row) => row.getAttribute("data-served") ?? ""; const rows = Array.from(body.rows).sort((a, b) => { const left = keyOf(a); const right = keyOf(b); const leftNumber = Number(left); const rightNumber = Number(right); const compared = left !== "" && right !== "" && Number.isFinite(leftNumber) && Number.isFinite(rightNumber) ? leftNumber - rightNumber : left.localeCompare(right); return (order.descending ? -compared : compared) || served(a).localeCompare(served(b)); }); for (const row of rows) body.appendChild(row); const headings = table.querySelectorAll("th button[data-column]"); for (const heading of Array.from(headings)) { const own = Number(heading.getAttribute("data-column")) === order.column; heading.parentElement?.setAttribute( "aria-sort", own ? (order.descending ? "descending" : "ascending") : "none", ); } } /** * Sorts the sortable tables in `page` by a column when its heading is * clicked, ascending on the first click and descending on the next, and sorts * each fresh rendering of the page the same way before it is applied. The * rendering arrives in the order the page was served in, and sorted like the * page it compares equal wherever nothing changed, so those rows are kept. * Every heading on the page came either with the page or with a rendering, so * each is listened to as it arrives. It reads only the page it is given, which * a test can fake, and the page carries it serialized. */ export function followSorting( page: SortablePage, ): void { let order: SortOrder | undefined; const listen = (root: SortableRoot): SortableTable[] => { const tables = Array.from(root.querySelectorAll("table")) .filter((table) => table.hasAttribute("data-sortable")); for (const table of tables) { const headings = table.querySelectorAll("th button[data-column]"); for (const heading of Array.from(headings)) { heading.addEventListener("click", () => { const column = Number(heading.getAttribute("data-column")); order = { column, descending: order?.column === column && !order.descending, }; // The heading may have arrived in a rendering and been placed in // the table the page already had. sortTable(heading.closest("table") ?? table, order); }); } } return tables; }; listen(page); page.addEventListener(LIVE_PAGE_UPDATE, (event) => { const tables = listen(event.detail); if (order === undefined) return; for (const table of tables) sortTable(table, order); }); } /** The page's own script, which keeps its table sortable. */ export const CI_JOBS_SCRIPT = ` const LIVE_PAGE_UPDATE = ${JSON.stringify(LIVE_PAGE_UPDATE)}; const sortTable = ${sortTable.toString()}; (${followSorting.toString()})(document);`; function summary(collected: CiJobs): string { const count = (status: Status) => collected.jobs.filter((job) => job.status === status).length; // A job is counted as failing however old its failure is, since age decides // the color of its row and not whether it is failing. const failing = collected.jobs.filter((job) => job.failing).length; const unreadable = collected.jobs.filter((job) => job.status === "warn" && !job.failing) .length + collected.unreadableRepos.length; const facts: Array<[string, string]> = [ // The same count the tile's header carries: the jobs it speaks for. ["jobs", String(collected.jobs.length - count("unknown"))], ["repositories", String(collected.repos.length)], ["passing", String(count("good"))], ["failing", String(failing)], ["unreadable", String(unreadable)], ["no verdict", String(count("unknown"))], [ "running", String( collected.jobs.filter((job) => job.runningHref !== undefined).length, ), ], ]; return `
${ facts.map(([term, value]) => `
${term}
${escapeHtml(value)}
` ).join("") }
`; } function content( head: string, body: string, status?: Status, ): LivePageContent { return { title: "CI jobs", styles: STYLES, head, body, script: CI_JOBS_SCRIPT, status, }; } /** The page for one collection, or the page saying there is not one. */ export function ciJobsPage( collected: CiJobs | undefined, now = Date.now(), ): LivePageContent { if (collected === undefined) { return content( "", `

The ci tile has not finished a collection yet. It reads every repository in the organization, which takes a few seconds, and this page shows what it last saw.

`, ); } // A workflow with no verdict has nothing to report in the table's trigger, // result, and timing columns, and a workflow only a pull request triggers is // one of these. They go under the table rather than through it, where // thirteen rows of dashes would sit between the failures and everything // that passed. One with a run in progress carries the running dot there as // it would in the table. const judged = collected.jobs.filter((job) => job.status !== "unknown"); const silent = ordered(collected.jobs.filter((job) => job.status === "unknown")); const rows = ordered(judged).map((job) => jobRow(job, now)).join(""); const unjudged = silent.length === 0 ? "" : `

Workflows with no verdict · ${silent.length}

${ silent.map((job) => `${repoCell(job.repo, job.status)}` ).join("") }
repositoryworkflowwhy
${ workflowLink(job) }${escapeHtml(job.result)}
`; const unreadable = collected.unreadableRepos.length === 0 ? "" : `

Repositories that could not be read · ${collected.unreadableRepos.length}

${ collected.unreadableRepos.map((repo) => `${ repoCell(shortName(repo), "warn") }` ).join("") }
repositoryworkflows
workflows
`; return content( `collected ${minutePrecision(collected.collectedAt)} · ${ escapeHtml(compactSpan(now - collected.collectedAt)) } ago`, `${summary(collected)}

Jobs · ${judged.length}

${jobHead()}${rows}
${unjudged} ${unreadable}`, ciJobsStatus(collected), ); }