import type { MapKind } from '../workspace/store'; import { ADD_DIALOG, ADD_SCRIPT, ADD_STYLE } from './addProject'; import { APP_STYLE } from './appStyle'; import { BRAND_STYLE, FAVICON, MARK, STYLE, THEME_BOOT, THEME_SCRIPT, THEME_STYLE } from './theme'; export interface HubMap { unresolvedPaths?: number; unresolvedSchemas?: number; kind: MapKind; scannedAt?: string; scannedFrom?: string; endpoints: number; screens: number; calls: number; unresolved: number; open: number; both: number; uncalled: number; feOnly: number; unpaired: number; hasFe: boolean; hasBe: boolean; } export interface HubRev { kind: 'fe' | 'be'; branch?: string; sha?: string; } export interface HubProject { id: string; name: string; fe?: string; be?: string; hints?: string; rev?: HubRev[]; maps: HubMap[]; } export interface HubOptions { workspace: string; linkTo: (project: HubProject) => string | null; live: boolean; } // cm:edge lockstep -> packages/cli/src/view/appStyle.ts — the shell, the rail, the head, the tiles, // the panels and the watch list all come from there so both pages are one design. // cm:why Only what is unique to the list of projects lives here; anything added below that a // project page would also want belongs there instead. const HUB_STYLE = ` .detail { display:flex; flex-direction:column; } .has-js .detail { display:none; } .has-js .detail.on { display:flex; } .mapline { display:flex; align-items:baseline; gap:8px; font-size:12px; margin-top:7px; } .mapline:first-of-type { margin-top:0; } .mapline .kind2 { font:600 10px/1 ui-monospace,monospace; border:1px solid var(--line); border-radius:5px; padding:4px 6px; color:var(--muted); min-width:50px; text-align:center; flex:none; } .mapline .when { margin-left:auto; color:var(--muted); font-size:11px; flex:none; } .mapline.stale .kind2 { color:var(--guess); border-color:var(--guess); } .stalenote { margin:6px 0 0; font-size:11px; line-height:1.55; color:var(--guess); } .stalenote code { font:10.5px ui-monospace,monospace; word-break:break-all; } .watch .who2 { margin-left:auto; flex:none; font:11px ui-monospace,monospace; color:var(--muted); } .watch .more { font-size:11.5px; color:var(--muted); } .empty-box { border:1px dashed var(--line-2); border-radius:14px; background:var(--surface); padding:34px 26px; text-align:center; margin-top:8px; } .empty-box h2 { margin:0 0 6px; font-size:17px; } .empty-box p { margin:0 auto 16px; max-width:62ch; color:var(--muted); font-size:13px; line-height:1.65; } .empty-box code { background:var(--surface-2); padding:2px 6px; border-radius:5px; font:12px ui-monospace,monospace; } .none { color:var(--muted); font-size:12.5px; margin:0; } `; // cm:guard No backticks and no ${} below — embedded in a String.raw literal. // cm:why Switches panes in the browser instead of asking the server: `apiflow hub` writes this same // page as one static file with no server to ask, and a reload per click would wipe the scan log. export const HUB_SCRIPT = String.raw` (function () { const rail = document.getElementById('rail'); if (!rail) return; const items = [...rail.querySelectorAll('.ri')]; const projects = items.filter((el) => el.dataset.project !== undefined); const panes = [...document.querySelectorAll('.detail')]; const search = document.getElementById('hb-q'); const stateSel = document.getElementById('hb-state'); const sortSel = document.getElementById('hb-sort'); const count = document.getElementById('hb-count'); const state = { q: '', pick: '', sort: 'name', sel: null }; try { const saved = localStorage.getItem('apiflow-hub-sort'); if (saved) { state.sort = saved; sortSel.value = saved; } } catch (err) { /* a file:// page must still be able to sort */ } const num = (el, key) => Number(el.dataset[key] || 0); // cm:why A stale map outranks every real finding, because those findings were measured on a repo // the project no longer points at — the number being wrong beats the number being bad. The option // label spells this order out, so the ranking is not a mystery. const risk = (el) => num(el, 'open') * 3 + num(el, 'feonly') * 3 + (el.dataset.stale === '1' ? 1000 : 0) + num(el, 'unresolved') / 1000; const ORDER = { name: (a, b) => a.dataset.project.localeCompare(b.dataset.project), recent: (a, b) => num(b, 'scanned') - num(a, 'scanned'), oldest: (a, b) => num(a, 'scanned') - num(b, 'scanned'), endpoints: (a, b) => num(b, 'endpoints') - num(a, 'endpoints'), unresolved: (a, b) => num(b, 'unresolved') - num(a, 'unresolved'), risk: (a, b) => risk(b) - risk(a), }; const matches = (el) => { if (state.q && !el.dataset.hay.includes(state.q)) return false; if (state.pick === '') return true; if (state.pick === 'stale') return el.dataset.stale === '1'; return el.dataset.state === state.pick; }; const show = (id) => { state.sel = id; for (const el of items) el.classList.toggle('on', el.dataset.pick === id); for (const pane of panes) pane.classList.toggle('on', pane.dataset.detail === id); }; // cm:guard Every deliberate switch goes through the hash, so the address bar can never name one // project while the pane shows another — a reload would then land somewhere else than the screen. const pick = (id) => { if (location.hash.slice(1) === id) show(id); else location.hash = id; }; const apply = () => { const shown = projects.filter(matches); for (const el of projects) el.hidden = !matches(el); const box = rail.querySelector('.railitems'); for (const el of [...shown].sort(ORDER[state.sort] || ORDER.name)) box.appendChild(el); // cm:guard Says how many are hidden, never just how many are left: a count of 3 with no mention // of the other 9 reads as "this workspace has three projects". count.textContent = shown.length === projects.length ? projects.length + (projects.length === 1 ? ' project' : ' projects') : shown.length + '/' + projects.length + ' projects · ' + (projects.length - shown.length) + ' filtered out'; // cm:guard Never leaves the selection on a project the filter just hid — a detail pane whose rail // row is gone reads as a page that lost track of itself. const gone = state.sel !== null && state.sel !== 'all' && !shown.some((el) => el.dataset.pick === state.sel); // cm:why Falling back to nothing leaves the hash alone on purpose: a filter is not remembered, so // a reload should return to the project that was open, not to an empty screen. if (gone) { if (shown.length > 0) pick(shown[0].dataset.pick); else show('none'); } }; search.addEventListener('input', () => { state.q = search.value.trim().toLowerCase(); apply(); }); stateSel.addEventListener('change', () => { state.pick = stateSel.value; apply(); }); sortSel.addEventListener('change', () => { state.sort = sortSel.value; try { localStorage.setItem('apiflow-hub-sort', state.sort); } catch (err) { /* if it cannot be stored, let it go */ } apply(); }); for (const el of items) el.addEventListener('click', () => pick(el.dataset.pick)); const reset = document.getElementById('hb-reset'); if (reset) reset.addEventListener('click', () => { state.q = ''; state.pick = ''; search.value = ''; stateSel.value = ''; apply(); // cm:why Lands on the workspace pane, not on whichever project the cleared filter happens to put // first: the selection that led here was thrown away when its row disappeared. pick('all'); }); const fromHash = () => { const want = decodeURIComponent(location.hash.slice(1)); const found = items.some((el) => el.dataset.pick === want); show(found ? want : 'all'); }; window.addEventListener('hashchange', fromHash); // cm:edge contract -> packages/cli/src/view/addProject.ts — it fires this after DELETE /api/projects/:id // succeeds, because only this script knows the row and the pane are two separate elements. document.addEventListener('apiflow:project-removed', (ev) => { const id = ev.detail.id; const wasOpen = state.sel === id; for (const el of [...projects, ...panes]) { if (el.dataset.pick === id || el.dataset.detail === id) el.remove(); } const at = projects.findIndex((el) => el.dataset.pick === id); if (at >= 0) projects.splice(at, 1); // cm:guard Clears the selection BEFORE re-filtering, or the fallback inside apply picks the next // project and lands on a detail pane nobody asked for right after a delete. if (wasOpen) state.sel = null; apply(); if (wasOpen) pick('all'); }); apply(); fromHash(); })(); `; function escapeHtml(value: string): string { return value.replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c] as string); } // cm:why Ages are rendered on the SERVER for the live hub and baked in for the static one, so the // caller decides `now` — a page that computes it in the browser would drift against the map it names. export function relativeAge(iso: string | undefined, now: number): string { if (iso === undefined) return 'not scanned'; const minutes = Math.round((now - new Date(iso).getTime()) / 60000); if (minutes < 1) return 'just now'; if (minutes < 60) return `${minutes}m ago`; const hours = Math.round(minutes / 60); return hours < 48 ? `${hours}h ago` : `${Math.round(hours / 24)}d ago`; } const plural = (n: number, word: string): string => `${n} ${word}${n === 1 ? '' : 's'}`; const bestOf = (project: HubProject): HubMap | undefined => project.maps.find((m) => m.kind === 'linked') ?? project.maps[0]; const sidesOf = (project: HubProject): string => [project.fe !== undefined ? 'FE' : null, project.be !== undefined ? 'BE' : null].filter(Boolean).join('+'); const stateOf = (project: HubProject): string => { const best = bestOf(project); if (best === undefined) return 'unscanned'; if (best.hasFe && best.hasBe) return 'both'; return best.hasBe ? 'be' : 'fe'; }; const newestScan = (project: HubProject): number => project.maps .map((m) => (m.scannedAt === undefined ? 0 : new Date(m.scannedAt).getTime())) .reduce((a, b) => Math.max(a, b), 0); // cm:guard The rail shows the age of the NEWEST map, not of maps[0]: on a linked project the first // entry is whichever kind was written first, so reading it would age the row by a whole scan. const newestAge = (project: HubProject, now: number): string => { const newest = newestScan(project); return newest === 0 ? 'not scanned' : relativeAge(new Date(newest).toISOString(), now); }; // cm:edge lockstep -> packages/cli/src/view/app.ts#overview — the same four buckets, the same class names, so the // bar on a project card and the bar on that project's own page cannot end up telling different stories. const SEGMENTS = [ { key: 'both' as const, css: 'd-both', label: 'seen from both sides' }, { key: 'uncalled' as const, css: 'd-uncalled', label: 'declared, no screen calls it' }, { key: 'feOnly' as const, css: 'd-feonly', label: 'FE calls it, API does not declare it' }, { key: 'unpaired' as const, css: 'd-unpaired', label: 'not reconciled yet' }, ]; const total4 = (map: HubMap): number => SEGMENTS.reduce((n, s) => n + map[s.key], 0); function micro(map: HubMap): string { const total = total4(map); if (total === 0) return ''; const fills = SEGMENTS .map((s) => ``) .join(''); return `${fills}`; } function recon(map: HubMap): string { const total = total4(map); if (total === 0) return '

