diff --git a/devlog/_plan/261107_telemetry_dashboard/060_review_round.md b/devlog/_plan/261107_telemetry_dashboard/060_review_round.md index de1fad24e9a..c00c57a0230 100644 --- a/devlog/_plan/261107_telemetry_dashboard/060_review_round.md +++ b/devlog/_plan/261107_telemetry_dashboard/060_review_round.md @@ -108,3 +108,20 @@ D20 Dashboard: GitHub release download totals follow pagination (per_page=100, f gets one budget per /64; requests without cf-connecting-ip all share one budget. - Installs without an installer (the standalone `ocx.exe`, npm) meet SignPath's install-time conditions only through the first-run notice (CLI prompt or dashboard popup); D21 accepts that reading. + +## Delivery (round 1) + +| Layer | Fix commit | Merged upward as | +| --- | --- | --- | +| 1 plan | `3e4597d1ba` | — | +| 2 client core | `9e0284d392` | `64ac26aa7d` (layer 1 merged in) | +| 3 GUI | `5b6092ca14` | `ea8175f2b7` | +| 4 collector | `057c57e8be` | `54019606ff` | +| 5 docs | `d8fc29c2c1` | `4ebb3c1499` | +| 6 dashboard | `ea78acfa0f` | `bed30cf2b7` | + +The layer 5 merge conflicted where layers 2 and 5 both rewrote the privacy statement (README, seven +translations, `readme/i18n-manifest.json`, `code-signing.md`, Turkish `code-signing.md`) and +`structure/telemetry.md`. Layer 5's text was kept: it carries the same D12 statement plus the guide link, +and its structure doc is a superset of layer 2's. Layer 2's README test passes against it. Branches move by +merge commits, never by force-push. diff --git a/docs-site/src/components/Header.astro b/docs-site/src/components/Header.astro index 1e6c9e2d582..7457c6d4210 100644 --- a/docs-site/src/components/Header.astro +++ b/docs-site/src/components/Header.astro @@ -47,6 +47,7 @@ const fr = { 'Architecture': 'Architecture', 'Proxy API Formats': 'Formats de l’API proxy', 'Management API': 'API de gestion', + 'Dashboard': 'Tableau de bord', 'Contributing': 'Contribuer', 'Main': 'Navigation principale', 'Select language': 'Choisir la langue', @@ -145,6 +146,11 @@ const groups: NavGroup[] = [ }, ]; +const dashboard: NavLink = { + label: t('Dashboard', '대시보드', '仪表盘', 'Дашборд', 'ダッシュボード', '儀表板', 'Panel'), + href: `${prefix}dashboard/`, +}; + const contributing: NavLink = { label: t('Contributing', '기여하기', '贡献', 'Участие в проекте', 'コントリビュート', '貢獻', 'Katkıda Bulunma'), href: `${prefix}contributing/`, @@ -178,6 +184,9 @@ const contributing: NavLink = { )) } + @@ -280,6 +289,7 @@ const contributing: NavLink = { ))} + {dashboard.label} {contributing.label} @@ -289,7 +299,7 @@ const contributing: NavLink = { + + diff --git a/docs-site/src/components/telemetry-dashboard/client.ts b/docs-site/src/components/telemetry-dashboard/client.ts new file mode 100644 index 00000000000..b7a3835be88 --- /dev/null +++ b/docs-site/src/components/telemetry-dashboard/client.ts @@ -0,0 +1,707 @@ +import type * as Echarts from "echarts"; + +type EchartsModule = typeof import("echarts"); +let echartsLoad: Promise | null = null; +// Loaded on first chart render: keeps ECharts out of the initial page script and lets +// helpers in this module be imported where the docs-site dependencies are not installed. +function loadEcharts(): Promise { + return (echartsLoad ??= import("echarts")); +} +import type { TelemetryDashboardCopy } from "../../data/telemetry-dashboard-i18n"; +import { + DEFAULT_STATS_REQUEST_TIMEOUT_MS, + buildChannelRows, + buildDailyChartAccessibleTable, + buildDailyProviderChart, + buildHeatmapCalendar, + buildKpiStrip, + buildModelShareRows, + buildPlatformRows, + buildProviderShareRows, + buildReliabilitySummary, + buildStatsUrl, + buildTokenMix, + buildVersionRows, + classifyStatsResponse, + createEmptyStatsSnapshot, + createStatsRequestSequencer, + fetchGithubReleasesPages, + fetchJsonWithTimeout, + formatCompactNumber, + formatExactNumber, + formatGeneratedTimestamp, + formatHeatmapCellAriaLabel, + navigateHeatmapDate, + parseDownloadsPayload, + parseStatsRangeParam, + type DailyChartMetric, + type HeatmapCell, + type HeatmapNavigationKey, + type TelemetryStatsRange, + type TelemetryStatsV1, +} from "./index"; + +export function mountTelemetryDashboard( + root: HTMLElement | null = document.querySelector("[data-telemetry-dashboard]"), +): void { + if (!root) return; + + const locale = root.dataset.locale ?? "en"; + const statsEndpoint = + root.dataset.statsEndpoint ?? "https://telemetry.opencodex.me/v1/stats"; + const copy = JSON.parse(root.dataset.copy ?? "{}") as TelemetryDashboardCopy; + + let activeRange: TelemetryStatsRange = parseStatsRangeParam( + new URLSearchParams(window.location.search).get("range") ?? + root.dataset.defaultRange, + ); + let activeChartMetric: DailyChartMetric = "tokens"; + let latestStats: TelemetryStatsV1 | null = null; + let chartInstance: Echarts.ECharts | null = null; + let currentHeatmapCells: HeatmapCell[] = []; + let focusedHeatmapDate: string | null = null; + const statsSequencer = createStatsRequestSequencer(DEFAULT_STATS_REQUEST_TIMEOUT_MS); + + const setDashboardState = ( + state: "loading" | "ready" | "empty" | "error", + detail?: string, + ) => { + root.dataset.dashboardState = state; + for (const panel of root.querySelectorAll("[data-state-panel]")) { + panel.hidden = panel.dataset.statePanel !== state; + } + if (state === "error" && detail) { + const errBody = root.querySelector("[data-error-body]"); + if (errBody) { + errBody.textContent = `${copy.stateErrorBody} (${detail})`; + } + } + }; + + const syncRangePills = () => { + for (const btn of root.querySelectorAll("[data-range-pill]")) { + const isCurrent = btn.dataset.rangePill === activeRange; + btn.setAttribute("aria-pressed", isCurrent ? "true" : "false"); + } + }; + + const syncChartMetricPills = () => { + for (const btn of root.querySelectorAll("[data-chart-metric]")) { + const isCurrent = btn.dataset.chartMetric === activeChartMetric; + btn.setAttribute("aria-pressed", isCurrent ? "true" : "false"); + } + }; + + const renderDailyChart = () => { + const chartEl = root.querySelector("[data-daily-chart]"); + if (!chartEl) return; + const textColor = getComputedStyle(document.body).color; + const borderColor = + getComputedStyle(document.documentElement) + .getPropertyValue("--sl-color-gray-5") + .trim() || "rgba(128,128,128,0.25)"; + + const model = buildDailyProviderChart( + latestStats?.dailyByProvider, + latestStats?.daily, + activeChartMetric, + ); + const metricLabel = + activeChartMetric === "requests" ? copy.metricRequests : copy.metricTokens; + chartEl.setAttribute( + "aria-label", + `${copy.dailyChartTitle} (${metricLabel}): ${formatCompactNumber(model.total)}`, + ); + + const metricHeaderEl = root.querySelector( + "[data-daily-chart-metric-col]", + ); + if (metricHeaderEl) { + metricHeaderEl.textContent = metricLabel; + } + const chartTbody = root.querySelector( + "[data-daily-chart-tbody]", + ); + if (chartTbody) { + const srRows = buildDailyChartAccessibleTable(model, locale); + if (srRows.length === 0) { + chartTbody.replaceChildren(makeEmptyRow(3, copy.noDataInTable)); + } else { + chartTbody.replaceChildren( + ...srRows.map((row) => { + const tr = document.createElement("tr"); + const tdDay = document.createElement("td"); + tdDay.textContent = row.day; + const tdProv = document.createElement("td"); + tdProv.textContent = row.providerKind; + const tdVal = document.createElement("td"); + tdVal.textContent = `${row.valueExact} (${row.valueCompact})`; + tr.append(tdDay, tdProv, tdVal); + return tr; + }), + ); + } + } + + const applyChart = (echarts: EchartsModule) => { + if (!chartInstance) { + chartInstance = echarts.init(chartEl); + } + chartInstance.setOption( + { + textStyle: { color: textColor }, + tooltip: { + trigger: "axis", + axisPointer: { type: "shadow" }, + }, + legend: { + top: 0, + textStyle: { color: textColor, fontSize: 11 }, + }, + grid: { left: 52, right: 16, top: 34, bottom: 28 }, + xAxis: { + type: "category", + data: model.days, + axisLabel: { color: textColor, fontSize: 11 }, + axisLine: { lineStyle: { color: borderColor } }, + }, + yAxis: { + type: "value", + axisLabel: { + color: textColor, + fontSize: 11, + formatter: (val: number) => formatCompactNumber(val), + }, + splitLine: { lineStyle: { color: borderColor } }, + }, + series: model.series.map((s) => ({ + name: s.providerKind, + type: "bar", + stack: "daily", + itemStyle: { color: s.color }, + emphasis: { focus: "series" }, + data: s.values, + })), + }, + { notMerge: true }, + ); + }; + void loadEcharts().then(applyChart).catch(() => { + chartEl.dataset.chartUnavailable = "true"; + }); + }; + + const renderHeatmap = (stats: TelemetryStatsV1 | null) => { + const gridEl = root.querySelector("[data-heatmap-grid]"); + const monthsEl = root.querySelector("[data-heatmap-months]"); + const activeDaysEl = root.querySelector("[data-heatmap-active-days]"); + const peakEl = root.querySelector("[data-heatmap-peak]"); + const rangeLabelEl = root.querySelector("[data-heatmap-range-label]"); + const tooltipEl = root.querySelector("[data-heatmap-tooltip]"); + if (!gridEl || !monthsEl) return; + + const calendar = buildHeatmapCalendar(stats?.heatmap, locale); + currentHeatmapCells = calendar.cells; + + if (activeDaysEl) { + activeDaysEl.textContent = copy.heatmapActiveDays.replace( + "{count}", + formatExactNumber(calendar.activeDays, locale), + ); + } + if (peakEl) { + if (calendar.peakDay) { + peakEl.hidden = false; + peakEl.textContent = copy.heatmapPeakDay + .replace("{date}", calendar.peakDay.date) + .replace("{count}", formatExactNumber(calendar.peakDay.activeInstalls, locale)); + } else { + peakEl.hidden = true; + } + } + if (rangeLabelEl && calendar.startDate && calendar.endDate) { + rangeLabelEl.textContent = `${calendar.startDate} → ${calendar.endDate} (UTC)`; + } + + if (calendar.weekCount === 0) { + focusedHeatmapDate = null; + if (peakEl) { + peakEl.hidden = true; + peakEl.textContent = ""; + } + if (rangeLabelEl) { + rangeLabelEl.textContent = "365d UTC"; + } + gridEl.style.setProperty("--heatmap-weeks", "53"); + monthsEl.style.setProperty("--heatmap-weeks", "53"); + monthsEl.replaceChildren(); + const emptyButtons = Array.from({ length: 53 * 7 }, () => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "td-heatmap-cell"; + btn.dataset.grade = "0"; + btn.disabled = true; + btn.tabIndex = -1; + btn.setAttribute("aria-hidden", "true"); + return btn; + }); + gridEl.replaceChildren(...emptyButtons); + if (tooltipEl) { + tooltipEl.dataset.active = "false"; + tooltipEl.setAttribute("aria-hidden", "true"); + tooltipEl.textContent = "\u00a0"; + } + return; + } + + const weeks = String(calendar.weekCount); + gridEl.style.setProperty("--heatmap-weeks", weeks); + monthsEl.style.setProperty("--heatmap-weeks", weeks); + + monthsEl.replaceChildren( + ...calendar.monthMarkers.map((m) => { + const span = document.createElement("span"); + span.style.gridColumnStart = String(m.weekIndex + 1); + span.textContent = m.label; + return span; + }), + ); + + const selectableCells = calendar.cells.filter((c) => c.selectable); + focusedHeatmapDate = + selectableCells[selectableCells.length - 1]?.date ?? null; + + const showCellTooltip = (cell: HeatmapCell) => { + if (!tooltipEl || !cell.selectable) return; + tooltipEl.dataset.active = "true"; + tooltipEl.setAttribute("aria-hidden", "false"); + tooltipEl.textContent = `${cell.date} · ${formatExactNumber(cell.activeInstalls, locale)} ${copy.heatmapTooltipUnit}`; + }; + + const hideCellTooltip = () => { + if (!tooltipEl) return; + tooltipEl.dataset.active = "false"; + tooltipEl.setAttribute("aria-hidden", "true"); + }; + + const buttons = calendar.cells.map((cell) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "td-heatmap-cell"; + btn.dataset.grade = String(cell.grade); + if (cell.selectable) { + btn.dataset.date = cell.date; + btn.tabIndex = cell.date === focusedHeatmapDate ? 0 : -1; + btn.setAttribute( + "aria-label", + formatHeatmapCellAriaLabel(cell, locale, copy.heatmapTooltipUnit), + ); + btn.setAttribute("aria-describedby", "td-heatmap-tooltip"); + btn.addEventListener("mouseenter", () => showCellTooltip(cell)); + btn.addEventListener("mouseleave", hideCellTooltip); + btn.addEventListener("focus", () => { + focusedHeatmapDate = cell.date; + showCellTooltip(cell); + }); + btn.addEventListener("blur", hideCellTooltip); + btn.addEventListener("keydown", (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + hideCellTooltip(); + return; + } + if ( + ![ + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "Home", + "End", + ].includes(event.key) + ) { + return; + } + event.preventDefault(); + const nextDate = navigateHeatmapDate( + currentHeatmapCells, + cell.date, + event.key as HeatmapNavigationKey, + ); + if (!nextDate) return; + focusedHeatmapDate = nextDate; + for (const b of gridEl.querySelectorAll("button[data-date]")) { + const isTarget = b.dataset.date === nextDate; + b.tabIndex = isTarget ? 0 : -1; + if (isTarget) b.focus(); + } + }); + } else { + btn.disabled = true; + btn.tabIndex = -1; + btn.setAttribute("aria-hidden", "true"); + } + return btn; + }); + + gridEl.replaceChildren(...buttons); + }; + + const makeEmptyRow = (colSpan: number, message: string): HTMLTableRowElement => { + const tr = document.createElement("tr"); + tr.className = "td-empty-row"; + const td = document.createElement("td"); + td.colSpan = colSpan; + td.textContent = message; + tr.appendChild(td); + return tr; + }; + + const renderShareTable = ( + tbodyName: "providers" | "models", + rows: ReturnType, + ) => { + const tbody = root.querySelector( + `[data-tbody="${tbodyName}"]`, + ); + if (!tbody) return; + if (rows.length === 0) { + tbody.replaceChildren(makeEmptyRow(4, copy.noDataInTable)); + return; + } + tbody.replaceChildren( + ...rows.map((row) => { + const tr = document.createElement("tr"); + + const tdName = document.createElement("td"); + const identity = document.createElement("span"); + identity.className = "td-row-identity"; + const dot = document.createElement("span"); + dot.className = "td-row-dot"; + dot.style.backgroundColor = row.color; + dot.setAttribute("aria-hidden", "true"); + const label = document.createElement("span"); + label.textContent = row.label; + identity.append(dot, label); + tdName.appendChild(identity); + + const tdReq = document.createElement("td"); + tdReq.className = "td-num"; + tdReq.title = row.requestsExact; + tdReq.textContent = row.requestsCompact; + + const tdTok = document.createElement("td"); + tdTok.className = "td-num"; + tdTok.title = row.tokensExact; + tdTok.textContent = row.tokensCompact; + + const tdShare = document.createElement("td"); + tdShare.className = "td-num"; + tdShare.textContent = row.tokenShareLabel; + + tr.append(tdName, tdReq, tdTok, tdShare); + return tr; + }), + ); + }; + + const renderInstallTable = ( + tbodyName: "versions" | "platforms" | "channels", + rows: ReturnType, + includeNewInstalls: boolean, + ) => { + const tbody = root.querySelector( + `[data-tbody="${tbodyName}"]`, + ); + if (!tbody) return; + const cols = includeNewInstalls ? 4 : 3; + if (rows.length === 0) { + tbody.replaceChildren(makeEmptyRow(cols, copy.noDataInTable)); + return; + } + tbody.replaceChildren( + ...rows.map((row) => { + const tr = document.createElement("tr"); + const tdLabel = document.createElement("td"); + tdLabel.className = "td-mono"; + tdLabel.textContent = row.label; + + const tdInstalls = document.createElement("td"); + tdInstalls.className = "td-num"; + tdInstalls.title = row.installsExact; + tdInstalls.textContent = row.installsCompact; + + tr.append(tdLabel, tdInstalls); + + if (includeNewInstalls) { + const tdNew = document.createElement("td"); + tdNew.className = "td-num"; + tdNew.title = row.newInstallsExact ?? "0"; + tdNew.textContent = row.newInstallsCompact ?? "0"; + tr.appendChild(tdNew); + } + + const tdShare = document.createElement("td"); + tdShare.className = "td-num"; + tdShare.textContent = row.shareLabel; + tr.appendChild(tdShare); + return tr; + }), + ); + }; + + const applyStatsSnapshot = (stats: TelemetryStatsV1) => { + latestStats = stats; + + const updatedEl = root.querySelector("[data-updated-label]"); + const formattedTime = formatGeneratedTimestamp(stats.generatedAt, locale); + if (updatedEl && formattedTime) { + updatedEl.textContent = `${copy.updatedPrefix}: ${formattedTime}`; + } else if (updatedEl) { + updatedEl.textContent = `${copy.updatedPrefix}: ${copy.updatedPending}`; + } + + for (const kpi of buildKpiStrip(stats, locale)) { + const el = root.querySelector(`[data-kpi-value="${kpi.id}"]`); + if (el) { + el.textContent = kpi.compact; + el.title = kpi.exactTitle; + } + } + + const tokenMix = buildTokenMix(stats.totals.tokens, locale); + const totalMixEl = root.querySelector("[data-token-mix-total]"); + if (totalMixEl) { + totalMixEl.textContent = tokenMix.totalCompact; + totalMixEl.title = tokenMix.totalExactTitle; + } + for (const seg of tokenMix.segments) { + const barSeg = root.querySelector( + `[data-token-segment="${seg.id}"]`, + ); + if (barSeg) { + barSeg.style.flex = `${Math.max(seg.value, 0)} 1 0`; + barSeg.style.minWidth = seg.value > 0 ? "3px" : "0"; + barSeg.title = `${seg.id}: ${seg.exactTitle} (${seg.percentLabel})`; + } + const valEl = root.querySelector(`[data-token-value="${seg.id}"]`); + if (valEl) { + valEl.textContent = seg.compact; + valEl.title = seg.exactTitle; + } + const pctEl = root.querySelector(`[data-token-pct="${seg.id}"]`); + if (pctEl) { + pctEl.textContent = seg.percentLabel; + } + } + + renderHeatmap(stats); + renderDailyChart(); + + renderShareTable("providers", buildProviderShareRows(stats.providers, locale)); + renderShareTable("models", buildModelShareRows(stats.models, locale)); + + renderInstallTable("versions", buildVersionRows(stats.versions, locale), true); + renderInstallTable("platforms", buildPlatformRows(stats.platforms, locale), false); + renderInstallTable("channels", buildChannelRows(stats.channels, locale), true); + + const reliability = buildReliabilitySummary(stats.outcomes, stats.errors, locale); + const rateEl = root.querySelector("[data-completion-rate]"); + if (rateEl) { + rateEl.textContent = reliability.completedRatioLabel; + } + for (const out of reliability.outcomes) { + const segEl = root.querySelector( + `[data-outcome-segment="${out.key}"]`, + ); + if (segEl) { + segEl.style.flex = `${Math.max(out.count, 0)} 1 0`; + segEl.style.minWidth = out.count > 0 ? "3px" : "0"; + segEl.title = `${out.key}: ${out.exact} (${out.shareLabel})`; + } + const cntEl = root.querySelector( + `[data-outcome-count="${out.key}"]`, + ); + if (cntEl) { + cntEl.textContent = out.compact; + cntEl.title = out.exact; + } + const pctEl = root.querySelector( + `[data-outcome-pct="${out.key}"]`, + ); + if (pctEl) { + pctEl.textContent = out.shareLabel; + } + } + + const errorsTbody = root.querySelector( + '[data-tbody="errors"]', + ); + if (errorsTbody) { + if (reliability.errors.length === 0) { + errorsTbody.replaceChildren(makeEmptyRow(3, copy.noErrorsRecorded)); + } else { + errorsTbody.replaceChildren( + ...reliability.errors.map((err) => { + const tr = document.createElement("tr"); + const tdCause = document.createElement("td"); + tdCause.className = "td-mono"; + tdCause.textContent = err.cause; + + const tdCount = document.createElement("td"); + tdCount.className = "td-num"; + tdCount.title = err.exact; + tdCount.textContent = err.compact; + + const tdShare = document.createElement("td"); + tdShare.className = "td-num"; + tdShare.textContent = err.shareLabel; + + tr.append(tdCause, tdCount, tdShare); + return tr; + }), + ); + } + } + }; + + const loadTelemetryStats = async (range: TelemetryStatsRange) => { + activeRange = range; + syncRangePills(); + const ticket = statsSequencer.begin(range, DEFAULT_STATS_REQUEST_TIMEOUT_MS); + setDashboardState("loading"); + try { + const url = buildStatsUrl(range, statsEndpoint); + const response = await fetch(url, { + headers: { accept: "application/json" }, + signal: ticket.signal, + }); + if (!ticket.isCurrent()) return; + let body: unknown = null; + try { + body = await response.json(); + } catch { + body = null; + } + if (!ticket.isCurrent()) return; + const classified = classifyStatsResponse({ + ok: response.ok, + status: response.status, + body, + }); + if (classified.stats) { + applyStatsSnapshot(classified.stats); + } else { + applyStatsSnapshot(createEmptyStatsSnapshot(range)); + } + setDashboardState(classified.state, classified.detail); + } catch (error) { + if (!ticket.isCurrent()) return; + const isAbort = + error instanceof Error && error.name === "AbortError"; + const detailMessage = + ticket.didTimeout() || isAbort + ? "Request timed out" + : error instanceof Error + ? error.message + : String(error); + const classified = classifyStatsResponse({ + networkError: true, + errorMessage: detailMessage, + }); + applyStatsSnapshot(createEmptyStatsSnapshot(range)); + setDashboardState(classified.state, classified.detail); + } finally { + ticket.finish(); + } + }; + + const loadDownloads = async () => { + const [npmWeekly, npmMonthly, npmYearly, githubReleases] = await Promise.all([ + fetchJsonWithTimeout(root.dataset.npmWeeklyUrl, DEFAULT_STATS_REQUEST_TIMEOUT_MS), + fetchJsonWithTimeout(root.dataset.npmMonthlyUrl, DEFAULT_STATS_REQUEST_TIMEOUT_MS), + fetchJsonWithTimeout(root.dataset.npmYearlyUrl, DEFAULT_STATS_REQUEST_TIMEOUT_MS), + fetchGithubReleasesPages(root.dataset.githubReleasesUrl, DEFAULT_STATS_REQUEST_TIMEOUT_MS), + ]); + + const dl = parseDownloadsPayload({ + npmWeekly, + npmMonthly, + npmYearly, + githubReleases, + }); + + const setDlValue = (selector: string, value: number | null) => { + const el = root.querySelector(selector); + if (!el) return; + if (value === null) { + el.textContent = "—"; + el.title = copy.downloadsUnavailable; + } else { + el.textContent = formatCompactNumber(value); + el.title = formatExactNumber(value, locale); + } + }; + + setDlValue("[data-dl-npm-weekly]", dl.npmWeekly); + setDlValue("[data-dl-npm-monthly]", dl.npmMonthly); + setDlValue("[data-dl-npm-yearly]", dl.npmYearly); + const ghTotalEl = root.querySelector("[data-dl-gh-total]"); + if (ghTotalEl) { + if (dl.githubTotalDownloads === null) { + ghTotalEl.textContent = "—"; + ghTotalEl.title = copy.downloadsUnavailable; + delete ghTotalEl.dataset.partial; + } else if (dl.githubDownloadsPartial) { + const partialLabel = copy.downloadsPartial || "partial"; + ghTotalEl.textContent = `${formatCompactNumber(dl.githubTotalDownloads)} (${partialLabel})`; + ghTotalEl.title = `${formatExactNumber(dl.githubTotalDownloads, locale)} (${partialLabel})`; + ghTotalEl.dataset.partial = "true"; + } else { + ghTotalEl.textContent = formatCompactNumber(dl.githubTotalDownloads); + ghTotalEl.title = formatExactNumber(dl.githubTotalDownloads, locale); + ghTotalEl.dataset.partial = "false"; + } + } + setDlValue("[data-dl-gh-latest]", dl.githubLatestDownloads); + + const latestTagEl = root.querySelector("[data-dl-gh-latest-tag]"); + if (latestTagEl && dl.githubLatestTag) { + latestTagEl.textContent = `(${dl.githubLatestTag})`; + } + }; + + for (const btn of root.querySelectorAll("[data-range-pill]")) { + btn.addEventListener("click", () => { + const nextRange = parseStatsRangeParam(btn.dataset.rangePill); + const nextUrl = new URL(window.location.href); + nextUrl.searchParams.set("range", nextRange); + window.history.replaceState(null, "", nextUrl.toString()); + void loadTelemetryStats(nextRange); + }); + } + + for (const btn of root.querySelectorAll("[data-chart-metric]")) { + btn.addEventListener("click", () => { + activeChartMetric = + btn.dataset.chartMetric === "requests" ? "requests" : "tokens"; + syncChartMetricPills(); + renderDailyChart(); + }); + } + + const retryBtn = root.querySelector("[data-retry-btn]"); + retryBtn?.addEventListener("click", () => { + void loadTelemetryStats(activeRange); + }); + + window.addEventListener("resize", () => chartInstance?.resize()); + new MutationObserver(() => renderDailyChart()).observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); + + renderDailyChart(); + void loadTelemetryStats(activeRange); + void loadDownloads(); +} diff --git a/docs-site/src/components/telemetry-dashboard/constants.ts b/docs-site/src/components/telemetry-dashboard/constants.ts new file mode 100644 index 00000000000..50e330e9363 --- /dev/null +++ b/docs-site/src/components/telemetry-dashboard/constants.ts @@ -0,0 +1,188 @@ +import type { + HeatmapGrade, + OutcomeClassId, + TelemetryStatsRange, + TokenBucketId, +} from "./types"; + +export const TELEMETRY_STATS_ENDPOINT = "https://telemetry.opencodex.me/v1/stats"; + +export const TELEMETRY_RANGES: readonly TelemetryStatsRange[] = Object.freeze([ + "7d", + "30d", + "90d", + "365d", +]); + +export const TELEMETRY_RANGE_DAYS: Readonly> = + Object.freeze({ + "7d": 7, + "30d": 30, + "90d": 90, + "365d": 365, + }); + +export const DEFAULT_TELEMETRY_RANGE: TelemetryStatsRange = "30d"; + +export const NPM_PACKAGE_NAME = "@bitkyc08/opencodex"; +export const GITHUB_REPO_SLUG = "lidge-jun/opencodex"; +export const GITHUB_RELEASES_PER_PAGE = 100; +export const GITHUB_RELEASES_MAX_PAGES = 10; + +export const NPM_DOWNLOADS_WEEKLY_URL = + "https://api.npmjs.org/downloads/point/last-week/@bitkyc08/opencodex"; +export const NPM_DOWNLOADS_MONTHLY_URL = + "https://api.npmjs.org/downloads/point/last-month/@bitkyc08/opencodex"; +export const NPM_DOWNLOADS_YEARLY_URL = + "https://api.npmjs.org/downloads/point/last-year/@bitkyc08/opencodex"; +export const GITHUB_RELEASES_URL = + `https://api.github.com/repos/${GITHUB_REPO_SLUG}/releases?per_page=${GITHUB_RELEASES_PER_PAGE}`; + +export const TOKSCALE_REPO_URL = "https://github.com/junhoyeo/tokscale"; + +/** + * Relative docs path for the telemetry privacy guide added by stack layer 5 + * (`docs-site/src/content/docs/guides/telemetry.md`). + */ +export const TELEMETRY_GUIDE_PATH = "guides/telemetry/"; + +/** + * Disjoint token mix palette adapted from junhoyeo/tokscale `TokenBreakdown.tsx` (MIT). + */ +export const TOKEN_MIX_COLORS: Readonly> = Object.freeze({ + input: "#56b4e9", + cacheRead: "#cc79a7", + cacheWrite: "#e69f00", + output: "#009e73", + reasoning: "#8b5cf6", +}); + +export const TOKEN_BUCKET_ORDER: readonly TokenBucketId[] = Object.freeze([ + "input", + "cacheRead", + "cacheWrite", + "output", + "reasoning", +]); + +export const OUTCOME_COLORS: Readonly> = Object.freeze({ + completed: "#10b981", + incomplete: "#f59e0b", + aborted: "#6b7280", + failed: "#ef4444", +}); + +/** + * 5-grade contribution heatmap ramps adapted from junhoyeo/tokscale `src/lib/themes.ts` (MIT). + */ +export const HEATMAP_GRADE_COLORS: Readonly<{ + light: readonly [string, string, string, string, string]; + dark: readonly [string, string, string, string, string]; +}> = Object.freeze({ + light: ["#eff2f5", "#79b8ff", "#388bfd", "#1f6feb", "#0d419d"], + dark: ["#191f2b", "#1d4ed8", "#2563eb", "#4b91ff", "#8cc0ff"], +}); + +export function getHeatmapGradeColor( + theme: "light" | "dark", + grade: HeatmapGrade, +): string { + const palette = theme === "light" ? HEATMAP_GRADE_COLORS.light : HEATMAP_GRADE_COLORS.dark; + return palette[grade] ?? palette[0]; +} + +const PROVIDER_COLOR_MAP: Readonly> = Object.freeze({ + openai: "#10a37f", + chatgpt: "#14b8a6", + anthropic: "#d97757", + "claude-subscription": "#e39980", + google: "#4285f4", + vertex: "#3b82f6", + xai: "#1da1f2", + deepseek: "#06b6d4", + moonshot: "#8b5cf6", + qwen: "#6366f1", + zai: "#0ea5e9", + minimax: "#ec4899", + mistral: "#f97316", + openrouter: "#a855f7", + groq: "#ef4444", + cerebras: "#eab308", + together: "#22c55e", + fireworks: "#f59e0b", + cursor: "#f59e0b", + kiro: "#14b8a6", + copilot: "#38bdf8", + devin: "#84cc16", + ollama: "#94a3b8", + lmstudio: "#64748b", + "local-other": "#78716c", + other: "#6b7280", +}); + +const MODEL_COLOR_MAP: Readonly> = Object.freeze({ + "gpt-6": "#059669", + "gpt-5": "#10b981", + "gpt-4": "#34d399", + "o-series": "#6366f1", + codex: "#f59e0b", + "claude-opus": "#da7756", + "claude-sonnet": "#e39980", + "claude-haiku": "#ecb8a6", + "gemini-3": "#2563eb", + "gemini-2": "#60a5fa", + grok: "#1da1f2", + deepseek: "#06b6d4", + qwen: "#1a73e8", + kimi: "#a855f7", + glm: "#0ea5e9", + minimax: "#ec4899", + mistral: "#f97316", + llama: "#84cc16", + other: "#6b7280", +}); + +const FALLBACK_SERIES_PALETTE = [ + "#3b82f6", + "#10b981", + "#d97757", + "#8b5cf6", + "#f59e0b", + "#06b6d4", + "#ec4899", + "#84cc16", +] as const; + +function hashColor(key: string): string { + let hash = 0; + for (let i = 0; i < key.length; i++) { + hash = (hash * 31 + key.charCodeAt(i)) | 0; + } + return FALLBACK_SERIES_PALETTE[Math.abs(hash) % FALLBACK_SERIES_PALETTE.length]!; +} + +export function getProviderColor(providerKind: string): string { + const normalized = providerKind.trim().toLowerCase(); + return PROVIDER_COLOR_MAP[normalized] ?? hashColor(normalized); +} + +/** + * Model family color resolver adapted from junhoyeo/tokscale `modelColors.ts` (MIT). + */ +export function getModelFamilyColor(modelFamily: string): string { + const normalized = modelFamily.trim().toLowerCase(); + if (MODEL_COLOR_MAP[normalized]) { + return MODEL_COLOR_MAP[normalized]!; + } + const tokens = normalized.split(/[^a-z0-9]+/); + for (const token of tokens) { + if (token === "opus") return MODEL_COLOR_MAP["claude-opus"]!; + if (token === "sonnet") return MODEL_COLOR_MAP["claude-sonnet"]!; + if (token === "haiku") return MODEL_COLOR_MAP["claude-haiku"]!; + if (token.startsWith("claude")) return MODEL_COLOR_MAP["claude-sonnet"]!; + if (token.startsWith("gpt")) return MODEL_COLOR_MAP["gpt-5"]!; + if (token.startsWith("gemini")) return MODEL_COLOR_MAP["gemini-3"]!; + if (MODEL_COLOR_MAP[token]) return MODEL_COLOR_MAP[token]!; + } + return hashColor(normalized); +} diff --git a/docs-site/src/components/telemetry-dashboard/format.ts b/docs-site/src/components/telemetry-dashboard/format.ts new file mode 100644 index 00000000000..a93cb04df5a --- /dev/null +++ b/docs-site/src/components/telemetry-dashboard/format.ts @@ -0,0 +1,294 @@ +import { + DEFAULT_TELEMETRY_RANGE, + TELEMETRY_RANGES, + TELEMETRY_RANGE_DAYS, + TELEMETRY_STATS_ENDPOINT, + TOKEN_BUCKET_ORDER, + TOKEN_MIX_COLORS, +} from "./constants"; +import type { + KpiCardItem, + TelemetryStatsRange, + TelemetryStatsV1, + TokenMixSegment, + TokenMixSummary, +} from "./types"; + +const MAX_SAFE = Number.MAX_SAFE_INTEGER; + +export function finiteNonNegativeInt(value: unknown): number { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return 0; + if (n >= MAX_SAFE) return MAX_SAFE; + return Math.floor(n); +} + +export function safeAddInt(a: number, b: number): number { + return finiteNonNegativeInt(finiteNonNegativeInt(a) + finiteNonNegativeInt(b)); +} + +/** + * Compact K/M/B/T formatter adapted from junhoyeo/tokscale `formatTokenCount` (MIT). + * Promotes boundary values (>= 999.95 of a unit) to the next unit so `999,960` + * renders as `"1.0M"` instead of `"1000.0K"`. + */ +export function formatCompactNumber(value: number): string { + const count = finiteNonNegativeInt(value); + if (count >= 1_000_000_000_000) { + const val = (count / 1_000_000_000_000).toFixed(2).replace(/\.?0+$/, ""); + return `${val}T`; + } + if (count >= 1_000_000_000) { + const val = count / 1_000_000_000; + return val >= 999.95 ? `${(val / 1000).toFixed(1)}T` : `${val.toFixed(1)}B`; + } + if (count >= 1_000_000) { + const val = count / 1_000_000; + return val >= 999.95 ? `${(val / 1000).toFixed(1)}B` : `${val.toFixed(1)}M`; + } + if (count >= 1_000) { + const val = count / 1_000; + return val >= 999.95 ? `${(val / 1000).toFixed(1)}M` : `${val.toFixed(1)}K`; + } + return count.toLocaleString("en-US"); +} + +export function formatExactNumber(value: number, locale = "en-US"): string { + return finiteNonNegativeInt(value).toLocaleString(locale); +} + +export function computePercentage(part: number, total: number): number { + const safePart = finiteNonNegativeInt(part); + const safeTotal = finiteNonNegativeInt(total); + if (safeTotal <= 0 || safePart <= 0) return 0; + return Math.min(100, (safePart / safeTotal) * 100); +} + +export function formatPercent(part: number, total: number, digits = 1): string { + return `${computePercentage(part, total).toFixed(digits)}%`; +} + +export function formatActiveHours( + activeMinutes: number, + locale = "en-US", +): { hours: number; compact: string; exactTitle: string } { + const minutes = finiteNonNegativeInt(activeMinutes); + const hours = minutes / 60; + const exactMinutes = minutes.toLocaleString(locale); + if (minutes === 0) { + return { + hours: 0, + compact: "0h", + exactTitle: `0h (${exactMinutes} min)`, + }; + } + const roundedHours = Number(hours.toFixed(1)); + const exactHours = roundedHours.toLocaleString(locale, { + minimumFractionDigits: roundedHours % 1 === 0 ? 0 : 1, + maximumFractionDigits: 1, + }); + const compactBase = + hours >= 1_000 + ? formatCompactNumber(Math.round(hours)) + : hours >= 100 + ? String(Math.round(hours)) + : hours.toFixed(1).replace(/\.0$/, ""); + return { + hours: roundedHours, + compact: `${compactBase}h`, + exactTitle: `${exactHours}h (${exactMinutes} min)`, + }; +} + +export function sumDisjointTokens( + tokens: TelemetryStatsV1["totals"]["tokens"] | null | undefined, +): number { + if (!tokens) return 0; + return safeAddInt( + safeAddInt( + safeAddInt(tokens.input, tokens.cacheRead), + safeAddInt(tokens.cacheWrite, tokens.output), + ), + tokens.reasoning, + ); +} + +export function getStatsRangeDays( + range?: TelemetryStatsRange | string | null, +): number { + if (isTelemetryStatsRange(range)) { + return TELEMETRY_RANGE_DAYS[range]; + } + return TELEMETRY_RANGE_DAYS[DEFAULT_TELEMETRY_RANGE]; +} + +export function resolveAvgDailyActiveInstalls( + stats: Pick & + Partial>, +): number { + const explicit = stats.totals.avgDailyActiveInstalls; + if (typeof explicit === "number" && Number.isFinite(explicit) && explicit >= 0) { + return Math.round(Math.min(explicit, MAX_SAFE) * 10) / 10; + } + const installDays = finiteNonNegativeInt(stats.totals.activeInstalls); + const days = getStatsRangeDays(stats.range); + if (days <= 0 || installDays <= 0) return 0; + return Math.round((installDays / days) * 10) / 10; +} + +export function formatAvgDailyActiveInstalls( + avgDailyActiveInstalls: number, + installDays: number, + locale = "en-US", + installDaysLabel = "install-days", +): { avg: number; compact: string; exactTitle: string } { + const safeInstallDays = finiteNonNegativeInt(installDays); + const safeAvg = + typeof avgDailyActiveInstalls === "number" && + Number.isFinite(avgDailyActiveInstalls) && + avgDailyActiveInstalls > 0 + ? Math.round(Math.min(avgDailyActiveInstalls, MAX_SAFE) * 10) / 10 + : 0; + const exactInstallDays = safeInstallDays.toLocaleString(locale); + if (safeAvg === 0 && safeInstallDays === 0) { + return { + avg: 0, + compact: "0", + exactTitle: `0 (0 ${installDaysLabel})`, + }; + } + const exactAvg = safeAvg.toLocaleString(locale, { + minimumFractionDigits: safeAvg % 1 === 0 ? 0 : 1, + maximumFractionDigits: 1, + }); + const compact = + safeAvg >= 1_000 + ? formatCompactNumber(Math.round(safeAvg)) + : safeAvg.toFixed(1).replace(/\.0$/, ""); + return { + avg: safeAvg, + compact, + exactTitle: `${exactAvg} (${exactInstallDays} ${installDaysLabel})`, + }; +} + +export function buildKpiStrip( + stats: Pick & + Partial>, + locale = "en-US", + installDaysLabel = "install-days", +): KpiCardItem[] { + const installDays = finiteNonNegativeInt(stats.totals.activeInstalls); + const avgDaily = resolveAvgDailyActiveInstalls(stats); + const formattedAvg = formatAvgDailyActiveInstalls( + avgDaily, + installDays, + locale, + installDaysLabel, + ); + const newInstalls = finiteNonNegativeInt(stats.totals.newInstalls); + const requests = finiteNonNegativeInt(stats.totals.requests); + const totalTokens = sumDisjointTokens(stats.totals.tokens); + const activeHours = formatActiveHours(stats.totals.activeMinutes, locale); + + return [ + { + id: "activeInstalls", + rawValue: formattedAvg.avg, + compact: formattedAvg.compact, + exactTitle: formattedAvg.exactTitle, + }, + { + id: "newInstalls", + rawValue: newInstalls, + compact: formatCompactNumber(newInstalls), + exactTitle: formatExactNumber(newInstalls, locale), + }, + { + id: "requests", + rawValue: requests, + compact: formatCompactNumber(requests), + exactTitle: formatExactNumber(requests, locale), + }, + { + id: "tokens", + rawValue: totalTokens, + compact: formatCompactNumber(totalTokens), + exactTitle: formatExactNumber(totalTokens, locale), + }, + { + id: "activeHours", + rawValue: finiteNonNegativeInt(stats.totals.activeMinutes), + compact: activeHours.compact, + exactTitle: activeHours.exactTitle, + }, + ]; +} + +export function buildTokenMix( + tokens: TelemetryStatsV1["totals"]["tokens"] | null | undefined, + locale = "en-US", +): TokenMixSummary { + const totalTokens = sumDisjointTokens(tokens); + const segments: TokenMixSegment[] = TOKEN_BUCKET_ORDER.map((id) => { + const value = finiteNonNegativeInt(tokens?.[id]); + const percent = computePercentage(value, totalTokens); + return { + id, + value, + compact: formatCompactNumber(value), + exactTitle: formatExactNumber(value, locale), + percent, + percentLabel: `${percent.toFixed(1)}%`, + color: TOKEN_MIX_COLORS[id], + }; + }); + + return { + totalTokens, + totalCompact: formatCompactNumber(totalTokens), + totalExactTitle: formatExactNumber(totalTokens, locale), + segments, + }; +} + +export function isTelemetryStatsRange(value: unknown): value is TelemetryStatsRange { + return ( + typeof value === "string" && + (TELEMETRY_RANGES as readonly string[]).includes(value) + ); +} + +export function parseStatsRangeParam( + value: string | null | undefined, +): TelemetryStatsRange { + if (isTelemetryStatsRange(value)) return value; + return DEFAULT_TELEMETRY_RANGE; +} + +export function buildStatsUrl( + range: TelemetryStatsRange, + endpoint: string = TELEMETRY_STATS_ENDPOINT, +): string { + const url = new URL(endpoint); + url.searchParams.set("range", range); + return url.toString(); +} + +export function formatGeneratedTimestamp( + iso: string | null | undefined, + locale = "en-US", +): string | null { + if (!iso || typeof iso !== "string") return null; + const parsed = new Date(iso); + if (Number.isNaN(parsed.getTime())) return null; + return parsed.toLocaleString(locale, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + timeZone: "UTC", + timeZoneName: "short", + }); +} diff --git a/docs-site/src/components/telemetry-dashboard/heatmap.ts b/docs-site/src/components/telemetry-dashboard/heatmap.ts new file mode 100644 index 00000000000..7343c98ad9c --- /dev/null +++ b/docs-site/src/components/telemetry-dashboard/heatmap.ts @@ -0,0 +1,269 @@ +import { finiteNonNegativeInt, formatExactNumber, safeAddInt } from "./format"; +import type { + HeatmapCalendar, + HeatmapCell, + HeatmapGrade, + HeatmapMonthMarker, + TelemetryStatsV1, +} from "./types"; + +const DAY_MS = 86_400_000; +const DAY_KEY_RE = /^(\d{4})-(\d{2})-(\d{2})$/; +const SHORT_MONTHS = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", +] as const; + +export function formatLocalizedMonthShort( + monthIndex: number, + locale = "en-US", +): string { + const safeMonth = Math.max(0, Math.min(11, Math.floor(monthIndex))); + try { + const d = new Date(Date.UTC(2026, safeMonth, 15)); + return new Intl.DateTimeFormat(locale, { + month: "short", + timeZone: "UTC", + }).format(d); + } catch { + return SHORT_MONTHS[safeMonth] ?? "Jan"; + } +} + +export function getLocalizedWeekdayLabels(locale = "en-US"): { + mon: string; + wed: string; + fri: string; +} { + try { + const fmt = new Intl.DateTimeFormat(locale, { + weekday: "short", + timeZone: "UTC", + }); + // 2026-01-05 is Monday, 2026-01-07 is Wednesday, 2026-01-09 is Friday (UTC) + return { + mon: fmt.format(new Date(Date.UTC(2026, 0, 5))), + wed: fmt.format(new Date(Date.UTC(2026, 0, 7))), + fri: fmt.format(new Date(Date.UTC(2026, 0, 9))), + }; + } catch { + return { mon: "Mon", wed: "Wed", fri: "Fri" }; + } +} + +export function parseUtcDayMs(day: string): number | null { + const match = DAY_KEY_RE.exec(day); + if (!match) return null; + const year = Number(match[1]); + const month = Number(match[2]) - 1; + const date = Number(match[3]); + const ms = Date.UTC(year, month, date); + const check = new Date(ms); + if ( + check.getUTCFullYear() !== year || + check.getUTCMonth() !== month || + check.getUTCDate() !== date + ) { + return null; + } + return ms; +} + +export function formatUtcDayKey(ms: number): string { + return new Date(ms).toISOString().slice(0, 10); +} + +/** + * 5-grade intensity bucket adapted from junhoyeo/tokscale `getContributionIntensity` (MIT). + */ +export function computeHeatmapGrade( + activeInstalls: number, + maxActiveInstalls: number, +): HeatmapGrade { + const value = finiteNonNegativeInt(activeInstalls); + const max = finiteNonNegativeInt(maxActiveInstalls); + if (value <= 0 || max <= 0) return 0; + const ratio = value / max; + if (ratio >= 0.75) return 4; + if (ratio >= 0.5) return 3; + if (ratio >= 0.25) return 2; + return 1; +} + +export function buildHeatmapCalendar( + heatmap: TelemetryStatsV1["heatmap"] | null | undefined, + locale = "en-US", +): HeatmapCalendar { + const byDayMs = new Map(); + for (const row of heatmap ?? []) { + if (!row || typeof row.day !== "string") continue; + const ms = parseUtcDayMs(row.day); + if (ms === null) continue; + const count = finiteNonNegativeInt(row.activeInstalls); + const prev = byDayMs.get(ms); + byDayMs.set(ms, { + date: row.day, + activeInstalls: prev ? safeAddInt(prev.activeInstalls, count) : count, + }); + } + + const sortedMs = [...byDayMs.keys()].sort((a, b) => a - b); + if (sortedMs.length === 0) { + return { + cells: [], + weekCount: 0, + activeDays: 0, + totalActiveInstalls: 0, + maxActiveInstalls: 0, + peakDay: null, + startDate: null, + endDate: null, + monthMarkers: [], + }; + } + + const firstMs = sortedMs[0]!; + const lastMs = sortedMs[sortedMs.length - 1]!; + + let maxActiveInstalls = 0; + let activeDays = 0; + let totalActiveInstalls = 0; + let peakDay: { date: string; activeInstalls: number } | null = null; + + for (const ms of sortedMs) { + const entry = byDayMs.get(ms)!; + totalActiveInstalls = safeAddInt(totalActiveInstalls, entry.activeInstalls); + if (entry.activeInstalls > 0) { + activeDays += 1; + if (entry.activeInstalls > maxActiveInstalls) { + maxActiveInstalls = entry.activeInstalls; + } + if ( + !peakDay || + entry.activeInstalls > peakDay.activeInstalls || + (entry.activeInstalls === peakDay.activeInstalls && entry.date < peakDay.date) + ) { + peakDay = { date: entry.date, activeInstalls: entry.activeInstalls }; + } + } + } + + const calendarStartMs = firstMs - new Date(firstMs).getUTCDay() * DAY_MS; + const calendarEndMs = lastMs + (6 - new Date(lastMs).getUTCDay()) * DAY_MS; + const totalGridDays = Math.round((calendarEndMs - calendarStartMs) / DAY_MS) + 1; + const weekCount = Math.round(totalGridDays / 7); + + const cells: HeatmapCell[] = []; + for (let offset = 0; offset < totalGridDays; offset++) { + const ms = calendarStartMs + offset * DAY_MS; + const date = formatUtcDayKey(ms); + const inRange = ms >= firstMs && ms <= lastMs; + const selectable = inRange; + const activeInstalls = selectable ? (byDayMs.get(ms)?.activeInstalls ?? 0) : 0; + const grade = selectable + ? computeHeatmapGrade(activeInstalls, maxActiveInstalls) + : 0; + cells.push({ + date, + activeInstalls, + grade, + inRange, + selectable, + weekIndex: Math.floor(offset / 7), + dayOfWeek: offset % 7, + }); + } + + const monthMarkers: HeatmapMonthMarker[] = []; + const seenWeeks = new Set(); + let cursorMs = firstMs; + while (cursorMs <= lastMs) { + const d = new Date(cursorMs); + const monthIndex = d.getUTCMonth(); + const weekIndex = Math.floor((cursorMs - calendarStartMs) / (DAY_MS * 7)); + if (!seenWeeks.has(weekIndex)) { + const marker: HeatmapMonthMarker = { + label: formatLocalizedMonthShort(monthIndex, locale), + monthIndex, + weekIndex, + }; + const prev = monthMarkers[monthMarkers.length - 1]; + if (prev && weekIndex - prev.weekIndex < 3) { + if (prev.weekIndex === 0) { + monthMarkers[monthMarkers.length - 1] = marker; + } + } else { + monthMarkers.push(marker); + } + seenWeeks.add(weekIndex); + } + cursorMs = Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1); + } + + return { + cells, + weekCount, + activeDays, + totalActiveInstalls, + maxActiveInstalls, + peakDay, + startDate: formatUtcDayKey(firstMs), + endDate: formatUtcDayKey(lastMs), + monthMarkers, + }; +} + +export type HeatmapNavigationKey = + | "ArrowUp" + | "ArrowDown" + | "ArrowLeft" + | "ArrowRight" + | "Home" + | "End"; + +export function navigateHeatmapDate( + cells: readonly HeatmapCell[], + currentDate: string, + key: HeatmapNavigationKey, +): string | null { + const selectable = cells.filter((cell) => cell.selectable); + if (selectable.length === 0) return null; + if (key === "Home") return selectable[0]!.date; + if (key === "End") return selectable[selectable.length - 1]!.date; + + const idx = selectable.findIndex((cell) => cell.date === currentDate); + if (idx === -1) return selectable[selectable.length - 1]!.date; + + const delta = + key === "ArrowUp" + ? -1 + : key === "ArrowDown" + ? 1 + : key === "ArrowLeft" + ? -7 + : 7; + const nextIdx = Math.max(0, Math.min(selectable.length - 1, idx + delta)); + return selectable[nextIdx]!.date; +} + +export function formatHeatmapCellAriaLabel( + cell: Pick, + locale = "en-US", + unitLabel = "active installs", +): string { + const count = formatExactNumber(cell.activeInstalls, locale); + if (locale.toLowerCase().startsWith("en") && unitLabel === "active installs") { + return `${cell.date}: ${count} active installs (grade ${cell.grade})`; + } + return `${cell.date}: ${count} ${unitLabel} (${cell.grade}/4)`; +} diff --git a/docs-site/src/components/telemetry-dashboard/index.ts b/docs-site/src/components/telemetry-dashboard/index.ts new file mode 100644 index 00000000000..1b2e34dd38b --- /dev/null +++ b/docs-site/src/components/telemetry-dashboard/index.ts @@ -0,0 +1,6 @@ +export * from "./types"; +export * from "./constants"; +export * from "./format"; +export * from "./heatmap"; +export * from "./tables"; +export * from "./state"; diff --git a/docs-site/src/components/telemetry-dashboard/state.ts b/docs-site/src/components/telemetry-dashboard/state.ts new file mode 100644 index 00000000000..7c4731780cc --- /dev/null +++ b/docs-site/src/components/telemetry-dashboard/state.ts @@ -0,0 +1,476 @@ +import { + GITHUB_RELEASES_MAX_PAGES, + GITHUB_RELEASES_PER_PAGE, +} from "./constants"; +import { + finiteNonNegativeInt, + isTelemetryStatsRange, + safeAddInt, + sumDisjointTokens, +} from "./format"; +import type { + DashboardFetchState, + DownloadStats, + TelemetryStatsRange, + TelemetryStatsV1, +} from "./types"; + +export const DEFAULT_STATS_REQUEST_TIMEOUT_MS = 15_000; + +function isPlainObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function isValidTelemetryStatsV1(value: unknown): value is TelemetryStatsV1 { + if (!isPlainObject(value)) return false; + if (value.schemaVersion !== 1) return false; + if (typeof value.generatedAt !== "string") return false; + if (!isTelemetryStatsRange(value.range)) return false; + if (!isPlainObject(value.totals)) return false; + if (!isPlainObject(value.totals.tokens)) return false; + if ( + value.totals.avgDailyActiveInstalls !== undefined && + (typeof value.totals.avgDailyActiveInstalls !== "number" || + !Number.isFinite(value.totals.avgDailyActiveInstalls) || + value.totals.avgDailyActiveInstalls < 0) + ) { + return false; + } + if (!isPlainObject(value.outcomes)) return false; + if ( + !Array.isArray(value.daily) || + !Array.isArray(value.dailyByProvider) || + !Array.isArray(value.heatmap) || + !Array.isArray(value.providers) || + !Array.isArray(value.models) || + !Array.isArray(value.versions) || + !Array.isArray(value.platforms) || + !Array.isArray(value.channels) || + !Array.isArray(value.errors) + ) { + return false; + } + return true; +} + +export function isStatsSnapshotEmpty(stats: TelemetryStatsV1): boolean { + const totalTokens = sumDisjointTokens(stats.totals.tokens); + const heatmap = Array.isArray(stats.heatmap) ? stats.heatmap : []; + const hasHeatmapActivity = heatmap.some( + (d) => isPlainObject(d) && finiteNonNegativeInt(d.activeInstalls) > 0, + ); + return ( + finiteNonNegativeInt(stats.totals.activeInstalls) === 0 && + finiteNonNegativeInt(stats.totals.newInstalls) === 0 && + finiteNonNegativeInt(stats.totals.requests) === 0 && + totalTokens === 0 && + !hasHeatmapActivity + ); +} + +export function createEmptyStatsSnapshot( + range: TelemetryStatsRange = "30d", +): TelemetryStatsV1 { + return { + schemaVersion: 1, + generatedAt: "", + range, + totals: { + activeInstalls: 0, + avgDailyActiveInstalls: 0, + newInstalls: 0, + activeMinutes: 0, + requests: 0, + tokens: { + input: 0, + cacheRead: 0, + cacheWrite: 0, + output: 0, + reasoning: 0, + }, + }, + daily: [], + dailyByProvider: [], + heatmap: [], + providers: [], + models: [], + versions: [], + platforms: [], + channels: [], + outcomes: { + completed: 0, + failed: 0, + incomplete: 0, + aborted: 0, + }, + errors: [], + }; +} + +export interface StatsRequestTicket { + readonly requestId: number; + readonly range: TelemetryStatsRange; + readonly signal: AbortSignal; + isCurrent(): boolean; + didTimeout(): boolean; + finish(): void; +} + +export interface StatsRequestSequencer { + begin(range: TelemetryStatsRange, timeoutMs?: number): StatsRequestTicket; + abortActive(): void; + getCurrentRequestId(): number; +} + +export function createStatsRequestSequencer( + defaultTimeoutMs = DEFAULT_STATS_REQUEST_TIMEOUT_MS, +): StatsRequestSequencer { + let currentRequestId = 0; + let activeController: AbortController | null = null; + let activeTimer: ReturnType | null = null; + + const clearActiveTimer = () => { + if (activeTimer !== null) { + clearTimeout(activeTimer); + activeTimer = null; + } + }; + + const abortActive = () => { + clearActiveTimer(); + if (activeController) { + activeController.abort(); + activeController = null; + } + }; + + return { + begin(range: TelemetryStatsRange, timeoutMs = defaultTimeoutMs): StatsRequestTicket { + abortActive(); + currentRequestId += 1; + const myRequestId = currentRequestId; + const controller = new AbortController(); + activeController = controller; + let timedOut = false; + + if (timeoutMs > 0 && Number.isFinite(timeoutMs)) { + activeTimer = setTimeout(() => { + timedOut = true; + controller.abort(); + }, timeoutMs); + } + + return { + requestId: myRequestId, + range, + signal: controller.signal, + isCurrent: () => myRequestId === currentRequestId, + didTimeout: () => timedOut, + finish: () => { + if (myRequestId === currentRequestId) { + clearActiveTimer(); + if (activeController === controller) { + activeController = null; + } + } + }, + }; + }, + abortActive, + getCurrentRequestId: () => currentRequestId, + }; +} + +export async function fetchJsonWithTimeout( + url: string | undefined, + timeoutMs = DEFAULT_STATS_REQUEST_TIMEOUT_MS, + fetchImpl: typeof fetch = fetch, +): Promise { + if (!url) return null; + const controller = new AbortController(); + const timer = + timeoutMs > 0 && Number.isFinite(timeoutMs) + ? setTimeout(() => controller.abort(), timeoutMs) + : null; + try { + const res = await fetchImpl(url, { + headers: { accept: "application/json" }, + signal: controller.signal, + }); + if (!res.ok) return null; + return await res.json(); + } catch { + return null; + } finally { + if (timer !== null) clearTimeout(timer); + } +} + +export function parseGithubNextLink( + linkHeader: string | null | undefined, + currentUrl?: string, +): string | null { + if (!linkHeader || typeof linkHeader !== "string") return null; + for (const part of linkHeader.split(",")) { + const urlMatch = /<([^>]+)>/.exec(part); + if (!urlMatch?.[1]) continue; + const params = part.slice(urlMatch.index + urlMatch[0].length); + if (!/;\s*rel\s*=\s*(?:"next"|'next'|next\b)/i.test(params)) continue; + try { + const parsed = currentUrl + ? new URL(urlMatch[1].trim(), currentUrl) + : new URL(urlMatch[1].trim()); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + return null; + } + if (currentUrl) { + const base = new URL(currentUrl); + if (parsed.origin !== base.origin) return null; + } + return parsed.toString(); + } catch { + return null; + } + } + return null; +} + +export interface GithubReleasesFetchResult { + releases: unknown[] | null; + partial: boolean; + pagesFetched: number; +} + +export async function fetchGithubReleasesPages( + initialUrl: string | undefined, + timeoutMs = DEFAULT_STATS_REQUEST_TIMEOUT_MS, + fetchImpl: typeof fetch = fetch, + maxPages = GITHUB_RELEASES_MAX_PAGES, +): Promise { + if (!initialUrl) { + return { releases: null, partial: false, pagesFetched: 0 }; + } + const pageCap = Math.max(1, Math.floor(maxPages)); + let nextUrl: string | null = null; + try { + const normalized = new URL(initialUrl); + if (!normalized.searchParams.has("per_page")) { + normalized.searchParams.set("per_page", String(GITHUB_RELEASES_PER_PAGE)); + } + nextUrl = normalized.toString(); + } catch { + nextUrl = initialUrl; + } + + const allReleases: unknown[] = []; + let pagesFetched = 0; + + while (nextUrl && pagesFetched < pageCap) { + const controller = new AbortController(); + const timer = + timeoutMs > 0 && Number.isFinite(timeoutMs) + ? setTimeout(() => controller.abort(), timeoutMs) + : null; + let res: Response; + try { + res = await fetchImpl(nextUrl, { + headers: { accept: "application/json" }, + signal: controller.signal, + }); + } catch { + if (timer !== null) clearTimeout(timer); + return pagesFetched === 0 + ? { releases: null, partial: false, pagesFetched: 0 } + : { releases: allReleases, partial: true, pagesFetched }; + } + let pageBody: unknown; + try { + if (!res.ok) { + return pagesFetched === 0 + ? { releases: null, partial: false, pagesFetched: 0 } + : { releases: allReleases, partial: true, pagesFetched }; + } + pageBody = await res.json(); + } catch { + return pagesFetched === 0 + ? { releases: null, partial: false, pagesFetched: 0 } + : { releases: allReleases, partial: true, pagesFetched }; + } finally { + if (timer !== null) clearTimeout(timer); + } + + if (!Array.isArray(pageBody)) { + return pagesFetched === 0 + ? { releases: null, partial: false, pagesFetched: 0 } + : { releases: allReleases, partial: true, pagesFetched }; + } + + pagesFetched += 1; + allReleases.push(...pageBody); + + const linkHeader = res.headers?.get?.("link") ?? null; + const linkedNext = parseGithubNextLink(linkHeader, nextUrl); + if (!linkedNext) { + return { releases: allReleases, partial: false, pagesFetched }; + } + if (pagesFetched >= pageCap) { + return { releases: allReleases, partial: true, pagesFetched }; + } + nextUrl = linkedNext; + } + + return { + releases: pagesFetched > 0 ? allReleases : null, + partial: false, + pagesFetched, + }; +} + +export interface ClassifyStatsInput { + ok?: boolean; + status?: number; + body?: unknown; + networkError?: boolean; + errorMessage?: string; +} + +export interface ClassifiedStatsResult { + state: Exclude; + stats: TelemetryStatsV1 | null; + reason: + | "ok" + | "collector-not-deployed" + | "no-reports-yet" + | "http-error" + | "invalid-payload" + | "network-error"; + detail?: string; +} + +export function classifyStatsResponse( + input: ClassifyStatsInput, +): ClassifiedStatsResult { + if (input.networkError) { + return { + state: "error", + stats: null, + reason: "network-error", + detail: input.errorMessage ?? "Network request failed", + }; + } + + const status = input.status ?? (input.ok ? 200 : 500); + if (status === 404 || status === 502 || status === 503) { + return { + state: "empty", + stats: null, + reason: "collector-not-deployed", + detail: `HTTP ${status}`, + }; + } + + if (status < 200 || status >= 300 || input.ok === false) { + return { + state: "error", + stats: null, + reason: "http-error", + detail: input.errorMessage ?? `HTTP ${status}`, + }; + } + + if (!isValidTelemetryStatsV1(input.body)) { + return { + state: "error", + stats: null, + reason: "invalid-payload", + detail: "Malformed TelemetryStatsV1 payload", + }; + } + + if (isStatsSnapshotEmpty(input.body)) { + return { + state: "empty", + stats: input.body, + reason: "no-reports-yet", + }; + } + + return { + state: "ready", + stats: input.body, + reason: "ok", + }; +} + +function parseNpmDownloadsCount(payload: unknown): number | null { + if (!isPlainObject(payload)) return null; + const raw = payload.downloads; + if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 0) return null; + return Math.floor(raw); +} + +export function parseDownloadsPayload(input: { + npmWeekly?: unknown; + npmMonthly?: unknown; + npmYearly?: unknown; + githubReleases?: unknown; + githubDownloadsPartial?: boolean; +}): DownloadStats { + const npmWeekly = parseNpmDownloadsCount(input.npmWeekly); + const npmMonthly = parseNpmDownloadsCount(input.npmMonthly); + const npmYearly = parseNpmDownloadsCount(input.npmYearly); + + let githubTotalDownloads: number | null = null; + let githubDownloadsPartial = Boolean(input.githubDownloadsPartial); + let githubLatestTag: string | null = null; + let githubLatestDownloads: number | null = null; + let githubReleaseCount = 0; + + let releasesArray: unknown[] | null = null; + if (Array.isArray(input.githubReleases)) { + releasesArray = input.githubReleases; + } else if ( + isPlainObject(input.githubReleases) && + Array.isArray(input.githubReleases.releases) + ) { + releasesArray = input.githubReleases.releases; + if (typeof input.githubReleases.partial === "boolean") { + githubDownloadsPartial = input.githubReleases.partial; + } + } + + if (releasesArray) { + let total = 0; + for (const rel of releasesArray) { + if (!isPlainObject(rel) || rel.draft === true) continue; + githubReleaseCount += 1; + const assets = Array.isArray(rel.assets) ? rel.assets : []; + let releaseTotal = 0; + for (const asset of assets) { + if (!isPlainObject(asset)) continue; + const count = finiteNonNegativeInt(asset.download_count); + releaseTotal = safeAddInt(releaseTotal, count); + } + total = safeAddInt(total, releaseTotal); + // Previews count toward the totals, but "latest release" is the newest stable one. + if (githubLatestTag === null && rel.prerelease !== true && typeof rel.tag_name === "string" && rel.tag_name.trim()) { + githubLatestTag = rel.tag_name.trim(); + githubLatestDownloads = releaseTotal; + } + } + githubTotalDownloads = total; + } else { + githubDownloadsPartial = false; + } + + return { + npmWeekly, + npmMonthly, + npmYearly, + githubTotalDownloads, + githubDownloadsPartial, + githubLatestTag, + githubLatestDownloads, + githubReleaseCount, + }; +} diff --git a/docs-site/src/components/telemetry-dashboard/tables.ts b/docs-site/src/components/telemetry-dashboard/tables.ts new file mode 100644 index 00000000000..192932a9b6e --- /dev/null +++ b/docs-site/src/components/telemetry-dashboard/tables.ts @@ -0,0 +1,478 @@ +import { + OUTCOME_COLORS, + getModelFamilyColor, + getProviderColor, +} from "./constants"; +import { + computePercentage, + finiteNonNegativeInt, + formatCompactNumber, + formatExactNumber, + formatPercent, + safeAddInt, +} from "./format"; +import type { + DailyChartMetric, + DailyProviderSeries, + ErrorCauseRow, + InstallRow, + OutcomeClassId, + OutcomeRow, + ReliabilitySummary, + ShareRow, + StackedDailyChartModel, + TelemetryStatsV1, +} from "./types"; + +const DEFAULT_MAX_CHART_SERIES = 6; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function nonEmptyKey(value: unknown): string | null { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +function isOtherKey(key: string): boolean { + return key.trim().toLowerCase() === "other"; +} + +function normalizeKeyOrOther(key: string): string { + return isOtherKey(key) ? "other" : key; +} + +export function buildDailyProviderChart( + dailyByProvider: TelemetryStatsV1["dailyByProvider"] | null | undefined, + daily: TelemetryStatsV1["daily"] | null | undefined, + metric: DailyChartMetric = "tokens", + maxSeries = DEFAULT_MAX_CHART_SERIES, +): StackedDailyChartModel { + const daySet = new Set(); + for (const d of daily ?? []) { + if (d && typeof d.day === "string") daySet.add(d.day); + } + for (const row of dailyByProvider ?? []) { + if (row && typeof row.day === "string") daySet.add(row.day); + } + const days = [...daySet].sort(); + const dayIndexByKey = new Map(days.map((d, i) => [d, i])); + + const byProvider = new Map(); + for (const row of dailyByProvider ?? []) { + if (!row || typeof row.day !== "string" || typeof row.providerKind !== "string") { + continue; + } + const rawProvider = nonEmptyKey(row.providerKind); + if (!rawProvider) continue; + const providerKind = normalizeKeyOrOther(rawProvider); + const dayIdx = dayIndexByKey.get(row.day); + if (dayIdx === undefined) continue; + const rawVal = metric === "requests" ? row.requests : row.tokens; + const val = finiteNonNegativeInt(rawVal); + if (val <= 0) continue; + let entry = byProvider.get(providerKind); + if (!entry) { + entry = { total: 0, values: new Array(days.length).fill(0) }; + byProvider.set(providerKind, entry); + } + entry.values[dayIdx] = safeAddInt(entry.values[dayIdx] ?? 0, val); + entry.total = safeAddInt(entry.total, val); + } + + const explicitOther = byProvider.get("other"); + const ranked = [...byProvider.entries()] + .filter(([key]) => key !== "other") + .sort((a, b) => { + if (b[1].total !== a[1].total) return b[1].total - a[1].total; + return a[0].localeCompare(b[0]); + }); + + const limit = Math.max(1, Math.floor(maxSeries)); + const top = ranked.slice(0, limit); + const overflow = ranked.slice(limit); + + const series: DailyProviderSeries[] = top.map(([providerKind, data]) => ({ + providerKind, + color: getProviderColor(providerKind), + total: data.total, + values: data.values, + })); + + if (explicitOther || overflow.length > 0) { + const otherValues = new Array(days.length).fill(0); + let otherTotal = 0; + if (explicitOther) { + otherTotal = safeAddInt(otherTotal, explicitOther.total); + for (let i = 0; i < days.length; i++) { + otherValues[i] = safeAddInt(otherValues[i] ?? 0, explicitOther.values[i] ?? 0); + } + } + for (const [, data] of overflow) { + otherTotal = safeAddInt(otherTotal, data.total); + for (let i = 0; i < days.length; i++) { + otherValues[i] = safeAddInt(otherValues[i] ?? 0, data.values[i] ?? 0); + } + } + if (otherTotal > 0) { + series.push({ + providerKind: "other", + color: getProviderColor("other"), + total: otherTotal, + values: otherValues, + }); + } + } + + const total = series.reduce((acc, item) => safeAddInt(acc, item.total), 0); + return { days, metric, total, series }; +} + +export interface DailyChartAccessibleRow { + day: string; + providerKind: string; + value: number; + valueCompact: string; + valueExact: string; +} + +export function buildDailyChartAccessibleTable( + model: StackedDailyChartModel, + locale = "en-US", +): DailyChartAccessibleRow[] { + const rows: DailyChartAccessibleRow[] = []; + for (let dayIdx = 0; dayIdx < model.days.length; dayIdx++) { + const day = model.days[dayIdx]!; + for (const s of model.series) { + const val = finiteNonNegativeInt(s.values[dayIdx]); + if (val <= 0) continue; + rows.push({ + day, + providerKind: s.providerKind, + value: val, + valueCompact: formatCompactNumber(val), + valueExact: formatExactNumber(val, locale), + }); + } + } + return rows; +} + +export function buildProviderShareRows( + providers: TelemetryStatsV1["providers"] | null | undefined, + locale = "en-US", +): ShareRow[] { + const merged = new Map(); + for (const p of providers ?? []) { + if (!isRecord(p)) continue; + const rawKey = nonEmptyKey(p.providerKind); + if (!rawKey) continue; + const key = normalizeKeyOrOther(rawKey); + const requests = finiteNonNegativeInt(p.requests); + const tokens = finiteNonNegativeInt(p.tokens); + const prev = merged.get(key); + if (prev) { + prev.requests = safeAddInt(prev.requests, requests); + prev.tokens = safeAddInt(prev.tokens, tokens); + } else { + merged.set(key, { key, requests, tokens }); + } + } + const rows = [...merged.values()]; + rows.sort((a, b) => { + if (a.key === "other" && b.key !== "other") return 1; + if (b.key === "other" && a.key !== "other") return -1; + if (b.tokens !== a.tokens) return b.tokens - a.tokens; + if (b.requests !== a.requests) return b.requests - a.requests; + return a.key.localeCompare(b.key); + }); + const totalTokens = rows.reduce((acc, r) => safeAddInt(acc, r.tokens), 0); + const totalRequests = rows.reduce((acc, r) => safeAddInt(acc, r.requests), 0); + + return rows.map((r) => { + const tokenSharePercent = computePercentage(r.tokens, totalTokens); + const requestSharePercent = computePercentage(r.requests, totalRequests); + return { + key: r.key, + label: r.key, + color: getProviderColor(r.key), + requests: r.requests, + requestsCompact: formatCompactNumber(r.requests), + requestsExact: formatExactNumber(r.requests, locale), + requestSharePercent, + requestShareLabel: formatPercent(r.requests, totalRequests), + tokens: r.tokens, + tokensCompact: formatCompactNumber(r.tokens), + tokensExact: formatExactNumber(r.tokens, locale), + tokenSharePercent, + tokenShareLabel: formatPercent(r.tokens, totalTokens), + }; + }); +} + +export function buildModelShareRows( + models: TelemetryStatsV1["models"] | null | undefined, + locale = "en-US", +): ShareRow[] { + const merged = new Map(); + for (const m of models ?? []) { + if (!isRecord(m)) continue; + const rawKey = nonEmptyKey(m.modelFamily); + if (!rawKey) continue; + const key = normalizeKeyOrOther(rawKey); + const requests = finiteNonNegativeInt(m.requests); + const tokens = finiteNonNegativeInt(m.tokens); + const prev = merged.get(key); + if (prev) { + prev.requests = safeAddInt(prev.requests, requests); + prev.tokens = safeAddInt(prev.tokens, tokens); + } else { + merged.set(key, { key, requests, tokens }); + } + } + const rows = [...merged.values()]; + rows.sort((a, b) => { + if (a.key === "other" && b.key !== "other") return 1; + if (b.key === "other" && a.key !== "other") return -1; + if (b.tokens !== a.tokens) return b.tokens - a.tokens; + if (b.requests !== a.requests) return b.requests - a.requests; + return a.key.localeCompare(b.key); + }); + const totalTokens = rows.reduce((acc, r) => safeAddInt(acc, r.tokens), 0); + const totalRequests = rows.reduce((acc, r) => safeAddInt(acc, r.requests), 0); + + return rows.map((r) => { + const tokenSharePercent = computePercentage(r.tokens, totalTokens); + const requestSharePercent = computePercentage(r.requests, totalRequests); + return { + key: r.key, + label: r.key, + color: getModelFamilyColor(r.key), + requests: r.requests, + requestsCompact: formatCompactNumber(r.requests), + requestsExact: formatExactNumber(r.requests, locale), + requestSharePercent, + requestShareLabel: formatPercent(r.requests, totalRequests), + tokens: r.tokens, + tokensCompact: formatCompactNumber(r.tokens), + tokensExact: formatExactNumber(r.tokens, locale), + tokenSharePercent, + tokenShareLabel: formatPercent(r.tokens, totalTokens), + }; + }); +} + +export function buildVersionRows( + versions: TelemetryStatsV1["versions"] | null | undefined, + locale = "en-US", +): InstallRow[] { + const merged = new Map< + string, + { version: string; installs: number; newInstalls: number } + >(); + for (const v of versions ?? []) { + if (!isRecord(v)) continue; + const rawVersion = nonEmptyKey(v.version); + if (!rawVersion) continue; + const version = normalizeKeyOrOther(rawVersion); + const installs = finiteNonNegativeInt(v.installs); + const newInstalls = finiteNonNegativeInt(v.newInstalls); + const prev = merged.get(version); + if (prev) { + prev.installs = safeAddInt(prev.installs, installs); + prev.newInstalls = safeAddInt(prev.newInstalls, newInstalls); + } else { + merged.set(version, { version, installs, newInstalls }); + } + } + const rows = [...merged.values()]; + rows.sort((a, b) => { + if (a.version === "other" && b.version !== "other") return 1; + if (b.version === "other" && a.version !== "other") return -1; + if (b.installs !== a.installs) return b.installs - a.installs; + return b.version.localeCompare(a.version); + }); + const totalInstalls = rows.reduce((acc, r) => safeAddInt(acc, r.installs), 0); + return rows.map((r) => ({ + key: r.version, + label: r.version === "other" ? "other" : `v${r.version.replace(/^v/i, "")}`, + installs: r.installs, + installsCompact: formatCompactNumber(r.installs), + installsExact: formatExactNumber(r.installs, locale), + newInstalls: r.newInstalls, + newInstallsCompact: formatCompactNumber(r.newInstalls), + newInstallsExact: formatExactNumber(r.newInstalls, locale), + sharePercent: computePercentage(r.installs, totalInstalls), + shareLabel: formatPercent(r.installs, totalInstalls), + })); +} + +export function buildPlatformRows( + platforms: TelemetryStatsV1["platforms"] | null | undefined, + locale = "en-US", +): InstallRow[] { + const merged = new Map(); + for (const p of platforms ?? []) { + if (!isRecord(p)) continue; + const os = nonEmptyKey(p.os); + const arch = nonEmptyKey(p.arch); + if (!os) continue; + let key: string; + if (isOtherKey(os) && (!arch || isOtherKey(arch))) { + key = "other"; + } else if (!arch) { + continue; + } else { + key = `${os}/${arch}`; + } + const installs = finiteNonNegativeInt(p.installs); + const prev = merged.get(key); + if (prev) { + prev.installs = safeAddInt(prev.installs, installs); + } else { + merged.set(key, { key, installs }); + } + } + const rows = [...merged.values()]; + rows.sort((a, b) => { + if (a.key === "other" && b.key !== "other") return 1; + if (b.key === "other" && a.key !== "other") return -1; + if (b.installs !== a.installs) return b.installs - a.installs; + return a.key.localeCompare(b.key); + }); + const totalInstalls = rows.reduce((acc, r) => safeAddInt(acc, r.installs), 0); + return rows.map((r) => ({ + key: r.key, + label: r.key, + installs: r.installs, + installsCompact: formatCompactNumber(r.installs), + installsExact: formatExactNumber(r.installs, locale), + sharePercent: computePercentage(r.installs, totalInstalls), + shareLabel: formatPercent(r.installs, totalInstalls), + })); +} + +export function buildChannelRows( + channels: TelemetryStatsV1["channels"] | null | undefined, + locale = "en-US", +): InstallRow[] { + const merged = new Map< + string, + { channel: string; installs: number; newInstalls: number } + >(); + for (const c of channels ?? []) { + if (!isRecord(c)) continue; + const rawChannel = nonEmptyKey(c.installChannel); + if (!rawChannel) continue; + const channel = normalizeKeyOrOther(rawChannel); + const installs = finiteNonNegativeInt(c.installs); + const newInstalls = finiteNonNegativeInt(c.newInstalls); + const prev = merged.get(channel); + if (prev) { + prev.installs = safeAddInt(prev.installs, installs); + prev.newInstalls = safeAddInt(prev.newInstalls, newInstalls); + } else { + merged.set(channel, { channel, installs, newInstalls }); + } + } + const rows = [...merged.values()]; + rows.sort((a, b) => { + if (a.channel === "other" && b.channel !== "other") return 1; + if (b.channel === "other" && a.channel !== "other") return -1; + if (b.installs !== a.installs) return b.installs - a.installs; + return a.channel.localeCompare(b.channel); + }); + const totalInstalls = rows.reduce((acc, r) => safeAddInt(acc, r.installs), 0); + return rows.map((r) => ({ + key: r.channel, + label: r.channel, + installs: r.installs, + installsCompact: formatCompactNumber(r.installs), + installsExact: formatExactNumber(r.installs, locale), + newInstalls: r.newInstalls, + newInstallsCompact: formatCompactNumber(r.newInstalls), + newInstallsExact: formatExactNumber(r.newInstalls, locale), + sharePercent: computePercentage(r.installs, totalInstalls), + shareLabel: formatPercent(r.installs, totalInstalls), + })); +} + +const OUTCOME_KEYS: readonly OutcomeClassId[] = [ + "completed", + "incomplete", + "aborted", + "failed", +]; + +export function buildReliabilitySummary( + outcomes: TelemetryStatsV1["outcomes"] | null | undefined, + errors: TelemetryStatsV1["errors"] | null | undefined, + locale = "en-US", +): ReliabilitySummary { + const counts: Record = { + completed: finiteNonNegativeInt(outcomes?.completed), + incomplete: finiteNonNegativeInt(outcomes?.incomplete), + aborted: finiteNonNegativeInt(outcomes?.aborted), + failed: finiteNonNegativeInt(outcomes?.failed), + }; + const totalRequests = safeAddInt( + safeAddInt(counts.completed, counts.incomplete), + safeAddInt(counts.aborted, counts.failed), + ); + const completedRatioPercent = computePercentage(counts.completed, totalRequests); + const completedRatioLabel = formatPercent(counts.completed, totalRequests); + + const outcomeRows: OutcomeRow[] = OUTCOME_KEYS.map((key) => ({ + key, + count: counts[key], + compact: formatCompactNumber(counts[key]), + exact: formatExactNumber(counts[key], locale), + sharePercent: computePercentage(counts[key], totalRequests), + shareLabel: formatPercent(counts[key], totalRequests), + color: OUTCOME_COLORS[key], + })); + + const mergedErrors = new Map(); + for (const e of errors ?? []) { + if (!isRecord(e)) continue; + const rawCause = nonEmptyKey(e.cause); + if (!rawCause) continue; + const cause = normalizeKeyOrOther(rawCause); + const count = finiteNonNegativeInt(e.count); + if (count <= 0) continue; + const prev = mergedErrors.get(cause); + if (prev) { + prev.count = safeAddInt(prev.count, count); + } else { + mergedErrors.set(cause, { cause, count }); + } + } + const rawErrors = [...mergedErrors.values()]; + rawErrors.sort((a, b) => { + if (a.cause === "other" && b.cause !== "other") return 1; + if (b.cause === "other" && a.cause !== "other") return -1; + if (b.count !== a.count) return b.count - a.count; + return a.cause.localeCompare(b.cause); + }); + + const totalErrors = rawErrors.reduce((acc, e) => safeAddInt(acc, e.count), 0); + const errorRows: ErrorCauseRow[] = rawErrors.map((e) => ({ + cause: e.cause, + count: e.count, + compact: formatCompactNumber(e.count), + exact: formatExactNumber(e.count, locale), + sharePercent: computePercentage(e.count, totalErrors), + shareLabel: formatPercent(e.count, totalErrors), + })); + + return { + totalRequests, + completedRatioPercent, + completedRatioLabel, + outcomes: outcomeRows, + errors: errorRows, + totalErrors, + }; +} diff --git a/docs-site/src/components/telemetry-dashboard/types.ts b/docs-site/src/components/telemetry-dashboard/types.ts new file mode 100644 index 00000000000..76fc0dd448a --- /dev/null +++ b/docs-site/src/components/telemetry-dashboard/types.ts @@ -0,0 +1,236 @@ +export type TelemetryStatsRange = "7d" | "30d" | "90d" | "365d"; + +/** + * Public response contract of `GET https://telemetry.opencodex.me/v1/stats`. + * Kept in lockstep with `services/telemetry-collector/src/types.ts` and + * verified by `tests/ci-workflows/docs-telemetry-dashboard.test.ts`. + */ +export type TelemetryStatsV1 = { + schemaVersion: 1; + generatedAt: string; + range: TelemetryStatsRange; + totals: { + activeInstalls: number; + avgDailyActiveInstalls?: number; + newInstalls: number; + activeMinutes: number; + requests: number; + tokens: { + input: number; + cacheRead: number; + cacheWrite: number; + output: number; + reasoning: number; + }; + }; + daily: Array<{ + day: string; + activeInstalls: number; + newInstalls: number; + activeMinutes: number; + requests: number; + failed: number; + tokens: number; + }>; + dailyByProvider: Array<{ + day: string; + providerKind: string; + requests: number; + tokens: number; + }>; + heatmap: Array<{ + day: string; + activeInstalls: number; + }>; + providers: Array<{ + providerKind: string; + requests: number; + tokens: number; + }>; + models: Array<{ + modelFamily: string; + requests: number; + tokens: number; + }>; + versions: Array<{ + version: string; + installs: number; + newInstalls: number; + }>; + platforms: Array<{ + os: string; + arch: string; + installs: number; + }>; + channels: Array<{ + installChannel: string; + installs: number; + newInstalls: number; + }>; + outcomes: { + completed: number; + failed: number; + incomplete: number; + aborted: number; + }; + errors: Array<{ + cause: string; + count: number; + }>; +}; + +export type HeatmapGrade = 0 | 1 | 2 | 3 | 4; + +export type KpiMetricId = + | "activeInstalls" + | "newInstalls" + | "requests" + | "tokens" + | "activeHours"; + +export interface KpiCardItem { + id: KpiMetricId; + rawValue: number; + compact: string; + exactTitle: string; +} + +export type TokenBucketId = + | "input" + | "cacheRead" + | "cacheWrite" + | "output" + | "reasoning"; + +export interface TokenMixSegment { + id: TokenBucketId; + value: number; + compact: string; + exactTitle: string; + percent: number; + percentLabel: string; + color: string; +} + +export interface TokenMixSummary { + totalTokens: number; + totalCompact: string; + totalExactTitle: string; + segments: TokenMixSegment[]; +} + +export interface HeatmapCell { + date: string; + activeInstalls: number; + grade: HeatmapGrade; + inRange: boolean; + selectable: boolean; + weekIndex: number; + dayOfWeek: number; +} + +export interface HeatmapMonthMarker { + label: string; + monthIndex: number; + weekIndex: number; +} + +export interface HeatmapCalendar { + cells: HeatmapCell[]; + weekCount: number; + activeDays: number; + totalActiveInstalls: number; + maxActiveInstalls: number; + peakDay: { date: string; activeInstalls: number } | null; + startDate: string | null; + endDate: string | null; + monthMarkers: HeatmapMonthMarker[]; +} + +export type DailyChartMetric = "tokens" | "requests"; + +export interface DailyProviderSeries { + providerKind: string; + color: string; + total: number; + values: number[]; +} + +export interface StackedDailyChartModel { + days: string[]; + metric: DailyChartMetric; + total: number; + series: DailyProviderSeries[]; +} + +export interface ShareRow { + key: string; + label: string; + color: string; + requests: number; + requestsCompact: string; + requestsExact: string; + requestSharePercent: number; + requestShareLabel: string; + tokens: number; + tokensCompact: string; + tokensExact: string; + tokenSharePercent: number; + tokenShareLabel: string; +} + +export interface InstallRow { + key: string; + label: string; + installs: number; + installsCompact: string; + installsExact: string; + newInstalls?: number; + newInstallsCompact?: string; + newInstallsExact?: string; + sharePercent: number; + shareLabel: string; +} + +export type OutcomeClassId = "completed" | "failed" | "incomplete" | "aborted"; + +export interface OutcomeRow { + key: OutcomeClassId; + count: number; + compact: string; + exact: string; + sharePercent: number; + shareLabel: string; + color: string; +} + +export interface ErrorCauseRow { + cause: string; + count: number; + compact: string; + exact: string; + sharePercent: number; + shareLabel: string; +} + +export interface ReliabilitySummary { + totalRequests: number; + completedRatioPercent: number; + completedRatioLabel: string; + outcomes: OutcomeRow[]; + errors: ErrorCauseRow[]; + totalErrors: number; +} + +export interface DownloadStats { + npmWeekly: number | null; + npmMonthly: number | null; + npmYearly: number | null; + githubTotalDownloads: number | null; + githubDownloadsPartial: boolean; + githubLatestTag: string | null; + githubLatestDownloads: number | null; + githubReleaseCount: number; +} + +export type DashboardFetchState = "loading" | "ready" | "empty" | "error"; diff --git a/docs-site/src/content/docs/dashboard.mdx b/docs-site/src/content/docs/dashboard.mdx new file mode 100644 index 00000000000..baf5865d2c6 --- /dev/null +++ b/docs-site/src/content/docs/dashboard.mdx @@ -0,0 +1,12 @@ +--- +title: "Usage Dashboard" +description: "Project-wide anonymous usage statistics, contribution activity, provider and model breakdowns, and download metrics for opencodex." +template: splash +tableOfContents: false +editUrl: false +lastUpdated: false +--- + +import TelemetryDashboard from '../../components/TelemetryDashboard.astro'; + + diff --git a/docs-site/src/content/docs/guides/telemetry.md b/docs-site/src/content/docs/guides/telemetry.md index e1a7ddb0221..fc6ed7d1929 100644 --- a/docs-site/src/content/docs/guides/telemetry.md +++ b/docs-site/src/content/docs/guides/telemetry.md @@ -9,7 +9,7 @@ practice and where request failures occur. Project-wide daily aggregates (active counts, a 365-day active-install heatmap, active minutes, request outcomes, five-bucket token totals, daily and range provider-kind and model-family shares, top-50 version counts, OS/CPU platform and install-channel counts, and error-cause counts) are served publicly by the -collector's read-only `GET /v1/stats` endpoint for the public project dashboard; per-day install +collector's read-only `GET /v1/stats` endpoint for the [public project dashboard](/dashboard/); per-day install hashes (`installDayHash`) and rate-limit hashes (`ip_hash`) are never returned by `/v1/stats`. Telemetry is **on by default**, and **nothing is sent until a person has seen the notice**. diff --git a/docs-site/src/data/telemetry-dashboard-i18n.ts b/docs-site/src/data/telemetry-dashboard-i18n.ts new file mode 100644 index 00000000000..19ed927000a --- /dev/null +++ b/docs-site/src/data/telemetry-dashboard-i18n.ts @@ -0,0 +1,960 @@ +export const TELEMETRY_DASHBOARD_LOCALES = [ + "en", + "fr", + "ko", + "zh-cn", + "zh-tw", + "ru", + "ja", + "tr", +] as const; + +export type TelemetryDashboardLocale = (typeof TELEMETRY_DASHBOARD_LOCALES)[number]; + +export interface TelemetryDashboardCopy { + title: string; + subtitle: string; + rangeAria: string; + range7d: string; + range30d: string; + range90d: string; + range365d: string; + updatedPrefix: string; + updatedPending: string; + privacyBadge: string; + selfReportedLabel: string; + privacyBody: string; + privacyOptOutHint: string; + privacyGuideLink: string; + stateLoadingTitle: string; + stateLoadingBody: string; + stateEmptyTitle: string; + stateEmptyBody: string; + stateErrorTitle: string; + stateErrorBody: string; + retryButton: string; + kpiSectionAria: string; + kpiActiveInstalls: string; + kpiNewInstalls: string; + kpiRequests: string; + kpiTokens: string; + kpiActiveHours: string; + tokenMixTitle: string; + tokenMixSubtitle: string; + tokenInput: string; + tokenCacheRead: string; + tokenCacheWrite: string; + tokenOutput: string; + tokenReasoning: string; + heatmapTitle: string; + heatmapSubtitle: string; + heatmapActiveDays: string; + heatmapPeakDay: string; + heatmapLess: string; + heatmapMore: string; + heatmapTooltipUnit: string; + heatmapKeyboardHint: string; + dailyChartTitle: string; + dailyChartSubtitle: string; + metricTokens: string; + metricRequests: string; + providersTitle: string; + providersSubtitle: string; + modelsTitle: string; + modelsSubtitle: string; + colProvider: string; + colModel: string; + colRequests: string; + colTokens: string; + colShare: string; + versionsTitle: string; + versionsSubtitle: string; + platformsTitle: string; + platformsSubtitle: string; + channelsTitle: string; + channelsSubtitle: string; + colVersion: string; + colPlatform: string; + colChannel: string; + colInstalls: string; + colNewInstalls: string; + colInstallShare: string; + reliabilityTitle: string; + reliabilitySubtitle: string; + completionRateLabel: string; + outcomeCompleted: string; + outcomeIncomplete: string; + outcomeAborted: string; + outcomeFailed: string; + topErrorsTitle: string; + colErrorCause: string; + colErrorCount: string; + noErrorsRecorded: string; + downloadsTitle: string; + downloadsSubtitle: string; + npmWeeklyLabel: string; + npmMonthlyLabel: string; + npmYearlyLabel: string; + githubTotalLabel: string; + githubLatestLabel: string; + downloadsPartial: string; + downloadsUnavailable: string; + noDataInTable: string; + creditPrefix: string; + creditSuffix: string; +} + +export const TELEMETRY_DASHBOARD_STRINGS: Readonly< + Record +> = { + en: { + title: "Usage Dashboard", + subtitle: + "Project-wide anonymous daily aggregates, with all figures self-reported by opencodex installs (on by default after a person has seen the privacy notice, unless declined or turned off).", + rangeAria: "Reporting window", + range7d: "7d", + range30d: "30d", + range90d: "90d", + range365d: "365d", + updatedPrefix: "Updated", + updatedPending: "Awaiting snapshot", + privacyBadge: "Anonymous aggregates only", + selfReportedLabel: "Self-reported by opencodex installs", + privacyBody: + "Telemetry is on by default and sends nothing until a person has seen the notice. Reports carry closed-vocabulary daily counts with a rotating daily install hash — never prompts, model outputs, API keys, account identifiers, file paths, or IP addresses.", + privacyOptOutHint: + "Disable anytime with ocx telemetry disable, OCX_TELEMETRY=0, or DO_NOT_TRACK=1.", + privacyGuideLink: "Telemetry & privacy guide", + stateLoadingTitle: "Loading project telemetry…", + stateLoadingBody: + "Fetching aggregate usage snapshot from telemetry.opencodex.me.", + stateEmptyTitle: "Collector not deployed yet", + stateEmptyBody: + "The public collector at telemetry.opencodex.me is not deployed yet or has not recorded daily aggregates for this window.", + stateErrorTitle: "Could not load live telemetry", + stateErrorBody: + "The request to telemetry.opencodex.me did not complete (the collector may not be deployed yet or is temporarily unreachable).", + retryButton: "Retry", + kpiSectionAria: "Key usage metrics", + kpiActiveInstalls: "Avg daily active installs", + kpiNewInstalls: "New installs", + kpiRequests: "Requests", + kpiTokens: "Total tokens", + kpiActiveHours: "Active hours", + tokenMixTitle: "Token mix", + tokenMixSubtitle: + "Disjoint token buckets across all reported model families — segments sum to 100% of total tokens.", + tokenInput: "Input", + tokenCacheRead: "Cache read", + tokenCacheWrite: "Cache write", + tokenOutput: "Output", + tokenReasoning: "Reasoning", + heatmapTitle: "365-day activity heatmap", + heatmapSubtitle: + "Daily reporting installs over the trailing 52 weeks (UTC).", + heatmapActiveDays: "{count} active days", + heatmapPeakDay: "Peak: {date} ({count})", + heatmapLess: "Low", + heatmapMore: "High", + heatmapTooltipUnit: "active installs", + heatmapKeyboardHint: + "Use arrow keys to inspect adjacent days, Home and End to jump to range boundaries, and Escape to dismiss the tooltip.", + dailyChartTitle: "Daily usage by provider kind", + dailyChartSubtitle: + "Stacked daily volume across the selected reporting window.", + metricTokens: "Tokens", + metricRequests: "Requests", + providersTitle: "Provider kinds", + providersSubtitle: + "Closed vendor roster share by tokens and requests.", + modelsTitle: "Model families", + modelsSubtitle: + "Coarse model family share by tokens and requests.", + colProvider: "Provider kind", + colModel: "Model family", + colRequests: "Requests", + colTokens: "Tokens", + colShare: "Token share", + versionsTitle: "Versions", + versionsSubtitle: + "Active and newly reporting installs by release version.", + platformsTitle: "Platforms", + platformsSubtitle: "OS and CPU architecture distribution.", + channelsTitle: "Install channels", + channelsSubtitle: "How reporting proxies were installed.", + colVersion: "Version", + colPlatform: "Platform", + colChannel: "Channel", + colInstalls: "Installs", + colNewInstalls: "New", + colInstallShare: "Share", + reliabilityTitle: "Reliability & outcomes", + reliabilitySubtitle: + "Request outcome ratio and top closed-vocabulary failure causes.", + completionRateLabel: "Completion rate", + outcomeCompleted: "Completed", + outcomeIncomplete: "Incomplete", + outcomeAborted: "Aborted", + outcomeFailed: "Failed", + topErrorsTitle: "Top failure causes", + colErrorCause: "Failure cause", + colErrorCount: "Count", + noErrorsRecorded: "No failure causes recorded in this window.", + downloadsTitle: "Package & release downloads", + downloadsSubtitle: + "Live download counts from npm (@bitkyc08/opencodex) and GitHub release assets (lidge-jun/opencodex).", + npmWeeklyLabel: "npm (last 7 days)", + npmMonthlyLabel: "npm (last 30 days)", + npmYearlyLabel: "npm (last 365 days)", + githubTotalLabel: "GitHub release assets (total)", + githubLatestLabel: "Latest release assets", + downloadsPartial: "partial", + downloadsUnavailable: "Unavailable", + noDataInTable: "No data reported for this window.", + creditPrefix: + "Visual design and contribution heatmap patterns adapted from", + creditSuffix: "(MIT License).", + }, + fr: { + title: "Tableau de bord d’utilisation", + subtitle: + "Agrégats quotidiens anonymes à l’échelle du projet, tous les chiffres étant auto-déclarés par les installations opencodex (activé par défaut après affichage de l’avis de confidentialité à une personne, sauf refus ou désactivation).", + rangeAria: "Période d’analyse", + range7d: "7j", + range30d: "30j", + range90d: "90j", + range365d: "365j", + updatedPrefix: "Mis à jour", + updatedPending: "En attente d’instantané", + privacyBadge: "Agrégats anonymes uniquement", + selfReportedLabel: "Auto-déclaré par les installations opencodex", + privacyBody: + "La télémétrie est activée par défaut et n’envoie rien tant qu’une personne n’a pas vu l’avis. Les rapports ne contiennent que des compteurs quotidiens à vocabulaire fermé avec un hachage journalier rotatif — jamais de prompts, de réponses, de clés API, d’identifiants de compte, de chemins ou d’adresses IP.", + privacyOptOutHint: + "Désactivez à tout moment avec ocx telemetry disable, OCX_TELEMETRY=0 ou DO_NOT_TRACK=1.", + privacyGuideLink: "Guide de télémétrie et confidentialité", + stateLoadingTitle: "Chargement de la télémétrie du projet…", + stateLoadingBody: + "Récupération des statistiques agrégées depuis telemetry.opencodex.me.", + stateEmptyTitle: "Collecteur non encore déployé", + stateEmptyBody: + "Le collecteur public sur telemetry.opencodex.me n’est pas encore déployé ou n’a pas encore enregistré d’agrégats pour cette période.", + stateErrorTitle: "Impossible de charger la télémétrie en direct", + stateErrorBody: + "La requête vers telemetry.opencodex.me a échoué (le collecteur n’est peut-être pas encore déployé ou est temporairement injoignable).", + retryButton: "Réessayer", + kpiSectionAria: "Indicateurs clés d’utilisation", + kpiActiveInstalls: "Installations actives moy./jour", + kpiNewInstalls: "Nouvelles installations", + kpiRequests: "Requêtes", + kpiTokens: "Jetons totaux", + kpiActiveHours: "Heures actives", + tokenMixTitle: "Répartition des jetons", + tokenMixSubtitle: + "Catégories de jetons disjointes sur toutes les familles de modèles — la somme correspond à 100 % des jetons.", + tokenInput: "Entrée", + tokenCacheRead: "Lecture cache", + tokenCacheWrite: "Écriture cache", + tokenOutput: "Sortie", + tokenReasoning: "Raisonnement", + heatmapTitle: "Carte d’activité sur 365 jours", + heatmapSubtitle: + "Installations actives quotidiennes sur les 52 dernières semaines (UTC).", + heatmapActiveDays: "{count} jours actifs", + heatmapPeakDay: "Pic : {date} ({count})", + heatmapLess: "Faible", + heatmapMore: "Élevé", + heatmapTooltipUnit: "installations actives", + heatmapKeyboardHint: + "Utilisez les flèches pour parcourir les jours, Début/Fin pour aller aux extrémités et Échap pour fermer l’infobulle.", + dailyChartTitle: "Utilisation quotidienne par type de fournisseur", + dailyChartSubtitle: + "Volume quotidien empilé sur la période sélectionnée.", + metricTokens: "Jetons", + metricRequests: "Requêtes", + providersTitle: "Types de fournisseurs", + providersSubtitle: "Part des fournisseurs par jetons et requêtes.", + modelsTitle: "Familles de modèles", + modelsSubtitle: "Part des familles de modèles par jetons et requêtes.", + colProvider: "Fournisseur", + colModel: "Famille de modèles", + colRequests: "Requêtes", + colTokens: "Jetons", + colShare: "Part (jetons)", + versionsTitle: "Versions", + versionsSubtitle: + "Installations actives et nouvelles par version publiée.", + platformsTitle: "Plateformes", + platformsSubtitle: "Répartition par système et architecture CPU.", + channelsTitle: "Canaux d’installation", + channelsSubtitle: "Mode d’installation des proxys déclarants.", + colVersion: "Version", + colPlatform: "Plateforme", + colChannel: "Canal", + colInstalls: "Installations", + colNewInstalls: "Nouvelles", + colInstallShare: "Part", + reliabilityTitle: "Fiabilité et résultats", + reliabilitySubtitle: + "Taux d’aboutissement des requêtes et principales causes d’échec.", + completionRateLabel: "Taux de réussite", + outcomeCompleted: "Terminées", + outcomeIncomplete: "Incomplètes", + outcomeAborted: "Interrompues", + outcomeFailed: "Échouées", + topErrorsTitle: "Principales causes d’échec", + colErrorCause: "Cause d’échec", + colErrorCount: "Nombre", + noErrorsRecorded: "Aucune cause d’échec enregistrée sur cette période.", + downloadsTitle: "Téléchargements de paquets et versions", + downloadsSubtitle: + "Compteurs en direct depuis npm (@bitkyc08/opencodex) et les binaires GitHub Releases (lidge-jun/opencodex).", + npmWeeklyLabel: "npm (7 derniers jours)", + npmMonthlyLabel: "npm (30 derniers jours)", + npmYearlyLabel: "npm (365 derniers jours)", + githubTotalLabel: "GitHub Releases (total)", + githubLatestLabel: "Dernière version GitHub", + downloadsPartial: "partiel", + downloadsUnavailable: "Indisponible", + noDataInTable: "Aucune donnée déclarée pour cette période.", + creditPrefix: + "Design visuel et carte d’activité adaptés de", + creditSuffix: "(licence MIT).", + }, + ko: { + title: "사용량 대시보드", + subtitle: + "개인정보 안내를 확인한 뒤 기본으로 활성화되는 opencodex 설치(거부하거나 끈 경우 제외)에서 자체 보고된 프로젝트 전체 익명 일일 통계입니다.", + rangeAria: "집계 기간", + range7d: "7일", + range30d: "30일", + range90d: "90일", + range365d: "365일", + updatedPrefix: "업데이트", + updatedPending: "스냅샷 대기 중", + privacyBadge: "익명 집계 전용", + selfReportedLabel: "opencodex 설치에서 자체 보고된 수치", + privacyBody: + "텔레메트리는 기본으로 켜져 있으며 사람이 안내를 확인하기 전에는 아무것도 전송하지 않습니다. 보고서에는 매일 회전하는 설치 해시와 고정 어휘 일일 카운터만 포함되며 프롬프트, 모델 출력, API 키, 계정 식별자, 파일 경로, IP 주소는 절대 전송되지 않습니다.", + privacyOptOutHint: + "언제든 ocx telemetry disable, OCX_TELEMETRY=0 또는 DO_NOT_TRACK=1로 끌 수 있습니다.", + privacyGuideLink: "텔레메트리 및 개인정보 가이드", + stateLoadingTitle: "프로젝트 텔레메트리 불러오는 중…", + stateLoadingBody: + "telemetry.opencodex.me에서 집계 스냅샷을 가져오고 있습니다.", + stateEmptyTitle: "수집기가 아직 배포되지 않았습니다", + stateEmptyBody: + "telemetry.opencodex.me 공개 수집기가 아직 배포되지 않았거나 선택한 기간에 기록된 일일 집계가 없습니다.", + stateErrorTitle: "실시간 텔레메트리를 불러오지 못했습니다", + stateErrorBody: + "telemetry.opencodex.me 요청을 완료하지 못했습니다(수집기가 아직 배포 전이거나 일시적으로 연결할 수 없습니다).", + retryButton: "다시 시도", + kpiSectionAria: "주요 사용 지표", + kpiActiveInstalls: "일평균 활성 설치", + kpiNewInstalls: "신규 설치", + kpiRequests: "요청 수", + kpiTokens: "총 토큰", + kpiActiveHours: "활성 시간", + tokenMixTitle: "토큰 구성비", + tokenMixSubtitle: + "입력·캐시 읽기·캐시 쓰기·출력·추론의 서로소(disjoint) 토큰 버킷 분포로 합계는 100%입니다.", + tokenInput: "입력", + tokenCacheRead: "캐시 읽기", + tokenCacheWrite: "캐시 쓰기", + tokenOutput: "출력", + tokenReasoning: "추론", + heatmapTitle: "365일 활동 히트맵", + heatmapSubtitle: + "최근 52주간 일별 활성 설치 추이(UTC 기준).", + heatmapActiveDays: "활성 일수 {count}일", + heatmapPeakDay: "최고치: {date} ({count})", + heatmapLess: "낮음", + heatmapMore: "높음", + heatmapTooltipUnit: "활성 설치", + heatmapKeyboardHint: + "방향키로 인접 날짜를 이동하고 Home/End로 시작·끝 날짜로 이동하며 Escape로 툴팁을 닫습니다.", + dailyChartTitle: "프로바이더 종류별 일일 사용량", + dailyChartSubtitle: + "선택한 기간의 프로바이더별 일일 누적 추이입니다.", + metricTokens: "토큰", + metricRequests: "요청 수", + providersTitle: "프로바이더 종류", + providersSubtitle: "토큰 및 요청 기준 프로바이더 점유율.", + modelsTitle: "모델 계열", + modelsSubtitle: "토큰 및 요청 기준 모델 계열 점유율.", + colProvider: "프로바이더", + colModel: "모델 계열", + colRequests: "요청 수", + colTokens: "토큰", + colShare: "토큰 점유율", + versionsTitle: "버전 분포", + versionsSubtitle: "릴리스 버전별 활성 설치 및 신규 설치 수.", + platformsTitle: "플랫폼 분포", + platformsSubtitle: "OS 및 CPU 아키텍처 비율.", + channelsTitle: "설치 채널", + channelsSubtitle: "프록시가 설치된 경로별 비율.", + colVersion: "버전", + colPlatform: "플랫폼", + colChannel: "설치 채널", + colInstalls: "설치 수", + colNewInstalls: "신규", + colInstallShare: "비율", + reliabilityTitle: "신뢰성 및 요청 결과", + reliabilitySubtitle: + "요청 완료 비율과 주요 실패 원인(고정 어휘 코드).", + completionRateLabel: "완료율", + outcomeCompleted: "완료", + outcomeIncomplete: "미완료", + outcomeAborted: "중단됨", + outcomeFailed: "실패", + topErrorsTitle: "주요 실패 원인", + colErrorCause: "실패 원인", + colErrorCount: "건수", + noErrorsRecorded: "이 기간에 기록된 실패 원인이 없습니다.", + downloadsTitle: "패키지 및 릴리스 다운로드", + downloadsSubtitle: + "npm(@bitkyc08/opencodex)과 GitHub 릴리스 에셋(lidge-jun/opencodex) 실시간 다운로드 집계.", + npmWeeklyLabel: "npm (최근 7일)", + npmMonthlyLabel: "npm (최근 30일)", + npmYearlyLabel: "npm (최근 365일)", + githubTotalLabel: "GitHub 릴리스 에셋 (누적)", + githubLatestLabel: "최신 릴리스 에셋", + downloadsPartial: "일부 집계", + downloadsUnavailable: "확인 불가", + noDataInTable: "이 기간에 보고된 데이터가 없습니다.", + creditPrefix: + "시각 디자인 및 기여도 히트맵 패턴 참고:", + creditSuffix: "(MIT 라이선스).", + }, + "zh-cn": { + title: "使用量仪表盘", + subtitle: + "由 opencodex 安装实例自行上报的全项目匿名每日汇总(在用户查看隐私提示后默认开启,除非拒绝或关闭)。", + rangeAria: "统计时间范围", + range7d: "7天", + range30d: "30天", + range90d: "90天", + range365d: "365天", + updatedPrefix: "更新于", + updatedPending: "等待快照", + privacyBadge: "仅匿名汇总数据", + selfReportedLabel: "由 opencodex 安装实例自行上报", + privacyBody: + "遥测默认开启,但在人工查看隐私提示前不会发送任何数据。上报数据仅包含固定词汇的每日计数与每日轮换的安装哈希——绝不包含提示词、模型输出、API 密钥、账户标识、文件路径或 IP 地址。", + privacyOptOutHint: + "可随时通过 ocx telemetry disable、OCX_TELEMETRY=0 或 DO_NOT_TRACK=1 关闭。", + privacyGuideLink: "遥测与隐私指南", + stateLoadingTitle: "正在加载项目遥测数据…", + stateLoadingBody: "正在从 telemetry.opencodex.me 获取汇总快照。", + stateEmptyTitle: "收集器尚未部署", + stateEmptyBody: + "位于 telemetry.opencodex.me 的公共收集器尚未部署,或该时间范围内尚无每日汇总记录。", + stateErrorTitle: "无法加载实时遥测数据", + stateErrorBody: + "对 telemetry.opencodex.me 的请求未能完成(收集器可能尚未部署或暂时无法访问)。", + retryButton: "重试", + kpiSectionAria: "核心使用指标", + kpiActiveInstalls: "日均活跃安装", + kpiNewInstalls: "新增安装", + kpiRequests: "请求数", + kpiTokens: "总 Token 数", + kpiActiveHours: "活跃小时数", + tokenMixTitle: "Token 构成", + tokenMixSubtitle: + "输入、缓存读取、缓存写入、输出与推理五个互不重叠的 Token 桶,总和为 100%。", + tokenInput: "输入", + tokenCacheRead: "缓存读取", + tokenCacheWrite: "缓存写入", + tokenOutput: "输出", + tokenReasoning: "推理", + heatmapTitle: "365 天活跃热力图", + heatmapSubtitle: "过去 52 周每日活跃安装量(UTC)。", + heatmapActiveDays: "{count} 个活跃日", + heatmapPeakDay: "峰值:{date}({count})", + heatmapLess: "低", + heatmapMore: "高", + heatmapTooltipUnit: "活跃安装", + heatmapKeyboardHint: + "使用方向键浏览相邻日期,Home/End 跳至起止日期,Escape 关闭提示框。", + dailyChartTitle: "按提供商类型的每日使用量", + dailyChartSubtitle: "所选时间窗口内的每日堆叠趋势。", + metricTokens: "Token", + metricRequests: "请求数", + providersTitle: "提供商类型", + providersSubtitle: "按 Token 与请求数统计的提供商占比。", + modelsTitle: "模型系列", + modelsSubtitle: "按 Token 与请求数统计的模型系列占比。", + colProvider: "提供商类型", + colModel: "模型系列", + colRequests: "请求数", + colTokens: "Token", + colShare: "Token 占比", + versionsTitle: "版本分布", + versionsSubtitle: "各发布版本的活跃安装与新增安装。", + platformsTitle: "平台分布", + platformsSubtitle: "操作系统与 CPU 架构分布。", + channelsTitle: "安装渠道", + channelsSubtitle: "上报代理的安装方式分布。", + colVersion: "版本", + colPlatform: "平台", + colChannel: "渠道", + colInstalls: "安装量", + colNewInstalls: "新增", + colInstallShare: "占比", + reliabilityTitle: "可靠性与请求结果", + reliabilitySubtitle: "请求完成率与主要失败原因(固定错误码)。", + completionRateLabel: "完成率", + outcomeCompleted: "已完成", + outcomeIncomplete: "未完成", + outcomeAborted: "已中止", + outcomeFailed: "失败", + topErrorsTitle: "主要失败原因", + colErrorCause: "失败原因", + colErrorCount: "次数", + noErrorsRecorded: "该时间窗口内无失败记录。", + downloadsTitle: "软件包与发行版下载量", + downloadsSubtitle: + "来自 npm (@bitkyc08/opencodex) 与 GitHub Release 资产 (lidge-jun/opencodex) 的实时下载统计。", + npmWeeklyLabel: "npm(最近 7 天)", + npmMonthlyLabel: "npm(最近 30 天)", + npmYearlyLabel: "npm(最近 365 天)", + githubTotalLabel: "GitHub 发行版资产(累计)", + githubLatestLabel: "最新发行版资产", + downloadsPartial: "部分统计", + downloadsUnavailable: "暂不可用", + noDataInTable: "该时间窗口内暂无上报数据。", + creditPrefix: "视觉设计与贡献热力图模式改编自", + creditSuffix: "(MIT 许可证)。", + }, + "zh-tw": { + title: "使用量儀表板", + subtitle: + "由 opencodex 安裝實例自行回報之全專案匿名每日彙總(於使用者查看隱私提示後預設開啟,除非拒絕或關閉)。", + rangeAria: "統計時間範圍", + range7d: "7天", + range30d: "30天", + range90d: "90天", + range365d: "365天", + updatedPrefix: "更新於", + updatedPending: "等待快照", + privacyBadge: "僅限匿名彙總資料", + selfReportedLabel: "由 opencodex 安裝實例自行回報", + privacyBody: + "遙測預設開啟,但在人工查看隱私提示前不會傳送任何資料。回報資料僅包含固定詞彙的每日計數與每日輪替的安裝雜湊——絕不包含提示詞、模型輸出、API 金鑰、帳號識別碼、檔案路徑或 IP 位址。", + privacyOptOutHint: + "可隨時透過 ocx telemetry disable、OCX_TELEMETRY=0 或 DO_NOT_TRACK=1 關閉。", + privacyGuideLink: "遙測與隱私指南", + stateLoadingTitle: "正在載入專案遙測資料…", + stateLoadingBody: "正在從 telemetry.opencodex.me 取得彙總快照。", + stateEmptyTitle: "收集器尚未部署", + stateEmptyBody: + "位於 telemetry.opencodex.me 的公開收集器尚未部署,或此時間範圍內尚無每日彙總紀錄。", + stateErrorTitle: "無法載入即時遙測資料", + stateErrorBody: + "對 telemetry.opencodex.me 的請求未能完成(收集器可能尚未部署或暫時無法連線)。", + retryButton: "重試", + kpiSectionAria: "核心使用指標", + kpiActiveInstalls: "日均活躍安裝", + kpiNewInstalls: "新增安裝", + kpiRequests: "請求數", + kpiTokens: "總 Token 數", + kpiActiveHours: "活躍小時數", + tokenMixTitle: "Token 構成", + tokenMixSubtitle: + "輸入、快取讀取、快取寫入、輸出與推理五個互不重疊的 Token 桶,總和為 100%。", + tokenInput: "輸入", + tokenCacheRead: "快取讀取", + tokenCacheWrite: "快取寫入", + tokenOutput: "輸出", + tokenReasoning: "推理", + heatmapTitle: "365 天活躍熱力圖", + heatmapSubtitle: "過去 52 週每日活躍安裝量(UTC)。", + heatmapActiveDays: "{count} 個活躍日", + heatmapPeakDay: "峰值:{date}({count})", + heatmapLess: "低", + heatmapMore: "高", + heatmapTooltipUnit: "活躍安裝", + heatmapKeyboardHint: + "使用方向鍵瀏覽相鄰日期,Home/End 跳至起訖日期,Escape 關閉提示框。", + dailyChartTitle: "依供應商類型的每日使用量", + dailyChartSubtitle: "所選時間視窗內的每日堆疊趨勢。", + metricTokens: "Token", + metricRequests: "請求數", + providersTitle: "供應商類型", + providersSubtitle: "依 Token 與請求數統計的供應商佔比。", + modelsTitle: "模型系列", + modelsSubtitle: "依 Token 與請求數統計的模型系列佔比。", + colProvider: "供應商類型", + colModel: "模型系列", + colRequests: "請求數", + colTokens: "Token", + colShare: "Token 佔比", + versionsTitle: "版本分佈", + versionsSubtitle: "各發佈版本的活躍安裝與新增安裝。", + platformsTitle: "平台分佈", + platformsSubtitle: "作業系統與 CPU 架構分佈。", + channelsTitle: "安裝管道", + channelsSubtitle: "回報代理的安裝方式分佈。", + colVersion: "版本", + colPlatform: "平台", + colChannel: "管道", + colInstalls: "安裝量", + colNewInstalls: "新增", + colInstallShare: "佔比", + reliabilityTitle: "可靠性與請求結果", + reliabilitySubtitle: "請求完成率與主要失敗原因(固定錯誤碼)。", + completionRateLabel: "完成率", + outcomeCompleted: "已完成", + outcomeIncomplete: "未完成", + outcomeAborted: "已中止", + outcomeFailed: "失敗", + topErrorsTitle: "主要失敗原因", + colErrorCause: "失敗原因", + colErrorCount: "次數", + noErrorsRecorded: "此時間視窗內無失敗紀錄。", + downloadsTitle: "套件與發行版下載量", + downloadsSubtitle: + "來自 npm (@bitkyc08/opencodex) 與 GitHub Release 資產 (lidge-jun/opencodex) 的即時下載統計。", + npmWeeklyLabel: "npm(最近 7 天)", + npmMonthlyLabel: "npm(最近 30 天)", + npmYearlyLabel: "npm(最近 365 天)", + githubTotalLabel: "GitHub 發行版資產(累計)", + githubLatestLabel: "最新發行版資產", + downloadsPartial: "部分統計", + downloadsUnavailable: "暫不可用", + noDataInTable: "此時間視窗內暫無回報資料。", + creditPrefix: "視覺設計與貢獻熱力圖模式改編自", + creditSuffix: "(MIT 授權條款)。", + }, + ru: { + title: "Дашборд использования", + subtitle: + "Анонимные ежедневные сводки по всему проекту по данным самоотчётов установок opencodex (включено по умолчанию после показа уведомления человеку, если не отклонено и не отключено).", + rangeAria: "Период отчёта", + range7d: "7д", + range30d: "30д", + range90d: "90д", + range365d: "365д", + updatedPrefix: "Обновлено", + updatedPending: "Ожидание снимка", + privacyBadge: "Только анонимные сводки", + selfReportedLabel: "По данным самоотчётов установок opencodex", + privacyBody: + "Телеметрия включена по умолчанию и ничего не отправляет, пока человек не увидит уведомление. Отчёты содержат только суточные счётчики фиксированного словаря и ежедневно меняющийся хеш установки — без промптов, ответов моделей, API-ключей, аккаунтов, путей или IP-адресов.", + privacyOptOutHint: + "Отключить можно в любой момент через ocx telemetry disable, OCX_TELEMETRY=0 или DO_NOT_TRACK=1.", + privacyGuideLink: "Руководство по телеметрии и приватности", + stateLoadingTitle: "Загрузка телеметрии проекта…", + stateLoadingBody: + "Получение сводного снимка использования с telemetry.opencodex.me.", + stateEmptyTitle: "Коллектор ещё не развёрнут", + stateEmptyBody: + "Публичный коллектор telemetry.opencodex.me ещё не развёрнут или не содержит записей за выбранный период.", + stateErrorTitle: "Не удалось загрузить телеметрию", + stateErrorBody: + "Запрос к telemetry.opencodex.me не завершился (коллектор ещё не развёрнут или временно недоступен).", + retryButton: "Повторить", + kpiSectionAria: "Ключевые метрики использования", + kpiActiveInstalls: "Среднесуточные активные установки", + kpiNewInstalls: "Новые установки", + kpiRequests: "Запросы", + kpiTokens: "Всего токенов", + kpiActiveHours: "Активные часы", + tokenMixTitle: "Структура токенов", + tokenMixSubtitle: + "Непересекающиеся категории токенов по всем семействам моделей — в сумме дают 100%.", + tokenInput: "Ввод", + tokenCacheRead: "Чтение кэша", + tokenCacheWrite: "Запись кэша", + tokenOutput: "Вывод", + tokenReasoning: "Рассуждения", + heatmapTitle: "Карта активности за 365 дней", + heatmapSubtitle: + "Ежедневные активные установки за последние 52 недели (UTC).", + heatmapActiveDays: "Активных дней: {count}", + heatmapPeakDay: "Пик: {date} ({count})", + heatmapLess: "Меньше", + heatmapMore: "Больше", + heatmapTooltipUnit: "активных установок", + heatmapKeyboardHint: + "Используйте стрелки для перехода по дням, Home/End для перехода к краям периода и Escape для закрытия подсказки.", + dailyChartTitle: "Ежедневное использование по типам провайдеров", + dailyChartSubtitle: + "Сложенная динамика по дням за выбранный период.", + metricTokens: "Токены", + metricRequests: "Запросы", + providersTitle: "Типы провайдеров", + providersSubtitle: "Доля провайдеров по токенам и запросам.", + modelsTitle: "Семейства моделей", + modelsSubtitle: "Доля семейств моделей по токенам и запросам.", + colProvider: "Провайдер", + colModel: "Семейство моделей", + colRequests: "Запросы", + colTokens: "Токены", + colShare: "Доля токенов", + versionsTitle: "Версии", + versionsSubtitle: + "Активные и новые установки по версиям релиза.", + platformsTitle: "Платформы", + platformsSubtitle: "Распределение по ОС и архитектуре CPU.", + channelsTitle: "Каналы установки", + channelsSubtitle: "Способы установки прокси.", + colVersion: "Версия", + colPlatform: "Платформа", + colChannel: "Канал", + colInstalls: "Установки", + colNewInstalls: "Новые", + colInstallShare: "Доля", + reliabilityTitle: "Надёжность и исходы", + reliabilitySubtitle: + "Доля успешных запросов и основные причины сбоев.", + completionRateLabel: "Доля завершения", + outcomeCompleted: "Завершено", + outcomeIncomplete: "Неполные", + outcomeAborted: "Прервано", + outcomeFailed: "Ошибка", + topErrorsTitle: "Основные причины сбоев", + colErrorCause: "Причина сбоя", + colErrorCount: "Количество", + noErrorsRecorded: "За этот период ошибок не зафиксировано.", + downloadsTitle: "Загрузки пакетов и релизов", + downloadsSubtitle: + "Статистика скачиваний из npm (@bitkyc08/opencodex) и GitHub Releases (lidge-jun/opencodex).", + npmWeeklyLabel: "npm (за 7 дней)", + npmMonthlyLabel: "npm (за 30 дней)", + npmYearlyLabel: "npm (за 365 дней)", + githubTotalLabel: "GitHub Releases (всего)", + githubLatestLabel: "Последний релиз GitHub", + downloadsPartial: "частично", + downloadsUnavailable: "Недоступно", + noDataInTable: "Нет данных за выбранный период.", + creditPrefix: + "Визуальный дизайн и тепловая карта активности адаптированы из", + creditSuffix: "(лицензия MIT).", + }, + ja: { + title: "利用統計ダッシュボード", + subtitle: + "opencodex インストールによる自己申告データから集計されたプロジェクト全体の匿名日次統計です(プライバシー通知の表示後にデフォルトで有効になり、拒否または無効化されている場合を除きます)。", + rangeAria: "集計期間", + range7d: "7日", + range30d: "30日", + range90d: "90日", + range365d: "365日", + updatedPrefix: "更新", + updatedPending: "スナップショット待機中", + privacyBadge: "匿名集計のみ", + selfReportedLabel: "opencodex インストールによる自己申告データ", + privacyBody: + "テレメトリはデフォルトでオンですが、人が通知を確認するまでは何も送信されません。レポートには日替わりインストールハッシュと固定語彙の日次カウンタのみが含まれ、プロンプト、モデル出力、APIキー、アカウント識別子、ファイルパス、IPアドレスは一切含まれません。", + privacyOptOutHint: + "ocx telemetry disable、OCX_TELEMETRY=0、または DO_NOT_TRACK=1 でいつでも無効化できます。", + privacyGuideLink: "テレメトリとプライバシーガイド", + stateLoadingTitle: "プロジェクトテレメトリを読み込み中…", + stateLoadingBody: + "telemetry.opencodex.me から集計スナップショットを取得しています。", + stateEmptyTitle: "コレクターはまだデプロイされていません", + stateEmptyBody: + "telemetry.opencodex.me の公開コレクターがまだデプロイされていないか、この期間の日次集計がまだ記録されていません。", + stateErrorTitle: "ライブテレメトリを読み込めませんでした", + stateErrorBody: + "telemetry.opencodex.me へのリクエストが完了しませんでした(コレクターが未デプロイか一時的に到達不能です)。", + retryButton: "再試行", + kpiSectionAria: "主要利用指標", + kpiActiveInstalls: "1日平均アクティブインストール", + kpiNewInstalls: "新規インストール", + kpiRequests: "リクエスト数", + kpiTokens: "総トークン数", + kpiActiveHours: "アクティブ時間", + tokenMixTitle: "トークン内訳", + tokenMixSubtitle: + "入力・キャッシュ読取・キャッシュ書込・出力・推論の互いに素な5区分の比率(合計100%)。", + tokenInput: "入力", + tokenCacheRead: "キャッシュ読取", + tokenCacheWrite: "キャッシュ書込", + tokenOutput: "出力", + tokenReasoning: "推論", + heatmapTitle: "365日アクティビティヒートマップ", + heatmapSubtitle: + "過去52週間の日次アクティブインストール数(UTC)。", + heatmapActiveDays: "アクティブ {count} 日", + heatmapPeakDay: "ピーク: {date} ({count})", + heatmapLess: "少", + heatmapMore: "多", + heatmapTooltipUnit: "アクティブインストール", + heatmapKeyboardHint: + "矢印キーで前後の日に移動し、Home/Endで期間の端へジャンプ、Escapeでツールチップを閉じます。", + dailyChartTitle: "プロバイダー種別ごとの日次利用量", + dailyChartSubtitle: "選択した期間における日次積み上げ推移。", + metricTokens: "トークン", + metricRequests: "リクエスト", + providersTitle: "プロバイダー種別", + providersSubtitle: "トークン数・リクエスト数別のプロバイダーシェア。", + modelsTitle: "モデル系統", + modelsSubtitle: "トークン数・リクエスト数別のモデル系統シェア。", + colProvider: "プロバイダー種別", + colModel: "モデル系統", + colRequests: "リクエスト", + colTokens: "トークン", + colShare: "トークン比率", + versionsTitle: "バージョン", + versionsSubtitle: "リリースバージョン別のアクティブ・新規インストール数。", + platformsTitle: "プラットフォーム", + platformsSubtitle: "OS および CPU アーキテクチャの分布。", + channelsTitle: "インストール経路", + channelsSubtitle: "プロキシのインストール方法別の分布。", + colVersion: "バージョン", + colPlatform: "プラットフォーム", + colChannel: "経路", + colInstalls: "インストール数", + colNewInstalls: "新規", + colInstallShare: "比率", + reliabilityTitle: "信頼性とリクエスト結果", + reliabilitySubtitle: + "リクエスト完了率と主な失敗原因(固定エラーコード)。", + completionRateLabel: "完了率", + outcomeCompleted: "完了", + outcomeIncomplete: "未完了", + outcomeAborted: "中断", + outcomeFailed: "失敗", + topErrorsTitle: "主な失敗原因", + colErrorCause: "失敗原因", + colErrorCount: "件数", + noErrorsRecorded: "この期間に記録された失敗原因はありません。", + downloadsTitle: "パッケージ & リリースダウンロード", + downloadsSubtitle: + "npm (@bitkyc08/opencodex) と GitHub Releases (lidge-jun/opencodex) のライブダウンロード集計。", + npmWeeklyLabel: "npm(過去7日間)", + npmMonthlyLabel: "npm(過去30日間)", + npmYearlyLabel: "npm(過去365日間)", + githubTotalLabel: "GitHub リリースアセット(累計)", + githubLatestLabel: "最新リリースアセット", + downloadsPartial: "一部集計", + downloadsUnavailable: "取得不可", + noDataInTable: "この期間のレポートデータはありません。", + creditPrefix: + "ビジュアルデザインとコントリビューションヒートマップは", + creditSuffix: "(MITライセンス)を参考に構成されています。", + }, + tr: { + title: "Kullanım Paneli", + subtitle: + "opencodex kurulumları tarafından kendi kendine bildirilen proje genelinde anonim günlük istatistikler (bir kişi gizlilik bildirimini gördükten sonra, reddedilmediği veya kapatılmadığı sürece varsayılan olarak açıktır).", + rangeAria: "Raporlama aralığı", + range7d: "7g", + range30d: "30g", + range90d: "90g", + range365d: "365g", + updatedPrefix: "Güncellendi", + updatedPending: "Anlık görüntü bekleniyor", + privacyBadge: "Yalnızca anonim toplamlar", + selfReportedLabel: "opencodex kurulumları tarafından kendi kendine bildirilen veriler", + privacyBody: + "Telemetri varsayılan olarak açıktır ve bir kişi bildirimi görene kadar hiçbir şey göndermez. Raporlar yalnızca günlük dönen kurulum karması ve kapalı sözlük sayaçları içerir; istemler, model çıktıları, API anahtarları, hesap kimlikleri, dosya yolları veya IP adresleri asla gönderilmez.", + privacyOptOutHint: + "İstediğiniz zaman ocx telemetry disable, OCX_TELEMETRY=0 veya DO_NOT_TRACK=1 ile kapatabilirsiniz.", + privacyGuideLink: "Telemetri ve gizlilik kılavuzu", + stateLoadingTitle: "Proje telemetrisi yükleniyor…", + stateLoadingBody: + "telemetry.opencodex.me adresinden toplu kullanım verileri alınıyor.", + stateEmptyTitle: "Toplayıcı henüz devreye alınmadı", + stateEmptyBody: + "telemetry.opencodex.me üzerindeki genel toplayıcı henüz dağıtılmadı veya bu aralık için günlük veri kaydedilmedi.", + stateErrorTitle: "Canlı telemetri yüklenemedi", + stateErrorBody: + "telemetry.opencodex.me isteği tamamlanamadı (toplayıcı henüz dağıtılmamış veya geçici olarak erişilemiyor olabilir).", + retryButton: "Yeniden dene", + kpiSectionAria: "Temel kullanım ölçütleri", + kpiActiveInstalls: "Günlük ort. etkin kurulum", + kpiNewInstalls: "Yeni kurulumlar", + kpiRequests: "İstekler", + kpiTokens: "Toplam jeton", + kpiActiveHours: "Etkin saat", + tokenMixTitle: "Jeton dağılımı", + tokenMixSubtitle: + "Giriş, önbellek okuma, önbellek yazma, çıkış ve akıl yürütme ayrık jeton dilimleri — toplamı %100'dür.", + tokenInput: "Giriş", + tokenCacheRead: "Önbellek okuma", + tokenCacheWrite: "Önbellek yazma", + tokenOutput: "Çıkış", + tokenReasoning: "Akıl yürütme", + heatmapTitle: "365 günlük etkinlik ısı haritası", + heatmapSubtitle: + "Son 52 hafta boyunca günlük rapor veren etkin kurulumlar (UTC).", + heatmapActiveDays: "{count} etkin gün", + heatmapPeakDay: "Zirve: {date} ({count})", + heatmapLess: "Düşük", + heatmapMore: "Yüksek", + heatmapTooltipUnit: "etkin kurulum", + heatmapKeyboardHint: + "Günler arasında gezinmek için ok tuşlarını, dönem uçlarına gitmek için Home/End tuşlarını, ipucunu kapatmak için Escape tuşunu kullanın.", + dailyChartTitle: "Sağlayıcı türüne göre günlük kullanım", + dailyChartSubtitle: "Seçilen aralıktaki günlük yığılmış kullanım hacmi.", + metricTokens: "Jeton", + metricRequests: "İstek", + providersTitle: "Sağlayıcı türleri", + providersSubtitle: "Jeton ve isteklere göre sağlayıcı payları.", + modelsTitle: "Model aileleri", + modelsSubtitle: "Jeton ve isteklere göre model ailesi payları.", + colProvider: "Sağlayıcı türü", + colModel: "Model ailesi", + colRequests: "İstek", + colTokens: "Jeton", + colShare: "Jeton payı", + versionsTitle: "Sürümler", + versionsSubtitle: "Sürüme göre etkin ve yeni kurulumlar.", + platformsTitle: "Platformlar", + platformsSubtitle: "İşletim sistemi ve CPU mimarisi dağılımı.", + channelsTitle: "Kurulum kanalları", + channelsSubtitle: "Proxy'lerin nasıl kurulduğu.", + colVersion: "Sürüm", + colPlatform: "Platform", + colChannel: "Kanal", + colInstalls: "Kurulum", + colNewInstalls: "Yeni", + colInstallShare: "Pay", + reliabilityTitle: "Güvenilirlik ve sonuçlar", + reliabilitySubtitle: + "İstek tamamlanma oranı ve başlıca kapalı sözlük hata nedenleri.", + completionRateLabel: "Tamamlanma oranı", + outcomeCompleted: "Tamamlandı", + outcomeIncomplete: "Eksik", + outcomeAborted: "İptal edildi", + outcomeFailed: "Başarısız", + topErrorsTitle: "Başlıca hata nedenleri", + colErrorCause: "Hata nedeni", + colErrorCount: "Sayı", + noErrorsRecorded: "Bu aralıkta hata nedeni kaydedilmedi.", + downloadsTitle: "Paket ve sürüm indirmeleri", + downloadsSubtitle: + "npm (@bitkyc08/opencodex) ve GitHub sürüm varlıklarından (lidge-jun/opencodex) canlı indirme sayıları.", + npmWeeklyLabel: "npm (son 7 gün)", + npmMonthlyLabel: "npm (son 30 gün)", + npmYearlyLabel: "npm (son 365 gün)", + githubTotalLabel: "GitHub sürüm varlıkları (toplam)", + githubLatestLabel: "Son sürüm varlıkları", + downloadsPartial: "kısmi", + downloadsUnavailable: "Kullanılamıyor", + noDataInTable: "Bu aralık için veri bildirilmedi.", + creditPrefix: + "Görsel tasarım ve katkı ısı haritası desenleri şuradan uyarlanmıştır:", + creditSuffix: "(MIT Lisansı).", + }, +}; + +export function resolveTelemetryDashboardLocale( + raw: string | null | undefined, +): TelemetryDashboardLocale { + if (!raw) return "en"; + const normalized = raw.trim().toLowerCase(); + if ((TELEMETRY_DASHBOARD_LOCALES as readonly string[]).includes(normalized)) { + return normalized as TelemetryDashboardLocale; + } + return "en"; +} + +export function getTelemetryDashboardCopy( + locale: string | null | undefined, +): TelemetryDashboardCopy { + return TELEMETRY_DASHBOARD_STRINGS[resolveTelemetryDashboardLocale(locale)]; +} diff --git a/scripts/test-layout/layout-usage.json b/scripts/test-layout/layout-usage.json index ff7ef11debc..c5103da4a5f 100644 --- a/scripts/test-layout/layout-usage.json +++ b/scripts/test-layout/layout-usage.json @@ -18,5 +18,6 @@ "usage-telemetry-collector-typecheck.test.ts": "usage", "usage-telemetry-collector-privacy.test.ts": "usage", "server-telemetry-routes.test.ts": "server", - "cli-telemetry.test.ts": "cli" + "cli-telemetry.test.ts": "cli", + "docs-telemetry-dashboard.test.ts": "ci-workflows" } diff --git a/tests/ci-workflows/docs-telemetry-dashboard.test.ts b/tests/ci-workflows/docs-telemetry-dashboard.test.ts new file mode 100644 index 00000000000..88974ef6d1a --- /dev/null +++ b/tests/ci-workflows/docs-telemetry-dashboard.test.ts @@ -0,0 +1,1099 @@ +import { describe, expect, test } from "bun:test"; +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import pkg from "../../package.json"; +import { + INSTALL_CHANNELS, + MODEL_FAMILIES, + PROVIDER_KINDS, + REQUEST_FAILURE_CAUSES, +} from "../../src/telemetry/contract"; +import type { + TelemetryStatsRange as CollectorStatsRange, + TelemetryStatsV1 as CollectorStatsV1, +} from "../../services/telemetry-collector/src/types"; +import { + DEFAULT_TELEMETRY_RANGE, + GITHUB_RELEASES_URL, + GITHUB_REPO_SLUG, + HEATMAP_GRADE_COLORS, + NPM_DOWNLOADS_WEEKLY_URL, + NPM_PACKAGE_NAME, + TELEMETRY_GUIDE_PATH, + TELEMETRY_RANGES, + TELEMETRY_STATS_ENDPOINT, + TOKSCALE_REPO_URL, + buildChannelRows, + buildDailyChartAccessibleTable, + buildDailyProviderChart, + buildHeatmapCalendar, + buildKpiStrip, + buildModelShareRows, + buildPlatformRows, + buildProviderShareRows, + buildReliabilitySummary, + buildStatsUrl, + buildTokenMix, + buildVersionRows, + classifyStatsResponse, + computeHeatmapGrade, + createEmptyStatsSnapshot, + createStatsRequestSequencer, + fetchGithubReleasesPages, + fetchJsonWithTimeout, + formatActiveHours, + formatAvgDailyActiveInstalls, + formatCompactNumber, + formatHeatmapCellAriaLabel, + formatLocalizedMonthShort, + getHeatmapGradeColor, + getLocalizedWeekdayLabels, + getStatsRangeDays, + isStatsSnapshotEmpty, + isValidTelemetryStatsV1, + navigateHeatmapDate, + parseDownloadsPayload, + parseGithubNextLink, + parseStatsRangeParam, + resolveAvgDailyActiveInstalls, + type TelemetryStatsRange as DashboardStatsRange, + type TelemetryStatsV1 as DashboardStatsV1, +} from "../../docs-site/src/components/telemetry-dashboard/index"; +import { mountTelemetryDashboard } from "../../docs-site/src/components/telemetry-dashboard/client"; +import { + TELEMETRY_DASHBOARD_LOCALES, + TELEMETRY_DASHBOARD_STRINGS, + getTelemetryDashboardCopy, + resolveTelemetryDashboardLocale, +} from "../../docs-site/src/data/telemetry-dashboard-i18n"; +import { repoPath } from "../helpers/repo-root"; + +type NormalizeCollectorStatsForParity = Omit< + T, + "totals" +> & { + totals: Omit & { + avgDailyActiveInstalls?: number; + }; +}; + +type AssertExactType = [A] extends [B] + ? [B] extends [A] + ? true + : never + : never; + +const _assertStatsParity: AssertExactType< + NormalizeCollectorStatsForParity, + NormalizeCollectorStatsForParity +> = true; +const _assertCollectorAssignableToDashboard: [CollectorStatsV1] extends [DashboardStatsV1] + ? true + : never = true; +const _assertRangeParity: AssertExactType = true; + +function makeFixtureStats(overrides: Partial = {}): DashboardStatsV1 { + const heatmap: DashboardStatsV1["heatmap"] = []; + const startMs = Date.UTC(2025, 10, 9); // 2025-11-09 .. 2026-11-08 (365 days) + for (let i = 0; i < 365; i++) { + const day = new Date(startMs + i * 86_400_000).toISOString().slice(0, 10); + const activeInstalls = + i === 364 + ? 400 + : i === 300 + ? 250 + : i === 200 + ? 120 + : i % 5 === 0 + ? 40 + : 0; + heatmap.push({ day, activeInstalls }); + } + + return { + schemaVersion: 1, + generatedAt: "2026-11-08T00:00:00.000Z", + range: "30d", + totals: { + activeInstalls: 4_820, + avgDailyActiveInstalls: 160.7, + newInstalls: 640, + activeMinutes: 187_500, + requests: 1_250_000, + tokens: { + input: 450_000_000, + cacheRead: 1_200_000_000, + cacheWrite: 150_000_000, + output: 120_000_000, + reasoning: 80_000_000, + }, + }, + daily: [ + { + day: "2026-11-06", + activeInstalls: 180, + newInstalls: 24, + activeMinutes: 6_200, + requests: 42_000, + failed: 420, + tokens: 68_000_000, + }, + { + day: "2026-11-07", + activeInstalls: 210, + newInstalls: 31, + activeMinutes: 7_500, + requests: 51_000, + failed: 380, + tokens: 82_000_000, + }, + { + day: "2026-11-08", + activeInstalls: 400, + newInstalls: 45, + activeMinutes: 9_600, + requests: 64_000, + failed: 510, + tokens: 110_000_000, + }, + ], + dailyByProvider: [ + { day: "2026-11-06", providerKind: "anthropic", requests: 22_000, tokens: 38_000_000 }, + { day: "2026-11-06", providerKind: "openai", requests: 14_000, tokens: 20_000_000 }, + { day: "2026-11-06", providerKind: "google", requests: 6_000, tokens: 10_000_000 }, + { day: "2026-11-07", providerKind: "anthropic", requests: 28_000, tokens: 46_000_000 }, + { day: "2026-11-07", providerKind: "openai", requests: 16_000, tokens: 24_000_000 }, + { day: "2026-11-07", providerKind: "google", requests: 7_000, tokens: 12_000_000 }, + { day: "2026-11-08", providerKind: "anthropic", requests: 34_000, tokens: 60_000_000 }, + { day: "2026-11-08", providerKind: "openai", requests: 20_000, tokens: 32_000_000 }, + { day: "2026-11-08", providerKind: "google", requests: 10_000, tokens: 18_000_000 }, + ], + heatmap, + providers: [ + { providerKind: PROVIDER_KINDS[2]!, requests: 640_000, tokens: 1_100_000_000 }, + { providerKind: PROVIDER_KINDS[0]!, requests: 410_000, tokens: 620_000_000 }, + { providerKind: PROVIDER_KINDS[4]!, requests: 200_000, tokens: 280_000_000 }, + ], + models: [ + { modelFamily: MODEL_FAMILIES[6]!, requests: 520_000, tokens: 900_000_000 }, + { modelFamily: MODEL_FAMILIES[1]!, requests: 410_000, tokens: 620_000_000 }, + { modelFamily: MODEL_FAMILIES[8]!, requests: 200_000, tokens: 280_000_000 }, + { modelFamily: MODEL_FAMILIES[5]!, requests: 120_000, tokens: 200_000_000 }, + ], + versions: [ + { version: "2.99.0", installs: 3_100, newInstalls: 420 }, + { version: "2.98.0", installs: 1_220, newInstalls: 150 }, + { version: "2.97.0", installs: 500, newInstalls: 70 }, + ], + platforms: [ + { os: "darwin", arch: "arm64", installs: 2_600 }, + { os: "linux", arch: "x64", installs: 1_420 }, + { os: "win32", arch: "x64", installs: 800 }, + ], + channels: [ + { installChannel: INSTALL_CHANNELS[0]!, installs: 2_400, newInstalls: 310 }, + { installChannel: INSTALL_CHANNELS[2]!, installs: 1_420, newInstalls: 190 }, + { installChannel: INSTALL_CHANNELS[5]!, installs: 1_000, newInstalls: 140 }, + ], + outcomes: { + completed: 1_210_000, + incomplete: 18_000, + aborted: 9_500, + failed: 12_500, + }, + errors: [ + { cause: REQUEST_FAILURE_CAUSES[3]!, count: 6_200 }, + { cause: REQUEST_FAILURE_CAUSES[11]!, count: 3_800 }, + { cause: REQUEST_FAILURE_CAUSES[4]!, count: 2_500 }, + ], + ...overrides, + }; +} + +describe("docs telemetry dashboard route and navigation", () => { + test("dashboard.mdx exists as a splash page mounting TelemetryDashboard.astro", () => { + expect(_assertStatsParity).toBe(true); + expect(_assertCollectorAssignableToDashboard).toBe(true); + expect(_assertRangeParity).toBe(true); + + const mdxPath = repoPath("docs-site/src/content/docs/dashboard.mdx"); + expect(existsSync(mdxPath)).toBe(true); + const mdx = readFileSync(mdxPath, "utf8"); + expect(mdx).toContain("template: splash"); + expect(mdx).toContain("editUrl: false"); + expect(mdx).toContain("lastUpdated: false"); + expect(mdx).toContain("import TelemetryDashboard from '../../components/TelemetryDashboard.astro'"); + expect(mdx).toContain(""); + }); + + test("Header.astro links /dashboard/ in both the desktop nav and the splash mobile menu", () => { + const header = readFileSync(repoPath("docs-site/src/components/Header.astro"), "utf8"); + expect(header).toContain("href: `${prefix}dashboard/`"); + const desktopMatches = header.match(/href={dashboard.href}/g) ?? []; + expect(desktopMatches.length).toBeGreaterThanOrEqual(2); + expect(header).toContain('{dashboard.label}'); + expect(header).toContain('{dashboard.label}'); + expect(header).toContain("flex-shrink: 0;"); + }); + + test("constants match collector endpoint, root package.json name, GitHub repo, guide link, and tokscale credit", () => { + expect(TELEMETRY_STATS_ENDPOINT).toBe("https://telemetry.opencodex.me/v1/stats"); + expect(TELEMETRY_RANGES).toEqual(["7d", "30d", "90d", "365d"]); + expect(DEFAULT_TELEMETRY_RANGE).toBe("30d"); + expect(NPM_PACKAGE_NAME).toBe(pkg.name); + expect(NPM_DOWNLOADS_WEEKLY_URL).toContain(pkg.name); + expect(GITHUB_REPO_SLUG).toBe("lidge-jun/opencodex"); + expect(GITHUB_RELEASES_URL).toContain("lidge-jun/opencodex"); + expect(GITHUB_RELEASES_URL).toContain("per_page=100"); + expect(TOKSCALE_REPO_URL).toBe("https://github.com/junhoyeo/tokscale"); + expect(TELEMETRY_GUIDE_PATH).toBe("guides/telemetry/"); + + const guidePath = repoPath("docs-site/src/content/docs/guides/telemetry.md"); + expect(existsSync(guidePath)).toBe(true); + const guideMd = readFileSync(guidePath, "utf8"); + expect(guideMd).toContain("(/dashboard/)"); + + const astroSource = readFileSync( + repoPath("docs-site/src/components/TelemetryDashboard.astro"), + "utf8", + ); + expect(astroSource).toContain('import.meta.glob("../content/docs/guides/telemetry.{md,mdx}")'); + expect(astroSource).not.toContain("TELEMETRY_GUIDE_LINK_ENABLED"); + }); +}); + +describe("telemetry dashboard formatting and bucketing helpers", () => { + test("formatCompactNumber formats K/M/B/T and promotes boundary values without 1000.0K artifacts", () => { + expect(formatCompactNumber(-5)).toBe("0"); + expect(formatCompactNumber(0)).toBe("0"); + expect(formatCompactNumber(999)).toBe("999"); + expect(formatCompactNumber(1_250)).toBe("1.3K"); + expect(formatCompactNumber(999_960)).toBe("1.0M"); + expect(formatCompactNumber(1_250_000)).toBe("1.3M"); + expect(formatCompactNumber(999_960_000)).toBe("1.0B"); + expect(formatCompactNumber(2_000_000_000)).toBe("2.0B"); + expect(formatCompactNumber(1_500_000_000_000)).toBe("1.5T"); + }); + + test("buildKpiStrip and buildTokenMix format and bucket a fixture TelemetryStatsV1", () => { + const fixture = makeFixtureStats(); + const kpis = buildKpiStrip(fixture, "en-US"); + expect(kpis).toEqual([ + { + id: "activeInstalls", + rawValue: 160.7, + compact: "160.7", + exactTitle: "160.7 (4,820 install-days)", + }, + { id: "newInstalls", rawValue: 640, compact: "640", exactTitle: "640" }, + { id: "requests", rawValue: 1_250_000, compact: "1.3M", exactTitle: "1,250,000" }, + { id: "tokens", rawValue: 2_000_000_000, compact: "2.0B", exactTitle: "2,000,000,000" }, + { + id: "activeHours", + rawValue: 187_500, + compact: "3.1Kh", + exactTitle: "3,125h (187,500 min)", + }, + ]); + + expect(formatActiveHours(0)).toEqual({ + hours: 0, + compact: "0h", + exactTitle: "0h (0 min)", + }); + expect(formatAvgDailyActiveInstalls(0, 0)).toEqual({ + avg: 0, + compact: "0", + exactTitle: "0 (0 install-days)", + }); + expect(getStatsRangeDays("7d")).toBe(7); + expect(getStatsRangeDays("30d")).toBe(30); + expect(getStatsRangeDays("90d")).toBe(90); + expect(getStatsRangeDays("365d")).toBe(365); + expect(getStatsRangeDays("unknown")).toBe(30); + + // Explicit avgDailyActiveInstalls from newer collector takes precedence + const withExplicitAvg = makeFixtureStats({ + totals: { + ...fixture.totals, + avgDailyActiveInstalls: 42.5, + }, + }); + expect(resolveAvgDailyActiveInstalls(withExplicitAvg)).toBe(42.5); + expect(buildKpiStrip(withExplicitAvg, "en-US")[0]).toEqual({ + id: "activeInstalls", + rawValue: 42.5, + compact: "42.5", + exactTitle: "42.5 (4,820 install-days)", + }); + + // Older collector without avgDailyActiveInstalls computes activeInstalls / days rounded to 1 decimal + const withoutExplicitAvg = makeFixtureStats({ + totals: { + ...fixture.totals, + avgDailyActiveInstalls: undefined, + }, + }); + expect(resolveAvgDailyActiveInstalls(withoutExplicitAvg)).toBe(160.7); + expect(buildKpiStrip(withoutExplicitAvg, "en-US")[0]).toEqual({ + id: "activeInstalls", + rawValue: 160.7, + compact: "160.7", + exactTitle: "160.7 (4,820 install-days)", + }); + expect( + resolveAvgDailyActiveInstalls({ + range: "7d", + totals: { ...fixture.totals, activeInstalls: 100, avgDailyActiveInstalls: undefined }, + }), + ).toBe(14.3); + + const mix = buildTokenMix(fixture.totals.tokens, "en-US"); + expect(mix.totalTokens).toBe(2_000_000_000); + expect(mix.totalCompact).toBe("2.0B"); + expect(mix.totalExactTitle).toBe("2,000,000,000"); + expect(mix.segments.map((s) => s.id)).toEqual([ + "input", + "cacheRead", + "cacheWrite", + "output", + "reasoning", + ]); + const sumValues = mix.segments.reduce((acc, s) => acc + s.value, 0); + const sumPct = mix.segments.reduce((acc, s) => acc + s.percent, 0); + expect(sumValues).toBe(mix.totalTokens); + expect(sumPct).toBeCloseTo(100, 5); + expect(mix.segments[1]).toMatchObject({ + id: "cacheRead", + compact: "1.2B", + exactTitle: "1,200,000,000", + percentLabel: "60.0%", + }); + }); + + test("parseStatsRangeParam and buildStatsUrl handle valid and fallback ranges", () => { + expect(parseStatsRangeParam("7d")).toBe("7d"); + expect(parseStatsRangeParam("30d")).toBe("30d"); + expect(parseStatsRangeParam("90d")).toBe("90d"); + expect(parseStatsRangeParam("365d")).toBe("365d"); + expect(parseStatsRangeParam("invalid")).toBe("30d"); + expect(parseStatsRangeParam(null)).toBe("30d"); + expect(buildStatsUrl("90d")).toBe("https://telemetry.opencodex.me/v1/stats?range=90d"); + }); + + test("computeHeatmapGrade and buildHeatmapCalendar grade and lay out 365 days into 52/53 weeks", () => { + expect(computeHeatmapGrade(0, 400)).toBe(0); + expect(computeHeatmapGrade(50, 0)).toBe(0); + expect(computeHeatmapGrade(1, 400)).toBe(1); + expect(computeHeatmapGrade(99, 400)).toBe(1); + expect(computeHeatmapGrade(100, 400)).toBe(2); + expect(computeHeatmapGrade(199, 400)).toBe(2); + expect(computeHeatmapGrade(200, 400)).toBe(3); + expect(computeHeatmapGrade(299, 400)).toBe(3); + expect(computeHeatmapGrade(300, 400)).toBe(4); + expect(computeHeatmapGrade(400, 400)).toBe(4); + + for (const grade of [0, 1, 2, 3, 4] as const) { + expect(getHeatmapGradeColor("light", grade)).toBe(HEATMAP_GRADE_COLORS.light[grade]); + expect(getHeatmapGradeColor("dark", grade)).toBe(HEATMAP_GRADE_COLORS.dark[grade]); + } + + const fixture = makeFixtureStats(); + const calendar = buildHeatmapCalendar(fixture.heatmap); + expect(calendar.weekCount).toBeGreaterThanOrEqual(52); + expect(calendar.weekCount).toBeLessThanOrEqual(54); + expect(calendar.cells.length).toBe(calendar.weekCount * 7); + expect(calendar.startDate).toBe("2025-11-09"); + expect(calendar.endDate).toBe("2026-11-08"); + expect(calendar.maxActiveInstalls).toBe(400); + expect(calendar.peakDay).toEqual({ date: "2026-11-08", activeInstalls: 400 }); + expect(calendar.activeDays).toBeGreaterThan(70); + expect(calendar.monthMarkers.length).toBeGreaterThanOrEqual(10); + + const selectable = calendar.cells.filter((c) => c.selectable); + expect(selectable.length).toBe(365); + const peakCell = selectable.find((c) => c.date === "2026-11-08")!; + expect(peakCell.grade).toBe(4); + expect(formatHeatmapCellAriaLabel(peakCell, "en-US")).toBe( + "2026-11-08: 400 active installs (grade 4)", + ); + expect(formatHeatmapCellAriaLabel(peakCell, "ko", "활성 설치")).toBe( + "2026-11-08: 400 활성 설치 (4/4)", + ); + expect(formatLocalizedMonthShort(0, "en-US")).toBe("Jan"); + expect(getLocalizedWeekdayLabels("en-US")).toEqual({ + mon: "Mon", + wed: "Wed", + fri: "Fri", + }); + + // Keyboard navigation across selectable days + expect(navigateHeatmapDate(calendar.cells, "2026-11-08", "Home")).toBe("2025-11-09"); + expect(navigateHeatmapDate(calendar.cells, "2025-11-09", "End")).toBe("2026-11-08"); + expect(navigateHeatmapDate(calendar.cells, "2026-11-08", "ArrowUp")).toBe("2026-11-07"); + expect(navigateHeatmapDate(calendar.cells, "2026-11-08", "ArrowLeft")).toBe("2026-11-01"); + expect(navigateHeatmapDate(calendar.cells, "2025-11-09", "ArrowDown")).toBe("2025-11-10"); + }); + + test("buildDailyProviderChart, share tables, install tables, and reliability summary bucket fixture data", () => { + const fixture = makeFixtureStats(); + const chartTokens = buildDailyProviderChart( + fixture.dailyByProvider, + fixture.daily, + "tokens", + 2, + ); + expect(chartTokens.days).toEqual(["2026-11-06", "2026-11-07", "2026-11-08"]); + // Top 2 providers ("anthropic", "openai") plus folded "other" ("google") + expect(chartTokens.series.map((s) => s.providerKind)).toEqual([ + "anthropic", + "openai", + "other", + ]); + expect(chartTokens.series[0]?.values).toEqual([38_000_000, 46_000_000, 60_000_000]); + expect(chartTokens.series[2]?.values).toEqual([10_000_000, 12_000_000, 18_000_000]); + + const srTable = buildDailyChartAccessibleTable(chartTokens, "en-US"); + expect(srTable.length).toBe(9); + expect(srTable[0]).toEqual({ + day: "2026-11-06", + providerKind: "anthropic", + value: 38_000_000, + valueCompact: "38.0M", + valueExact: "38,000,000", + }); + + const providerRows = buildProviderShareRows(fixture.providers); + expect(providerRows[0]).toMatchObject({ + key: "anthropic", + requestsCompact: "640.0K", + tokensCompact: "1.1B", + tokenShareLabel: "55.0%", + }); + + const modelRows = buildModelShareRows(fixture.models); + expect(modelRows[0]).toMatchObject({ + key: "claude-sonnet", + tokensCompact: "900.0M", + tokenShareLabel: "45.0%", + }); + + const versionRows = buildVersionRows(fixture.versions); + expect(versionRows[0]).toMatchObject({ + key: "2.99.0", + label: "v2.99.0", + installsCompact: "3.1K", + newInstallsCompact: "420", + }); + + const platformRows = buildPlatformRows(fixture.platforms); + expect(platformRows[0]).toMatchObject({ + key: "darwin/arm64", + installsCompact: "2.6K", + }); + + const channelRows = buildChannelRows(fixture.channels); + expect(channelRows[0]).toMatchObject({ + key: "npm-global", + installsCompact: "2.4K", + newInstallsCompact: "310", + }); + + const reliability = buildReliabilitySummary(fixture.outcomes, fixture.errors); + expect(reliability.totalRequests).toBe(1_250_000); + expect(reliability.completedRatioLabel).toBe("96.8%"); + expect(reliability.totalErrors).toBe(12_500); + expect(reliability.errors[0]).toMatchObject({ + cause: "rate-limit", + compact: "6.2K", + shareLabel: "49.6%", + }); + }); + + test("table and state helpers filter out partial or malformed array entries without throwing", () => { + const malformedStats = makeFixtureStats({ + heatmap: [ + null as unknown as DashboardStatsV1["heatmap"][number], + {} as unknown as DashboardStatsV1["heatmap"][number], + { day: "2026-11-08", activeInstalls: 12 }, + ], + providers: [ + null as unknown as DashboardStatsV1["providers"][number], + { providerKind: null as unknown as string, requests: 100, tokens: 500 }, + { providerKind: " ", requests: 100, tokens: 500 }, + { providerKind: "anthropic", requests: 50, tokens: 1_000 }, + ], + models: [ + undefined as unknown as DashboardStatsV1["models"][number], + { modelFamily: 123 as unknown as string, requests: 10, tokens: 100 }, + { modelFamily: "claude-opus", requests: 25, tokens: 2_000 }, + ], + versions: [ + null as unknown as DashboardStatsV1["versions"][number], + { version: undefined as unknown as string, installs: 10, newInstalls: 1 }, + { version: "2.99.0", installs: 40, newInstalls: 5 }, + ], + platforms: [ + null as unknown as DashboardStatsV1["platforms"][number], + { os: "darwin", arch: null as unknown as string, installs: 10 }, + { os: null as unknown as string, arch: "arm64", installs: 10 }, + { os: "darwin", arch: "arm64", installs: 30 }, + ], + channels: [ + {} as unknown as DashboardStatsV1["channels"][number], + { installChannel: "", installs: 5, newInstalls: 1 }, + { installChannel: "npm-global", installs: 20, newInstalls: 3 }, + ], + errors: [ + null as unknown as DashboardStatsV1["errors"][number], + { cause: null as unknown as string, count: 10 }, + { cause: " ", count: 10 }, + { cause: "rate-limit", count: 4 }, + ], + }); + + expect(isStatsSnapshotEmpty(malformedStats)).toBe(false); + expect(buildProviderShareRows(malformedStats.providers).map((r) => r.key)).toEqual(["anthropic"]); + expect(buildModelShareRows(malformedStats.models).map((r) => r.key)).toEqual(["claude-opus"]); + expect(buildVersionRows(malformedStats.versions).map((r) => r.key)).toEqual(["2.99.0"]); + expect(buildPlatformRows(malformedStats.platforms).map((r) => r.key)).toEqual(["darwin/arm64"]); + expect(buildChannelRows(malformedStats.channels).map((r) => r.key)).toEqual(["npm-global"]); + expect(buildReliabilitySummary(malformedStats.outcomes, malformedStats.errors).errors.map((e) => e.cause)).toEqual([ + "rate-limit", + ]); + }); + + test("createStatsRequestSequencer, fetchJsonWithTimeout, and createEmptyStatsSnapshot guard range switches and timeouts", async () => { + const sequencer = createStatsRequestSequencer(5_000); + const t1 = sequencer.begin("30d"); + expect(t1.requestId).toBe(1); + expect(t1.isCurrent()).toBe(true); + expect(t1.signal.aborted).toBe(false); + + // Switching range to 7d aborts in-flight 30d request and invalidates its ticket + const t2 = sequencer.begin("7d"); + expect(t1.signal.aborted).toBe(true); + expect(t1.isCurrent()).toBe(false); + expect(t2.requestId).toBe(2); + expect(t2.isCurrent()).toBe(true); + expect(t2.signal.aborted).toBe(false); + + // Finishing stale t1 does not disturb active t2 + t1.finish(); + expect(t2.isCurrent()).toBe(true); + t2.finish(); + + // Timeout aborts active request and marks didTimeout() + const tTimeout = sequencer.begin("90d", 15); + await new Promise((r) => setTimeout(r, 35)); + expect(tTimeout.signal.aborted).toBe(true); + expect(tTimeout.didTimeout()).toBe(true); + tTimeout.finish(); + + // fetchJsonWithTimeout aborts hanging download request and returns null + const hangingFetch = ((_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new DOMException("Aborted", "AbortError")); + }); + })) as typeof fetch; + const timedOutResult = await fetchJsonWithTimeout( + "https://api.npmjs.org/downloads/point/last-week/@bitkyc08/opencodex", + 15, + hangingFetch, + ); + expect(timedOutResult).toBeNull(); + + // Empty snapshot resets stale range metrics cleanly + const emptySnap = createEmptyStatsSnapshot("7d"); + expect(emptySnap.range).toBe("7d"); + expect(isValidTelemetryStatsV1(emptySnap)).toBe(true); + expect(isStatsSnapshotEmpty(emptySnap)).toBe(true); + }); + + test("classifyStatsResponse and parseDownloadsPayload handle ready, empty, error, and download fallbacks", () => { + const fixture = makeFixtureStats(); + expect(isValidTelemetryStatsV1(fixture)).toBe(true); + expect(isStatsSnapshotEmpty(fixture)).toBe(false); + + const ready = classifyStatsResponse({ ok: true, status: 200, body: fixture }); + expect(ready.state).toBe("ready"); + expect(ready.reason).toBe("ok"); + + // Not deployed yet (HTTP 404 / 503) -> explicit empty state + const notDeployed = classifyStatsResponse({ ok: false, status: 404, body: null }); + expect(notDeployed).toEqual({ + state: "empty", + stats: null, + reason: "collector-not-deployed", + detail: "HTTP 404", + }); + + // Zeroed stats snapshot -> explicit empty state + const emptySnapshot = makeFixtureStats({ + totals: { + activeInstalls: 0, + newInstalls: 0, + activeMinutes: 0, + requests: 0, + tokens: { input: 0, cacheRead: 0, cacheWrite: 0, output: 0, reasoning: 0 }, + }, + heatmap: [{ day: "2026-11-08", activeInstalls: 0 }], + }); + expect(classifyStatsResponse({ ok: true, status: 200, body: emptySnapshot })).toMatchObject({ + state: "empty", + reason: "no-reports-yet", + }); + + // Server error / network error / invalid schema -> explicit error state + expect(classifyStatsResponse({ ok: false, status: 500, body: {} })).toMatchObject({ + state: "error", + reason: "http-error", + }); + expect( + classifyStatsResponse({ networkError: true, errorMessage: "Failed to fetch" }), + ).toMatchObject({ + state: "error", + reason: "network-error", + detail: "Failed to fetch", + }); + expect( + classifyStatsResponse({ ok: true, status: 200, body: { schemaVersion: 999 } }), + ).toMatchObject({ + state: "error", + reason: "invalid-payload", + }); + + // Downloads parser with live-shaped payloads and graceful null fallback + const dl = parseDownloadsPayload({ + npmWeekly: { downloads: 18_420 }, + npmMonthly: { downloads: 74_900 }, + npmYearly: { downloads: 612_300 }, + githubReleases: [ + { + tag_name: "v2.99.0", + draft: false, + assets: [{ download_count: 1_200 }, { download_count: 850 }], + }, + { + tag_name: "v2.98.0", + draft: false, + assets: [{ download_count: 3_400 }], + }, + { + tag_name: "v3.0.0-draft", + draft: true, + assets: [{ download_count: 99_999 }], + }, + ], + }); + expect(dl).toEqual({ + npmWeekly: 18_420, + npmMonthly: 74_900, + npmYearly: 612_300, + githubTotalDownloads: 5_450, + githubDownloadsPartial: false, + githubLatestTag: "v2.99.0", + githubLatestDownloads: 2_050, + githubReleaseCount: 2, + }); + + // A preview published after a stable release counts toward totals but is not "the latest release". + expect( + parseDownloadsPayload({ + githubReleases: [ + { tag_name: "v3.0.0-preview.20261108", draft: false, prerelease: true, assets: [{ download_count: 7 }] }, + { tag_name: "v2.99.0", draft: false, prerelease: false, assets: [{ download_count: 5 }] }, + ], + }), + ).toMatchObject({ githubLatestTag: "v2.99.0", githubLatestDownloads: 5, githubTotalDownloads: 12, githubReleaseCount: 2 }); + + expect(parseDownloadsPayload({})).toEqual({ + npmWeekly: null, + npmMonthly: null, + npmYearly: null, + githubTotalDownloads: null, + githubDownloadsPartial: false, + githubLatestTag: null, + githubLatestDownloads: null, + githubReleaseCount: 0, + }); + + expect( + parseGithubNextLink( + '; rel="next", ; rel="last"', + GITHUB_RELEASES_URL, + ), + ).toBe( + "https://api.github.com/repos/lidge-jun/opencodex/releases?per_page=100&page=2", + ); + expect( + parseGithubNextLink( + '; rel="next"', + GITHUB_RELEASES_URL, + ), + ).toBeNull(); + expect(parseGithubNextLink(null)).toBeNull(); + }); + + test("all 8 site locales carry complete dashboard copy and component markup contains no secrets", () => { + expect(TELEMETRY_DASHBOARD_LOCALES).toEqual([ + "en", + "fr", + "ko", + "zh-cn", + "zh-tw", + "ru", + "ja", + "tr", + ]); + const enKeys = Object.keys(TELEMETRY_DASHBOARD_STRINGS.en).sort(); + const optInPattern = /\bopt(?:ed)?[-\s]?in\b|オプトイン|참여에\s*동의한/i; + const hangulPattern = /[\uac00-\ud7af\u1100-\u11ff\u3130-\u318f]/; + for (const locale of TELEMETRY_DASHBOARD_LOCALES) { + const copy = getTelemetryDashboardCopy(locale); + expect(Object.keys(copy).sort()).toEqual(enKeys); + for (const [k, v] of Object.entries(copy)) { + expect(typeof v, `${locale}.${k}`).toBe("string"); + expect(v.trim().length, `${locale}.${k} non-empty`).toBeGreaterThan(0); + expect(optInPattern.test(v), `${locale}.${k} must not claim opt-in`).toBe(false); + if (locale !== "ko") { + expect(hangulPattern.test(v), `${locale}.${k} must not contain Hangul`).toBe(false); + } + } + if (locale !== "en") { + expect(copy.stateEmptyTitle).not.toContain("Collector not deployed yet"); + } + } + expect(TELEMETRY_DASHBOARD_STRINGS.en.subtitle).toContain("on by default"); + expect(TELEMETRY_DASHBOARD_STRINGS.en.subtitle).toContain("privacy notice"); + expect(TELEMETRY_DASHBOARD_STRINGS.en.kpiActiveInstalls).toBe( + "Avg daily active installs", + ); + expect(TELEMETRY_DASHBOARD_STRINGS.en.subtitle.toLowerCase()).toContain( + "self-reported by opencodex installs", + ); + expect(resolveTelemetryDashboardLocale("KO")).toBe("ko"); + expect(resolveTelemetryDashboardLocale("unknown")).toBe("en"); + + const astroSource = readFileSync( + repoPath("docs-site/src/components/TelemetryDashboard.astro"), + "utf8", + ); + expect(astroSource).toContain('data-state-panel="loading"'); + expect(astroSource).toContain('data-state-panel="empty"'); + expect(astroSource).toContain('data-state-panel="error"'); + expect(astroSource).toContain("data-self-reported-label"); + expect(astroSource).toContain("data-daily-chart-table"); + expect(astroSource).toContain("var(--sl-color-gray-3)"); + expect(astroSource).toContain("junhoyeo/tokscale"); + + const clientSource = readFileSync( + repoPath("docs-site/src/components/telemetry-dashboard/client.ts"), + "utf8", + ); + expect(clientSource).toContain('aria-describedby", "td-heatmap-tooltip"'); + + const helperDir = repoPath("docs-site/src/components/telemetry-dashboard"); + const scannedFiles = [ + repoPath("docs-site/src/content/docs/dashboard.mdx"), + repoPath("docs-site/src/components/TelemetryDashboard.astro"), + repoPath("docs-site/src/data/telemetry-dashboard-i18n.ts"), + ...readdirSync(helperDir).map((name) => repoPath("docs-site/src/components/telemetry-dashboard", name)), + ]; + const secretPattern = /(?:sk-[a-zA-Z0-9]{20,}|ghp_[a-zA-Z0-9]{20,}|AIza[0-9A-Za-z-_]{35}|Bearer\s+[A-Za-z0-9._-]{20,})/; + for (const file of scannedFiles) { + const text = readFileSync(file, "utf8"); + expect(secretPattern.test(text), `secret pattern in ${file}`).toBe(false); + } + }); + + test("D15 breakdown tables format 'other' rows without a 'v' prefix and sort 'other' last", () => { + const versionRows = buildVersionRows([ + { version: "other", installs: 900, newInstalls: 50 }, + { version: "2.99.0", installs: 500, newInstalls: 40 }, + { version: "2.98.0", installs: 300, newInstalls: 20 }, + ]); + expect(versionRows.map((r) => r.label)).toEqual(["v2.99.0", "v2.98.0", "other"]); + + const platformRows = buildPlatformRows([ + { os: "other", arch: "other", installs: 800 }, + { os: "other", arch: "", installs: 50 }, + { os: "darwin", arch: "arm64", installs: 600 }, + ]); + expect(platformRows.map((r) => ({ key: r.key, installs: r.installs }))).toEqual([ + { key: "darwin/arm64", installs: 600 }, + { key: "other", installs: 850 }, + ]); + + const providerRows = buildProviderShareRows([ + { providerKind: "other", requests: 900, tokens: 9_000_000 }, + { providerKind: "anthropic", requests: 500, tokens: 5_000_000 }, + ]); + expect(providerRows.map((r) => r.key)).toEqual(["anthropic", "other"]); + + const modelRows = buildModelShareRows([ + { modelFamily: "other", requests: 900, tokens: 9_000_000 }, + { modelFamily: "claude-opus", requests: 500, tokens: 5_000_000 }, + ]); + expect(modelRows.map((r) => r.key)).toEqual(["claude-opus", "other"]); + + const channelRows = buildChannelRows([ + { installChannel: "other", installs: 900, newInstalls: 50 }, + { installChannel: "npm-global", installs: 500, newInstalls: 40 }, + ]); + expect(channelRows.map((r) => r.key)).toEqual(["npm-global", "other"]); + + const reliability = buildReliabilitySummary( + { completed: 100, failed: 20, incomplete: 0, aborted: 0 }, + [ + { cause: "other", count: 15 }, + { cause: "rate-limit", count: 5 }, + ], + ); + expect(reliability.errors.map((e) => e.cause)).toEqual(["rate-limit", "other"]); + + const dailyChart = buildDailyProviderChart( + [ + { day: "2026-11-08", providerKind: "other", requests: 500, tokens: 9_000_000 }, + { day: "2026-11-08", providerKind: "anthropic", requests: 200, tokens: 3_000_000 }, + ], + [{ day: "2026-11-08", activeInstalls: 50, newInstalls: 5, activeMinutes: 100, requests: 700, failed: 0, tokens: 12_000_000 }], + "tokens", + 6, + ); + expect(dailyChart.series.map((s) => s.providerKind)).toEqual(["anthropic", "other"]); + }); + + test("D20 mountTelemetryDashboard clears heatmap on empty snapshot and paginates GitHub release downloads up to 10 pages", async () => { + class MockElement { + tagName: string; + className = ""; + textContent = ""; + title = ""; + type = ""; + hidden = false; + disabled = false; + tabIndex = 0; + dataset: Record = {}; + attributes = new Map(); + styleProps = new Map(); + style: Record = { + setProperty: (k: string, v: string) => { + this.styleProps.set(k, v); + }, + }; + children: MockElement[] = []; + listeners = new Map void>>(); + constructor(tagName = "div", attrs: Record = {}) { + this.tagName = tagName.toLowerCase(); + for (const [k, v] of Object.entries(attrs)) { + this.setAttribute(k, v); + } + } + setAttribute(name: string, value: string) { + this.attributes.set(name, value); + if (name.startsWith("data-")) { + const camel = name + .slice(5) + .replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); + this.dataset[camel] = value; + } + } + getAttribute(name: string): string | null { + return this.attributes.get(name) ?? null; + } + append(...nodes: MockElement[]) { + this.children.push(...nodes); + } + appendChild(node: MockElement) { + this.children.push(node); + return node; + } + replaceChildren(...nodes: MockElement[]) { + this.children = [...nodes]; + } + addEventListener(type: string, cb: (ev?: unknown) => void) { + const list = this.listeners.get(type) ?? []; + list.push(cb); + this.listeners.set(type, list); + } + click() { + for (const cb of this.listeners.get("click") ?? []) cb({}); + } + focus() {} + matchesSelector(sel: string): boolean { + if (sel === "button[data-date]") { + return this.tagName === "button" && Boolean(this.dataset.date); + } + const attrMatch = /^\[([a-z0-9-]+)(?:="([^"]*)")?\]$/i.exec(sel.trim()); + if (!attrMatch) return false; + const [, attrName, attrVal] = attrMatch; + if (!attrName) return false; + if (attrVal === undefined) { + if (attrName.startsWith("data-")) { + const camel = attrName + .slice(5) + .replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); + return this.attributes.has(attrName) || camel in this.dataset; + } + return this.attributes.has(attrName); + } + if (attrName.startsWith("data-")) { + const camel = attrName + .slice(5) + .replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); + return this.attributes.get(attrName) === attrVal || this.dataset[camel] === attrVal; + } + return this.attributes.get(attrName) === attrVal; + } + querySelectorAll(sel: string): MockElement[] { + const results: MockElement[] = []; + const visit = (node: MockElement) => { + for (const child of node.children) { + if (child.matchesSelector(sel)) results.push(child); + visit(child); + } + }; + visit(this); + return results; + } + querySelector(sel: string): MockElement | null { + return this.querySelectorAll(sel)[0] ?? null; + } + } + + const root = new MockElement("div", { + "data-telemetry-dashboard": "", + "data-locale": "en", + "data-stats-endpoint": "https://telemetry.opencodex.me/v1/stats", + "data-default-range": "30d", + "data-npm-weekly-url": "https://api.npmjs.org/downloads/point/last-week/@bitkyc08/opencodex", + "data-npm-monthly-url": "https://api.npmjs.org/downloads/point/last-month/@bitkyc08/opencodex", + "data-npm-yearly-url": "https://api.npmjs.org/downloads/point/last-year/@bitkyc08/opencodex", + "data-github-releases-url": GITHUB_RELEASES_URL, + "data-copy": JSON.stringify(TELEMETRY_DASHBOARD_STRINGS.en), + }); + const gridEl = new MockElement("div", { "data-heatmap-grid": "" }); + const monthsEl = new MockElement("div", { "data-heatmap-months": "" }); + const activeDaysEl = new MockElement("span", { "data-heatmap-active-days": "" }); + const peakEl = new MockElement("span", { "data-heatmap-peak": "" }); + const rangeLabelEl = new MockElement("span", { "data-heatmap-range-label": "" }); + const tooltipEl = new MockElement("div", { "data-heatmap-tooltip": "" }); + const retryBtn = new MockElement("button", { "data-retry-btn": "" }); + const ghTotalEl = new MockElement("dd", { "data-dl-gh-total": "" }); + const ghLatestEl = new MockElement("dd", { "data-dl-gh-latest": "" }); + const ghLatestTagEl = new MockElement("span", { "data-dl-gh-latest-tag": "" }); + root.append( + gridEl, + monthsEl, + activeDaysEl, + peakEl, + rangeLabelEl, + tooltipEl, + retryBtn, + ghTotalEl, + ghLatestEl, + ghLatestTagEl, + ); + + const prevWindow = (globalThis as Record).window; + const prevDocument = (globalThis as Record).document; + const prevMutationObserver = (globalThis as Record).MutationObserver; + const prevFetch = globalThis.fetch; + + let statsReturnEmptyNext = false; + const fetchedUrls: string[] = []; + + try { + (globalThis as Record).window = { + location: { search: "", href: "https://opencodex.me/dashboard/" }, + history: { replaceState: () => {} }, + addEventListener: () => {}, + }; + (globalThis as Record).document = { + createElement: (tag: string) => new MockElement(tag), + documentElement: new MockElement("html"), + body: new MockElement("body"), + }; + (globalThis as Record).MutationObserver = class { + observe() {} + }; + globalThis.fetch = (async (input: RequestInfo | URL) => { + const url = String(input); + fetchedUrls.push(url); + if (url.includes("/v1/stats")) { + if (statsReturnEmptyNext) { + return new Response("Service Unavailable", { status: 503 }); + } + return new Response(JSON.stringify(makeFixtureStats()), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (new URL(url).hostname === "api.npmjs.org") { + return new Response(JSON.stringify({ downloads: 1_000 }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + if (new URL(url).hostname === "api.github.com") { + const parsed = new URL(url); + const page = Number(parsed.searchParams.get("page") ?? "1"); + const nextPage = page + 1; + const linkHeader = `; rel="next"`; + return new Response( + JSON.stringify([ + { + tag_name: `v2.${100 - page}.0`, + draft: false, + assets: [{ download_count: 100 }], + }, + ]), + { + status: 200, + headers: { + "content-type": "application/json", + link: linkHeader, + }, + }, + ); + } + return new Response("Not found", { status: 404 }); + }) as typeof fetch; + + mountTelemetryDashboard(root as unknown as HTMLElement); + await new Promise((r) => setTimeout(r, 25)); + + // After initial stats load, heatmap has 365 selectable [data-date] buttons + expect(gridEl.querySelectorAll("button[data-date]").length).toBe(365); + expect(rangeLabelEl.textContent).toBe("2025-11-09 → 2026-11-08 (UTC)"); + expect(peakEl.hidden).toBe(false); + + // Downloads followed pagination up to 10 pages (10 * 100 = 1,000) and marked partial + const ghReleaseFetches = fetchedUrls.filter((u) => new URL(u).hostname === "api.github.com"); + expect(ghReleaseFetches.length).toBe(10); + expect(ghTotalEl.dataset.partial).toBe("true"); + expect(ghTotalEl.textContent).toContain("partial"); + expect(ghTotalEl.title).toContain("1,000"); + expect(ghTotalEl.title).toContain("partial"); + + // Now trigger retry where collector returns 503 -> empty snapshot applied + statsReturnEmptyNext = true; + retryBtn.click(); + await new Promise((r) => setTimeout(r, 25)); + + // Heatmap must be cleared: 0 [data-date] buttons, range label reset, peak hidden + expect(gridEl.querySelectorAll("button[data-date]").length).toBe(0); + expect(gridEl.children.length).toBe(53 * 7); + expect(gridEl.children.every((c) => c.disabled && c.dataset.grade === "0")).toBe(true); + expect(monthsEl.children.length).toBe(0); + expect(rangeLabelEl.textContent).toBe("365d UTC"); + expect(peakEl.hidden).toBe(true); + } finally { + (globalThis as Record).window = prevWindow; + (globalThis as Record).document = prevDocument; + (globalThis as Record).MutationObserver = prevMutationObserver; + globalThis.fetch = prevFetch; + } + }); +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 0cf6336c97d..82a4c0450e4 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -175,7 +175,7 @@ "model-costs-prompt-length-pricing.test.ts": "server", "cli-models-prompt-length-pricing.test.ts": "cli", "usage-prompt-length-pricing.test.ts": "usage", - "telemetry-live-save.test.ts": "config", "usage-telemetry-client.test.ts": "usage", "usage-telemetry-core-boundary.test.ts": "usage", "usage-telemetry-collector-worker.test.ts": "usage", "usage-telemetry-collector-typecheck.test.ts": "usage", "usage-telemetry-collector-privacy.test.ts": "usage", "server-telemetry-routes.test.ts": "server", "cli-telemetry.test.ts": "cli", + "telemetry-live-save.test.ts": "config", "usage-telemetry-client.test.ts": "usage", "usage-telemetry-core-boundary.test.ts": "usage", "usage-telemetry-collector-worker.test.ts": "usage", "usage-telemetry-collector-typecheck.test.ts": "usage", "usage-telemetry-collector-privacy.test.ts": "usage", "docs-telemetry-dashboard.test.ts": "ci-workflows", "server-telemetry-routes.test.ts": "server", "cli-telemetry.test.ts": "cli", "messages-native-scope.test.ts": "claude-integration", "catalog-auto-review-row.test.ts": "codex-integration", "codex-use-remaining-quota.test.ts": "codex-integration", "cli-log-view-filter.test.ts": "cli",