import type { EvidenceRow, Framework } from "./exporter";
export interface ExportListRow {
id: string;
framework: string;
format: string;
status: string;
created_at: string;
}
export interface InstallationOption {
installation_id: number;
org_login: string;
}
// Shown only when the user can see more than one installation; a single-org
// user gets the plain org name instead of a pointless dropdown.
function installationSwitcher(
installations: InstallationOption[],
current: number,
returnTo: "dashboard" | "access-review",
): string {
if (installations.length < 2) return "";
const options = installations
.map(
(i) =>
``,
)
.join("");
return `
`;
}
export const POSTURES = ["negative", "positive", "informational"] as const;
export type Posture = (typeof POSTURES)[number];
export function normalizePosture(value: string | null): Posture | null {
return POSTURES.includes(value as Posture) ? (value as Posture) : null;
}
export interface DashboardData {
login: string;
installationId: number;
orgLogin: string;
installations: InstallationOption[];
framework: Framework;
// Null = show every posture; otherwise the table is narrowed to the one
// whose card the user clicked.
posture: Posture | null;
rows: EvidenceRow[];
exports: ExportListRow[];
lastPolledAt: string | null;
excludedRepos: string[];
// Repos seen in this installation's snapshots that aren't excluded yet —
// the options the exclusion form offers.
excludableRepos: string[];
}
// Deliberately narrower than `unknown`: an object reaching here would render
// as "[object Object]" in an evidence table, which is worse than failing.
// Keeping the parameter to primitives makes that a compile error instead.
function esc(value: string | number | null | undefined): string {
return String(value ?? "").replace(/[&<>"']/g, (c) => {
switch (c) {
case "&": return "&";
case "<": return "<";
case ">": return ">";
case '"': return """;
default: return "'";
}
});
}
const STYLE = `
:root { color-scheme: light; }
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #1a1a1a; background: #f6f7f9; }
header { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem;
padding: 1rem 1.5rem; background: #fff; border-bottom: 1px solid #e2e5e9; flex-wrap: wrap; }
header h1 { font-size: 1.05rem; margin: 0; }
header .who { color: #666; font-size: 0.85rem; }
header .who a { color: #0055dc; margin-left: 0.75rem; }
main { max-width: 1100px; margin: 0 auto; padding: 1.5rem; }
.cards { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
.card { flex: 1 1 120px; background: #fff; border: 1px solid #e2e5e9; border-radius: 8px; padding: 0.9rem 1rem; }
a.card { display: block; color: inherit; text-decoration: none; }
a.card:hover { border-color: #0055dc; }
a.card.active { border-color: #0055dc; box-shadow: inset 0 0 0 1px #0055dc; }
a.card.active .l::after { content: " ✕"; }
.card .n { font-size: 1.6rem; font-weight: 600; }
.card .l { color: #666; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.03em; }
.n.positive { color: #1a8039; } .n.negative { color: #b32626; } .n.informational { color: #666; }
.bar { display: flex; gap: 1rem; align-items: center; flex-wrap: wrap; margin-bottom: 1rem; }
.bar .filters a { margin-right: 0.5rem; text-decoration: none; color: #0055dc; padding: 0.2rem 0.5rem; border-radius: 5px; }
.bar .filters a.active { background: #0055dc; color: #fff; }
form { display: inline-flex; gap: 0.4rem; align-items: center; margin: 0; }
input, select, button { font: inherit; padding: 0.35rem 0.6rem; border: 1px solid #c9ced6; border-radius: 6px; background: #fff; }
button { cursor: pointer; background: #0055dc; color: #fff; border-color: #0055dc; }
button.secondary { background: #fff; color: #1a1a1a; }
table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid #e2e5e9; border-radius: 8px; overflow: hidden; }
th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #eef0f3; font-size: 0.85rem; }
th { background: #fafbfc; font-weight: 600; color: #444; }
tr:last-child td { border-bottom: none; }
.posture { font-weight: 600; }
.posture.positive { color: #1a8039; } .posture.negative { color: #b32626; } .posture.informational { color: #888; }
.section-title { font-size: 1rem; margin: 2rem 0 0.75rem; }
.muted { color: #888; }
code { background: #eef0f3; padding: 0.1rem 0.3rem; border-radius: 4px; font-size: 0.85em; }
`;
export function renderDashboard(data: DashboardData): string {
const counts = { positive: 0, negative: 0, informational: 0 };
const repos = new Set();
for (const r of data.rows) {
counts[r.posture as keyof typeof counts] = (counts[r.posture as keyof typeof counts] ?? 0) + 1;
if (r.repo) repos.add(r.repo);
}
// Cards count the whole framework view, so the numbers stay stable while a
// posture filter is applied — otherwise clicking "Gaps" would zero the other
// two cards and there would be nothing left to click.
const href = (framework: Framework, posture: Posture | null) =>
`/?framework=${framework}${posture ? `&posture=${posture}` : ""}`;
const frameworkTab = (value: Framework, label: string) =>
`${label}`;
// Clicking the active card clears the filter rather than reapplying it.
const postureCard = (posture: Posture, label: string) => {
const active = data.posture === posture;
return `
`,
)
.join("");
let banner: string;
if (!diff.currentAt) {
banner = `
No access data collected yet. Access review requires the App to be
installed on an organization (personal accounts have no membership to review),
and at least one sync to have run.
`;
} else if (!diff.priorAt) {
banner = `
Baseline captured ${esc(diff.currentAt)} (${diff.currentCount} access
entries). No earlier snapshot before ${esc(data.since)} to compare against yet — the next sync
after that date will produce a diff.