Nothing reconciled yet — no map.

'; const bars = SEGMENTS .map((s) => ``) .join(''); const legend = SEGMENTS .map((s) => `
${map[s.key].toLocaleString('en-US')}` + ` ${s.label}
`) .join(''); return `
${bars}
${legend}
`; } // cm:guard Says what is MISSING before it says what is wrong: on a one-sided scan the honest label // is "BE not scanned", and printing a comparison finding there invents a defect out of a gap. // cm:edge lockstep -> packages/cli/src/view/app.ts#overview — same `watch` rows, so a finding looks the same // here and on the project page it links into. function watch(map: HubMap, href: string | null): string { const rows: string[] = []; const row = (n: number, text: string, tone: string, hash: string) => { const inner = `${n.toLocaleString('en-US')}${text}`; rows.push(href === null ? `
${inner}
` : `${inner}`); }; if (map.open > 0) row(map.open, 'no auth gate found', 'bad', '#alerts'); if (map.hasBe && map.feOnly > 0) row(map.feOnly, 'FE calls it, API does not declare it', 'bad', '#alerts'); if (map.hasFe && map.uncalled > 0) row(map.uncalled, 'declared by the API, called by no screen', '', '#alerts'); if ((map.unresolvedPaths ?? map.unresolved) > 0) row(map.unresolvedPaths ?? map.unresolved, 'calls whose path could not be resolved', 'warn', '#unresolved'); if ((map.unresolvedSchemas ?? 0) > 0) row(map.unresolvedSchemas ?? 0, 'endpoints with no request/response schema in the code', 'warn', '#unresolved'); if (!map.hasBe) rows.push('
BE not scanned — nothing to reconcile against.
'); if (!map.hasFe) rows.push('
FE not scanned — no idea which screens call it.
'); return rows.length === 0 ? '

Nothing worth a look.

' : `
${rows.join('')}
`; } const revOf = (project: HubProject, kind: 'fe' | 'be'): string => { const found = (project.rev ?? []).find((r) => r.kind === kind); const label = [found?.branch, found?.sha].filter((x) => x !== undefined).join(' · '); return label === '' ? 'revision unreadable' : `${escapeHtml(label)}`; }; // cm:edge lockstep -> packages/cli/src/view/panes.ts#kpiStrip — same three lines in the same order, because the // strip on a project page and the strip here sit one click apart and must not be different heights. const kpi = (lab: string, value: number, sub: string, alarm = false): string => `
${lab}
` + `
${value.toLocaleString('en-US')}
${sub}
`; // cm:edge contract -> packages/cli/src/view/hub.ts#HUB_SCRIPT — it filters, sorts and selects on these data-* attributes, // so a renamed attribute silently turns a filter into a no-op. function railItem(project: HubProject, now: number): string { const best = bestOf(project); const stale = project.maps.some((m) => m.scannedFrom !== undefined); const alerts = best === undefined ? 0 : best.open + (best.hasBe ? best.feOnly : 0); const badges = [ best === undefined ? '' : `${escapeHtml(newestAge(project, now))}`, alerts > 0 ? `${alerts.toLocaleString('en-US')} alert` : '', stale ? 'root drifted' : '', ].filter((x) => x !== ''); const counts = best === undefined ? 'not scanned' : `${best.endpoints.toLocaleString('en-US')} ep · ${best.screens.toLocaleString('en-US')} screens`; return ``; } function detail(project: HubProject, options: HubOptions, now: number): string { const href = options.linkTo(project); const best = bestOf(project); const roots = (['fe', 'be'] as const) .filter((kind) => project[kind] !== undefined) .map((kind) => `
${kind.toUpperCase()}` + `${escapeHtml(project[kind] as string)}` + ` ${revOf(project, kind)}
`) .join(''); // cm:guard A map scanned from a directory the project no longer points at gets its own line, never // folded into the findings: its numbers describe a different repo, so that must be read first. const lines = project.maps .map((m) => `
${m.kind}` + `${m.endpoints.toLocaleString('en-US')} endpoints · ${m.screens.toLocaleString('en-US')} screens · ${m.calls.toLocaleString('en-US')} calls` + `${escapeHtml(relativeAge(m.scannedAt, now))}
` + (m.scannedFrom === undefined ? '' : `

this map was scanned from ${escapeHtml(m.scannedFrom)} — not the current root. Re-scan to make it true.

`)) .join(''); // cm:why A project with no map gets a scan button, not an instruction to go and type the CLI: this // page can run the scan itself now, and the state it is describing is the state it can fix. const actions = options.live ? `${project.fe === undefined ? '' : ``} ${project.be === undefined ? '' : ``} ` : ''; // cm:guard Every tile says which map it was measured on: a project can hold three, and a number // with no map behind it invites the reader to assume it came from the fullest one. const from = best === undefined ? '' : `${best.kind} map`; const tiles = best === undefined ? '' : `
${kpi('endpoint', best.endpoints, from)} ${kpi('screens', best.screens, from)} ${kpi('calls', best.calls, from)} ${kpi('no auth', best.open, 'no gate found', true)} ${kpi('FE-only', best.hasBe ? best.feOnly : 0, best.hasBe ? from : 'BE not scanned', true)} ${kpi('unresolved', best.unresolved, (best.unresolvedSchemas ?? 0) > 0 ? `${best.unresolvedPaths ?? best.unresolved} path · ${best.unresolvedSchemas ?? 0} schema` : 'not part of the numbers above', true)}
`; return `

${escapeHtml(project.name)}

${sidesOf(project)} ${project.name === project.id ? '' : `${escapeHtml(project.id)}`}
${roots}
${href !== null ? `Open the map →` : ''} ${actions}
${tiles}

Reconciliation${best === undefined ? '' : ` — ${best.endpoints.toLocaleString('en-US')} endpoints`}

${best === undefined ? '

No map yet — hit Scan to build the first one.

' : recon(best)}

Maps scanned

${project.maps.length > 0 ? lines : '

No map yet — hit Scan to build the first one.

'}

Worth a look

${best === undefined ? '

Not scanned, so nothing is known yet.

' : watch(best, href)}
`; } interface Todo { weight: number; tone: string; num: number; text: string; who: string; href: string | null; hash: string; } // cm:why One ranked list of things to actually go and look at. Six big numbers over the whole // workspace say a lot and point nowhere: nothing in them tells you which project to open. function todos(projects: HubProject[], options: HubOptions): Todo[] { const out: Todo[] = []; for (const project of projects) { const href = options.linkTo(project); const add = (weight: number, tone: string, num: number, text: string, hash: string) => out.push({ weight, tone, num, text, who: project.id, href, hash }); const best = bestOf(project); const staleMaps = project.maps.filter((m) => m.scannedFrom !== undefined).length; if (staleMaps > 0) add(100, 'warn', staleMaps, 'maps scanned from an older root — measured on a different repo', ''); if (best === undefined) { add(60, '', 0, 'never scanned', ''); continue; } if (best.open > 0) add(50, 'bad', best.open, 'endpoints with no auth gate found', '#alerts'); if (best.hasBe && best.feOnly > 0) add(45, 'bad', best.feOnly, 'FE calls it, API does not declare it', '#alerts'); if (!best.hasBe) add(30, '', 0, 'BE not scanned — nothing to reconcile', ''); if (!best.hasFe) add(30, '', 0, 'FE not scanned — no idea which screens call it', ''); if (best.hasFe && best.uncalled > 0) add(20, '', best.uncalled, 'declared by the API, called by no screen', '#alerts'); if ((best.unresolvedPaths ?? best.unresolved) > 0) add(10, 'warn', best.unresolvedPaths ?? best.unresolved, 'calls whose path could not be resolved', '#unresolved'); if ((best.unresolvedSchemas ?? 0) > 0) add(10, 'warn', best.unresolvedSchemas ?? 0, 'endpoints with no request/response schema in the code', '#unresolved'); } return out.sort((a, b) => b.weight - a.weight); } // cm:why Totals over every project, with unresolved kept OUT of the endpoint count: the two are // counted separately everywhere else, and a hub that adds them up contradicts every other page. function overview(projects: HubProject[], options: HubOptions, live: boolean): string { const best = projects.map(bestOf).filter((m): m is HubMap => m !== undefined); const sum = (pick: (m: HubMap) => number) => best.reduce((n, m) => n + pick(m), 0); const scanned = projects.filter((p) => p.maps.length > 0).length; const list = todos(projects, options); const LIMIT = 10; // cm:guard Says how many it did not print. A list that stops at ten with no word about the rest // reads as "that is everything", which is exactly the lie this page must not tell. const rows = list.slice(0, LIMIT).map((item) => { const inner = `${item.num === 0 ? '—' : item.num.toLocaleString('en-US')}` + `${item.text}${escapeHtml(item.who)}`; return item.href === null ? `
${inner}
` : `${inner}`; }).join(''); const more = list.length > LIMIT ? `
… and ${list.length - LIMIT} more, open a project to see them
` : ''; return `

Whole workspace

${plural(projects.length, 'project')} ${live ? 'live' : 'static'}
WS${escapeHtml(options.workspace)} ${scanned}/${projects.length} scanned
${kpi('endpoints', sum((m) => m.endpoints), 'across every project')} ${kpi('screens', sum((m) => m.screens), 'across every project')} ${kpi('calls', sum((m) => m.calls), 'FE → endpoint')} ${kpi('no auth', sum((m) => m.open), 'no gate found', true)} ${kpi('FE-only', sum((m) => (m.hasBe ? m.feOnly : 0)), 'only projects scanned on both sides', true)} ${kpi('unresolved', sum((m) => m.unresolved), `${sum((m) => m.unresolvedPaths ?? m.unresolved)} path · ${sum((m) => m.unresolvedSchemas ?? 0)} schema`, true)}

Worth a look${list.length === 0 ? '' : ` (${list.length})`}

${list.length === 0 ? '

Nothing. Every project is scanned on both sides with no alerts.

' : `
${rows}${more}
`}
`; } export function renderHub(projects: HubProject[], options: HubOptions, now: number): string { // cm:guard The empty state points at the button when there IS one. Telling someone to go and type // a CLI command on a page that can do the thing is how a feature stays undiscovered. const empty = options.live ? `

No project yet

apiflow reads an FE repo and a BE repo and builds a screen ↔ endpoint ↔ field map, so it can answer “if I change this endpoint, which screens break”. It only writes to the workspace below — never a single byte into the repo it reads.

` : `

No project yet

This static page was generated while the workspace ${escapeHtml(options.workspace)} was still empty.

apiflow project add webapp --fe=/path/to/ui --be=/path/to/api

`; const railHead = `
${projects.map((p) => railItem(p, now)).join('\n')}
`; // cm:why The same shell as a project page, down to the rail width and the foot: opening a project // from here should look like walking into the next room, not into another application. return ` apiflow — ${plural(projects.length, 'project')} ${THEME_BOOT}
${MARK}

apiflow

${projects.length === 0 ? '' : railHead}
${options.live ? '' : ''}

${escapeHtml(options.workspace)}

${options.live ? '
' : ''}
    ${projects.length === 0 ? empty : `${overview(projects, options, options.live)}
    ${projects.map((p) => detail(p, options, now)).join('\n')}
    

No match

No project matches the search box and the filters as set.

`} ${projects.length === 0 ? '' : '

Every number here is a candidate, not a verdict. “no auth” means no gate was found in the code. “unresolved” are calls apiflow saw but could not resolve to a path — they are not part of any of the other numbers.

'}
${options.live ? ADD_DIALOG : ''} ${options.live ? `` : ''} `; }