Spaces:
Running
Running
| <html lang="en" class="dark"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>Copilot API Usage Dashboard</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| <link | |
| href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" | |
| rel="stylesheet" | |
| /> | |
| <script src="https://unpkg.com/lucide-react@0.378.0/dist/umd/lucide.min.js"></script> | |
| <style> | |
| /* Gruvbox-themed color palette */ | |
| :root { | |
| /* Main Color Palette */ | |
| --color-red: #cc241d; | |
| --color-green: #98971a; | |
| --color-yellow: #d79921; | |
| --color-blue: #458588; | |
| --color-purple: #b16286; | |
| --color-aqua: #689d6a; | |
| --color-orange: #d65d0e; | |
| --color-gray: #a89984; | |
| /* Accent/Lighter/Darker Shades of Main Colors */ | |
| --color-red-accent: #fb4934; | |
| --color-green-accent: #b8bb26; | |
| --color-yellow-accent: #fabd2f; | |
| --color-blue-accent: #83a598; | |
| --color-purple-accent: #d3869b; | |
| --color-aqua-accent: #8ec07c; | |
| --color-orange-accent: #fe8019; | |
| --color-gray-accent: #928374; | |
| /* Background Colors */ | |
| --color-bg-darkest: #1d2021; /* bg0_h */ | |
| --color-bg: #282828; /* bg and bg0 */ | |
| --color-bg-light-1: #3c3836; /* bg1 */ | |
| --color-bg-light-2: #504945; /* bg2 */ | |
| --color-bg-light-3: #665c54; /* bg3 */ | |
| --color-bg-light-4: #7c6f64; /* bg4 */ | |
| --color-bg-soft: #32302f; /* bg0_s */ | |
| /* Foreground Colors */ | |
| --color-fg-darker: #a89984; /* fg4 - duplicate of gray */ | |
| --color-fg-dark: #bdae93; /* fg3 */ | |
| --color-fg-medium: #d5c4a1; /* fg2 */ | |
| --color-fg-light: #ebdbb2; /* fg and fg1 */ | |
| --color-fg-lightest: #fbf1c7; /* fg0 */ | |
| } | |
| /* Custom styles using the new palette */ | |
| body { | |
| font-family: "Inter", sans-serif; | |
| background-color: var(--color-bg-darkest); | |
| color: var(--color-fg-light); | |
| } | |
| /* Custom progress bar styles */ | |
| .progress-bar-bg { | |
| background-color: var(--color-bg-light-1); | |
| } | |
| .progress-bar-fg { | |
| transition: width 0.5s ease-in-out; | |
| } | |
| /* Custom scrollbar for the raw data view */ | |
| .code-block::-webkit-scrollbar { | |
| width: 8px; | |
| height: 8px; | |
| } | |
| .code-block::-webkit-scrollbar-track { | |
| background: var(--color-bg); | |
| } | |
| .code-block::-webkit-scrollbar-thumb { | |
| background: var(--color-bg-light-3); | |
| } | |
| .code-block::-webkit-scrollbar-thumb:hover { | |
| background: var(--color-bg-light-4); | |
| } | |
| /* Style for focus rings to use variables */ | |
| .input-focus:focus { | |
| --tw-ring-color: var(--color-blue); | |
| border-color: var(--color-blue); | |
| } | |
| button:disabled, | |
| select:disabled { | |
| cursor: not-allowed; | |
| opacity: 0.55; | |
| } | |
| </style> | |
| </head> | |
| <body class="antialiased"> | |
| <div id="app" class="min-h-screen p-4 sm:p-6"> | |
| <div class="max-w-7xl mx-auto"> | |
| <!-- Header Section --> | |
| <header class="mb-6"> | |
| <h1 | |
| class="text-2xl font-bold flex items-center gap-2" | |
| style="color: var(--color-fg-lightest)" | |
| > | |
| <svg | |
| xmlns="http://www.w3.org/2000/svg" | |
| width="24" | |
| height="24" | |
| viewBox="0 0 24 24" | |
| fill="none" | |
| stroke="currentColor" | |
| stroke-width="2" | |
| stroke-linecap="round" | |
| stroke-linejoin="round" | |
| class="lucide lucide-gauge-circle h-7 w-7" | |
| style="color: var(--color-aqua-accent)" | |
| > | |
| <path d="M15.6 3.3a10 10 0 1 0 5.1 5.1" /> | |
| <path | |
| d="M12 12a1 1 0 0 0-1-1v4a1 1 0 0 0 1 1h4a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-3z" | |
| /> | |
| <path d="M12 6.8a10 10 0 0 0 -3.2 7.2" /> | |
| </svg> | |
| <span>Copilot API Usage Dashboard</span> | |
| </h1> | |
| <p class="mt-1 text-sm" style="color: var(--color-gray)"> | |
| Should be the same as the one in VSCode | |
| </p> | |
| </header> | |
| <!-- Form Section --> | |
| <div | |
| class="mb-6 p-4 border" | |
| style=" | |
| background-color: var(--color-bg-soft); | |
| border-color: var(--color-bg-light-2); | |
| " | |
| > | |
| <form | |
| id="endpoint-form" | |
| class="grid grid-cols-1 xl:grid-cols-[minmax(0,2fr)_minmax(0,1fr)_180px_auto] gap-3 items-end" | |
| > | |
| <div class="w-full"> | |
| <label | |
| for="endpoint-url" | |
| class="mb-1 block font-semibold whitespace-nowrap text-sm" | |
| style="color: var(--color-fg-lightest)" | |
| >Usage Endpoint URL</label | |
| > | |
| <input | |
| type="text" | |
| id="endpoint-url" | |
| class="w-full px-3 py-2 border focus:ring-1 transition input-focus text-sm" | |
| style=" | |
| background-color: var(--color-bg-darkest); | |
| border-color: var(--color-bg-light-3); | |
| color: var(--color-fg-medium); | |
| " | |
| placeholder="http://localhost:4141/usage" | |
| /> | |
| </div> | |
| <div class="w-full"> | |
| <label | |
| for="x-api-key" | |
| class="mb-1 block font-semibold whitespace-nowrap text-sm" | |
| style="color: var(--color-fg-lightest)" | |
| >x-api-key</label | |
| > | |
| <input | |
| type="password" | |
| id="x-api-key" | |
| class="w-full px-3 py-2 border focus:ring-1 transition input-focus text-sm" | |
| style=" | |
| background-color: var(--color-bg-darkest); | |
| border-color: var(--color-bg-light-3); | |
| color: var(--color-fg-medium); | |
| " | |
| placeholder="Optional when auth is enabled" | |
| autocomplete="off" | |
| spellcheck="false" | |
| /> | |
| </div> | |
| <div class="w-full"> | |
| <label | |
| for="token-usage-period" | |
| class="mb-1 block font-semibold whitespace-nowrap text-sm" | |
| style="color: var(--color-fg-lightest)" | |
| >Token Usage Period</label | |
| > | |
| <select | |
| id="token-usage-period" | |
| class="w-full px-3 py-2 border focus:ring-1 transition input-focus text-sm" | |
| style=" | |
| background-color: var(--color-bg-darkest); | |
| border-color: var(--color-bg-light-3); | |
| color: var(--color-fg-medium); | |
| " | |
| > | |
| <option value="day">Day</option> | |
| <option value="week">Last 7 days</option> | |
| <option value="month">Last 30 days</option> | |
| </select> | |
| </div> | |
| <button | |
| id="fetch-button" | |
| type="submit" | |
| class="w-full xl:w-auto font-bold py-2 px-5 transition-colors flex items-center justify-center gap-2 text-sm" | |
| style=" | |
| background-color: var(--color-blue); | |
| color: var(--color-bg-darkest); | |
| " | |
| > | |
| <svg | |
| xmlns="http://www.w3.org/2000/svg" | |
| width="24" | |
| height="24" | |
| viewBox="0 0 24 24" | |
| fill="none" | |
| stroke="currentColor" | |
| stroke-width="2" | |
| stroke-linecap="round" | |
| stroke-linejoin="round" | |
| class="lucide lucide-refresh-cw h-4 w-4" | |
| > | |
| <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" /> | |
| <path d="M21 3v5h-5" /> | |
| <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" /> | |
| <path d="M3 21v-5h5" /> | |
| </svg> | |
| <span>Fetch</span> | |
| </button> | |
| </form> | |
| <p class="mt-3 text-xs" style="color: var(--color-gray-accent)"> | |
| The page derives <code>/token-usage</code> and | |
| <code>/token-usage/events</code> from the usage endpoint and sends | |
| <code>x-api-key</code> to all requests when provided. | |
| </p> | |
| </div> | |
| <!-- Content Area for dynamic data --> | |
| <main id="content-area"></main> | |
| </div> | |
| </div> | |
| <script> | |
| document.addEventListener("DOMContentLoaded", () => { | |
| const endpointForm = document.getElementById("endpoint-form"); | |
| const endpointUrlInput = document.getElementById("endpoint-url"); | |
| const apiKeyInput = document.getElementById("x-api-key"); | |
| const tokenUsagePeriodSelect = document.getElementById( | |
| "token-usage-period" | |
| ); | |
| const contentArea = document.getElementById("content-area"); | |
| const fetchButton = document.getElementById("fetch-button"); | |
| const fetchButtonLabel = fetchButton.querySelector("span"); | |
| // Apply hover effect for fetch button via JS | |
| fetchButton.addEventListener("mouseenter", () => { | |
| fetchButton.style.backgroundColor = "var(--color-blue-accent)"; | |
| }); | |
| fetchButton.addEventListener("mouseleave", () => { | |
| fetchButton.style.backgroundColor = "var(--color-blue)"; | |
| }); | |
| const DEFAULT_ENDPOINT = "http://localhost:4141/usage"; | |
| const DEFAULT_TOKEN_USAGE_PERIOD = "day"; | |
| const DEFAULT_TOKEN_USAGE_EVENTS_PAGE_SIZE = 20; | |
| const ALL_METRICS_VALUE = "__all__"; | |
| const ALL_MODELS_VALUE = "__all__"; | |
| const API_KEY_STORAGE_KEY = "copilot-api.usage-viewer.x-api-key"; | |
| const VALID_PERIODS = new Set(["day", "week", "month"]); | |
| const EMPTY_TOKEN_USAGE_TOTALS = { | |
| cache_creation_input_tokens: 0, | |
| cache_read_input_tokens: 0, | |
| input_tokens: 0, | |
| output_tokens: 0, | |
| request_count: 0, | |
| total_tokens: 0, | |
| }; | |
| // --- State Management --- | |
| const state = { | |
| isLoading: false, | |
| isTokenUsageLoading: false, | |
| isEventsLoading: false, | |
| error: null, | |
| data: null, | |
| tokenUsageDailySummary: null, | |
| tokenUsageSummary: null, | |
| tokenUsageEventsPage: null, | |
| tokenUsageDailyError: null, | |
| tokenUsageSummaryError: null, | |
| tokenUsageEventsError: null, | |
| tokenUsagePeriod: DEFAULT_TOKEN_USAGE_PERIOD, | |
| tokenUsageTrendDayIndex: null, | |
| tokenUsageTrendMetric: ALL_METRICS_VALUE, | |
| tokenUsageTrendModel: ALL_MODELS_VALUE, | |
| }; | |
| // --- Rendering Logic --- | |
| /** | |
| * Safely calls lucide.createIcons() if the library is available. | |
| */ | |
| function createIcons() { | |
| if (typeof lucide !== "undefined") { | |
| lucide.createIcons(); | |
| } | |
| } | |
| function escapeHtml(value) { | |
| return String(value) | |
| .replaceAll("&", "&") | |
| .replaceAll("<", "<") | |
| .replaceAll(">", ">") | |
| .replaceAll('"', """) | |
| .replaceAll("'", "'"); | |
| } | |
| function getErrorMessage(error) { | |
| if (error instanceof Error) { | |
| return error.message; | |
| } | |
| return typeof error === "string" ? error : "Unknown error."; | |
| } | |
| function isMissingTokenUsageEndpointError(error) { | |
| return Boolean( | |
| error | |
| && typeof error === "object" | |
| && "status" in error | |
| && error.status === 404 | |
| ); | |
| } | |
| function normalizePeriod(value) { | |
| return VALID_PERIODS.has(value) | |
| ? value | |
| : DEFAULT_TOKEN_USAGE_PERIOD; | |
| } | |
| function getSelectedPeriod() { | |
| const normalized = normalizePeriod(tokenUsagePeriodSelect.value); | |
| tokenUsagePeriodSelect.value = normalized; | |
| state.tokenUsagePeriod = normalized; | |
| return normalized; | |
| } | |
| function loadStoredApiKey() { | |
| try { | |
| return localStorage.getItem(API_KEY_STORAGE_KEY) || ""; | |
| } catch (error) { | |
| console.warn("Could not read stored API key:", getErrorMessage(error)); | |
| return ""; | |
| } | |
| } | |
| function storeApiKey(value) { | |
| try { | |
| const trimmed = value.trim(); | |
| if (trimmed) { | |
| localStorage.setItem(API_KEY_STORAGE_KEY, trimmed); | |
| } else { | |
| localStorage.removeItem(API_KEY_STORAGE_KEY); | |
| } | |
| } catch (error) { | |
| console.warn("Could not store API key:", getErrorMessage(error)); | |
| } | |
| } | |
| function buildRequestHeaders() { | |
| const headers = {}; | |
| const apiKey = apiKeyInput.value.trim(); | |
| if (apiKey) { | |
| headers["x-api-key"] = apiKey; | |
| } | |
| return headers; | |
| } | |
| function resolveUsageUrl(endpoint) { | |
| return new URL(endpoint, window.location.href).toString(); | |
| } | |
| function deriveEndpointUrl(usageEndpoint, targetPath) { | |
| const url = new URL(usageEndpoint, window.location.href); | |
| const normalizedTargetPath = targetPath.replace(/^\/+/, ""); | |
| const normalizedPath = url.pathname.replace(/\/+$/, ""); | |
| const nextPath = normalizedPath.endsWith("/usage") | |
| ? normalizedPath.replace(/\/usage$/, `/${normalizedTargetPath}`) | |
| : `${normalizedPath || ""}/${normalizedTargetPath}`; | |
| url.pathname = nextPath.startsWith("/") ? nextPath : `/${nextPath}`; | |
| url.search = ""; | |
| url.hash = ""; | |
| return url.toString(); | |
| } | |
| function buildTokenUsageSummaryUrl(usageEndpoint, period) { | |
| const url = new URL(deriveEndpointUrl(usageEndpoint, "token-usage")); | |
| url.searchParams.set("period", period); | |
| return url.toString(); | |
| } | |
| function buildTokenUsageDailyUrl(usageEndpoint, period) { | |
| const url = new URL( | |
| deriveEndpointUrl(usageEndpoint, "token-usage/daily") | |
| ); | |
| url.searchParams.set("period", period); | |
| return url.toString(); | |
| } | |
| function buildTokenUsageEventsUrl(usageEndpoint, period, page) { | |
| const url = new URL( | |
| deriveEndpointUrl(usageEndpoint, "token-usage/events") | |
| ); | |
| url.searchParams.set("period", period); | |
| url.searchParams.set("page", String(page)); | |
| url.searchParams.set( | |
| "page_size", | |
| String(DEFAULT_TOKEN_USAGE_EVENTS_PAGE_SIZE) | |
| ); | |
| return url.toString(); | |
| } | |
| async function fetchJson(url) { | |
| const response = await fetch(url, { | |
| headers: buildRequestHeaders(), | |
| }); | |
| if (!response.ok) { | |
| let message = response.statusText || "Request failed"; | |
| try { | |
| const contentType = response.headers.get("content-type") || ""; | |
| if (contentType.includes("application/json")) { | |
| const payload = await response.json(); | |
| const apiMessage = payload?.error?.message || payload?.message; | |
| if (typeof apiMessage === "string" && apiMessage.trim()) { | |
| message = apiMessage.trim(); | |
| } | |
| } else { | |
| const text = await response.text(); | |
| if (text.trim()) { | |
| message = text.trim(); | |
| } | |
| } | |
| } catch (error) { | |
| console.warn( | |
| "Could not parse error response:", | |
| getErrorMessage(error) | |
| ); | |
| } | |
| const error = new Error( | |
| `Request failed with status ${response.status}: ${message}` | |
| ); | |
| error.status = response.status; | |
| throw error; | |
| } | |
| return response.json(); | |
| } | |
| function syncUrlState() { | |
| try { | |
| const currentUrl = new URL(window.location.href); | |
| const endpoint = endpointUrlInput.value.trim(); | |
| const period = getSelectedPeriod(); | |
| if (endpoint) { | |
| currentUrl.searchParams.set("endpoint", endpoint); | |
| } else { | |
| currentUrl.searchParams.delete("endpoint"); | |
| } | |
| currentUrl.searchParams.set("period", period); | |
| window.history.pushState({}, "", currentUrl); | |
| } catch (error) { | |
| console.warn("Could not update URL:", getErrorMessage(error)); | |
| } | |
| } | |
| function updateControls() { | |
| fetchButton.disabled = state.isLoading; | |
| fetchButtonLabel.textContent = state.isLoading ? "Fetching..." : "Fetch"; | |
| tokenUsagePeriodSelect.disabled = | |
| state.isLoading || state.isTokenUsageLoading; | |
| } | |
| function formatNumber(value) { | |
| return Number.isFinite(value) ? Number(value).toLocaleString() : "0"; | |
| } | |
| function formatDateTime(value) { | |
| if (!Number.isFinite(value)) { | |
| return "N/A"; | |
| } | |
| return new Date(value).toLocaleString(); | |
| } | |
| function formatCellText(value) { | |
| if (typeof value !== "string") { | |
| return "—"; | |
| } | |
| const trimmed = value.trim(); | |
| return trimmed || "—"; | |
| } | |
| function renderTokenUsageRangeText(period, range) { | |
| const labels = { | |
| day: "Day window", | |
| week: "Last 7 days", | |
| month: "Last 30 days", | |
| }; | |
| if (!range) { | |
| return labels[period] || labels.day; | |
| } | |
| return `${labels[period] || labels.day}: ${formatDateTime( | |
| range.start_ms | |
| )} - ${formatDateTime(range.end_ms)}`; | |
| } | |
| /** | |
| * Renders the entire UI based on the current state. | |
| */ | |
| function render() { | |
| updateControls(); | |
| if (state.isLoading) { | |
| contentArea.innerHTML = renderSpinner(); | |
| createIcons(); | |
| return; | |
| } | |
| const hasContent = Boolean( | |
| state.error | |
| || state.data | |
| || state.isTokenUsageLoading | |
| || state.isEventsLoading | |
| || state.tokenUsageDailySummary | |
| || state.tokenUsageSummary | |
| || state.tokenUsageEventsPage | |
| || state.tokenUsageDailyError | |
| || state.tokenUsageSummaryError | |
| || state.tokenUsageEventsError | |
| ); | |
| if (!hasContent) { | |
| contentArea.innerHTML = renderWelcomeMessage(); | |
| } else if (state.error && !state.data) { | |
| contentArea.innerHTML = ` | |
| ${renderError(state.error, "Usage request failed")} | |
| ${renderTokenUsageSection()} | |
| `; | |
| } else if (state.data) { | |
| contentArea.innerHTML = ` | |
| ${renderUsageQuotas(state.data.quota_snapshots)} | |
| ${renderTokenUsageSection()} | |
| ${renderDetailedData(state.data)} | |
| `; | |
| } else { | |
| contentArea.innerHTML = renderTokenUsageSection(); | |
| } | |
| // Replace placeholder icons with actual Lucide icons | |
| createIcons(); | |
| } | |
| /** | |
| * Renders the "Usage Quotas" section with progress bars. | |
| * @param {object} snapshots - The quota_snapshots object from the API response. | |
| * @returns {string} HTML string for the usage quotas section. | |
| */ | |
| function renderUsageQuotas(snapshots) { | |
| if (!snapshots) return ""; | |
| const quotaCards = Object.entries(snapshots) | |
| .map(([key, value]) => { | |
| return renderQuotaCard(key, value); | |
| }) | |
| .join(""); | |
| return ` | |
| <section id="usage-quotas" class="mb-6"> | |
| <h2 class="text-xl font-bold mb-3 flex items-center gap-2" style="color: var(--color-fg-lightest);"> | |
| <i data-lucide="bar-chart-big"></i> Usage Quotas | |
| </h2> | |
| <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> | |
| ${quotaCards} | |
| </div> | |
| </section> | |
| `; | |
| } | |
| /** | |
| * Renders a single quota card. | |
| * @param {string} title - The name of the quota (e.g., 'chat'). | |
| * @param {object} details - The details object for the quota. | |
| * @returns {string} HTML string for a single card. | |
| */ | |
| function renderQuotaCard(title, details) { | |
| const { entitlement, remaining, percent_remaining, unlimited } = | |
| details; | |
| const percentUsed = unlimited ? 0 : 100 - percent_remaining; | |
| const used = unlimited | |
| ? "N/A" | |
| : (entitlement - remaining).toLocaleString(); | |
| let progressBarColor = "var(--color-green)"; | |
| if (percentUsed > 75) progressBarColor = "var(--color-yellow)"; | |
| if (percentUsed > 90) progressBarColor = "var(--color-red)"; | |
| if (unlimited) progressBarColor = "var(--color-blue)"; | |
| return ` | |
| <div class="p-4 border" style="background-color: var(--color-bg); border-color: var(--color-bg-light-2);"> | |
| <div class="flex justify-between items-center mb-2"> | |
| <h3 class="text-md font-semibold capitalize" style="color: var(--color-fg-lightest);">${escapeHtml(title.replace(/_/g, " "))}</h3> | |
| ${ | |
| unlimited | |
| ? `<span class="px-2 py-0.5 text-xs font-medium" style="color: var(--color-blue-accent); background-color: var(--color-bg-light-1);">Unlimited</span>` | |
| : `<span class="text-sm font-mono" style="color: var(--color-fg-medium);">${percentUsed.toFixed(1)}% Used</span>` | |
| } | |
| </div> | |
| <div class="mb-3"> | |
| <div class="w-full progress-bar-bg h-2"> | |
| <div class="progress-bar-fg h-2" style="width: ${unlimited ? 100 : percentUsed}%; background-color: ${progressBarColor};"></div> | |
| </div> | |
| </div> | |
| <div class="flex justify-between text-xs font-mono" style="color: var(--color-fg-dark);"> | |
| <span>${used} / ${unlimited ? "∞" : entitlement.toLocaleString()}</span> | |
| <span>${unlimited ? "∞" : remaining.toLocaleString()} remaining</span> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| /** | |
| * Recursively builds a formatted HTML list from a JSON object. | |
| * @param {object} obj - The object to format. | |
| * @returns {string} HTML string for the formatted list. | |
| */ | |
| function formatObject(obj) { | |
| if (obj === null || typeof obj !== "object") { | |
| return `<span style="color: var(--color-green-accent);">${escapeHtml(JSON.stringify(obj))}</span>`; | |
| } | |
| return ( | |
| '<div class="pl-4">' + | |
| Object.entries(obj) | |
| .map(([key, value]) => { | |
| const formattedKey = escapeHtml(key.replace(/_/g, " ")); | |
| let displayValue; | |
| if (Array.isArray(value)) { | |
| displayValue = | |
| value.length > 0 | |
| ? `<span style='color: var(--color-gray-accent)'>[...${value.length} items]</span>` | |
| : `<span style='color: var(--color-gray-accent)'>[]</span>`; | |
| } else if (typeof value === "object" && value !== null) { | |
| displayValue = formatObject(value); | |
| } else if (typeof value === "boolean") { | |
| displayValue = `<span class="font-semibold" style="color: ${value ? "var(--color-green-accent)" : "var(--color-red-accent)"}">${escapeHtml(String(value))}</span>`; | |
| } else { | |
| displayValue = `<span style="color: var(--color-blue-accent);">${escapeHtml(JSON.stringify(value))}</span>`; | |
| } | |
| return `<div class="mt-1"> | |
| <span class="capitalize font-semibold" style="color: var(--color-fg-medium);">${formattedKey}:</span> | |
| ${typeof value === "object" && value !== null && !Array.isArray(value) ? displayValue : ` ${displayValue}`} | |
| </div>`; | |
| }) | |
| .join("") + | |
| "</div>" | |
| ); | |
| } | |
| /** | |
| * Renders the section with the full, formatted API response. | |
| * @param {object} data - The full API response data. | |
| * @returns {string} HTML string for the full data section. | |
| */ | |
| function renderDetailedData(data) { | |
| const formattedDetails = formatObject(data); | |
| return ` | |
| <section id="detailed-data"> | |
| <h2 class="text-xl font-bold mb-3 flex items-center gap-2" style="color: var(--color-fg-lightest);"> | |
| <i data-lucide="file-text"></i> Usage API Response | |
| </h2> | |
| <div class="border p-4 relative font-mono text-xs code-block overflow-auto" style="background-color: var(--color-bg-darkest); border-color: var(--color-bg-light-2);"> | |
| ${formattedDetails} | |
| </div> | |
| </section> | |
| `; | |
| } | |
| function renderTokenUsageSection() { | |
| const hasTokenUsageContent = Boolean( | |
| state.isTokenUsageLoading | |
| || state.isEventsLoading | |
| || state.tokenUsageDailySummary | |
| || state.tokenUsageSummary | |
| || state.tokenUsageEventsPage | |
| || state.tokenUsageDailyError | |
| || state.tokenUsageSummaryError | |
| || state.tokenUsageEventsError | |
| ); | |
| if (!hasTokenUsageContent) { | |
| return ""; | |
| } | |
| const summary = state.tokenUsageSummary; | |
| const dailySummary = state.tokenUsageDailySummary; | |
| const eventsPage = state.tokenUsageEventsPage; | |
| const totals = summary?.totals || EMPTY_TOKEN_USAGE_TOTALS; | |
| const activeRange = | |
| summary?.range || dailySummary?.range || eventsPage?.range || null; | |
| const totalPages = eventsPage ? Math.max(eventsPage.total_pages, 1) : 1; | |
| const eventsMeta = eventsPage | |
| ? `Page ${eventsPage.page} / ${totalPages} · ${formatNumber( | |
| eventsPage.total | |
| )} events` | |
| : "No detail page loaded yet."; | |
| return ` | |
| <section id="token-usage" class="mb-6"> | |
| <div class="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between"> | |
| <div> | |
| <h2 class="text-xl font-bold flex items-center gap-2" style="color: var(--color-fg-lightest);"> | |
| <i data-lucide="database"></i> Token Usage | |
| </h2> | |
| <p class="mt-1 text-xs" style="color: var(--color-gray);">${escapeHtml( | |
| renderTokenUsageRangeText(state.tokenUsagePeriod, activeRange) | |
| )}</p> | |
| </div> | |
| <div class="flex flex-wrap items-center gap-2 text-xs" style="color: var(--color-gray-accent);"> | |
| ${ | |
| state.isTokenUsageLoading | |
| ? "<span>Refreshing summary...</span>" | |
| : "" | |
| } | |
| ${ | |
| state.isEventsLoading | |
| ? "<span>Refreshing details...</span>" | |
| : "" | |
| } | |
| </div> | |
| </div> | |
| ${ | |
| state.tokenUsageSummaryError | |
| ? renderError( | |
| state.tokenUsageSummaryError, | |
| "Token usage summary failed" | |
| ) | |
| : "" | |
| } | |
| <div class="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-6 gap-3 ${ | |
| state.isTokenUsageLoading ? "opacity-60" : "" | |
| }"> | |
| ${renderTokenUsageMetric( | |
| "Total", | |
| totals.total_tokens, | |
| "var(--color-fg-lightest)" | |
| )} | |
| ${renderTokenUsageMetric( | |
| "Input", | |
| totals.input_tokens, | |
| "var(--color-blue-accent)" | |
| )} | |
| ${renderTokenUsageMetric( | |
| "Output", | |
| totals.output_tokens, | |
| "var(--color-green-accent)" | |
| )} | |
| ${renderTokenUsageMetric( | |
| "Cache Read", | |
| totals.cache_read_input_tokens, | |
| "var(--color-aqua-accent)" | |
| )} | |
| ${renderTokenUsageMetric( | |
| "Cache Write", | |
| totals.cache_creation_input_tokens, | |
| "var(--color-yellow-accent)" | |
| )} | |
| ${renderTokenUsageMetric( | |
| "Requests", | |
| totals.request_count, | |
| "var(--color-purple-accent)" | |
| )} | |
| </div> | |
| ${renderTokenUsageDailyTrend( | |
| dailySummary, | |
| state.tokenUsageDailyError | |
| )} | |
| <div class="mt-4 border" style="background-color: var(--color-bg); border-color: var(--color-bg-light-2);"> | |
| <div class="px-4 py-3 border-b flex items-center justify-between gap-3" style="background-color: var(--color-bg-soft); border-color: var(--color-bg-light-2);"> | |
| <div> | |
| <p class="text-sm font-semibold" style="color: var(--color-fg-lightest);">By Model</p> | |
| <p class="mt-1 text-xs" style="color: var(--color-gray-accent);">${summary ? `${formatNumber(summary.byModel.length)} models` : "No summary rows loaded."}</p> | |
| </div> | |
| </div> | |
| ${renderTokenUsageModelBreakdown(summary)} | |
| </div> | |
| <div class="mt-4 border" style="background-color: var(--color-bg); border-color: var(--color-bg-light-2);"> | |
| <div class="px-4 py-3 border-b flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between" style="background-color: var(--color-bg-soft); border-color: var(--color-bg-light-2);"> | |
| <div> | |
| <p class="text-sm font-semibold" style="color: var(--color-fg-lightest);">Event Details</p> | |
| <p class="mt-1 text-xs" style="color: var(--color-gray-accent);">${escapeHtml( | |
| eventsMeta | |
| )}</p> | |
| </div> | |
| <div class="flex items-center gap-2"> | |
| ${renderPaginationButton( | |
| "previous", | |
| "Previous", | |
| !eventsPage || state.isEventsLoading || eventsPage.page <= 1 | |
| )} | |
| ${renderPaginationButton( | |
| "next", | |
| "Next", | |
| !eventsPage | |
| || state.isEventsLoading | |
| || eventsPage.page >= totalPages | |
| )} | |
| </div> | |
| </div> | |
| ${ | |
| state.tokenUsageEventsError | |
| ? `<div class="p-4">${renderError( | |
| state.tokenUsageEventsError, | |
| "Token usage details failed" | |
| )}</div>` | |
| : "" | |
| } | |
| ${renderTokenUsageEventsTable(eventsPage)} | |
| </div> | |
| </section> | |
| `; | |
| } | |
| function getTokenUsageTrendMetrics() { | |
| return [ | |
| { | |
| color: "var(--color-fg-lightest)", | |
| key: "total_tokens", | |
| label: "Total", | |
| }, | |
| { | |
| color: "var(--color-blue-accent)", | |
| key: "input_tokens", | |
| label: "Input", | |
| }, | |
| { | |
| color: "var(--color-green-accent)", | |
| key: "output_tokens", | |
| label: "Output", | |
| }, | |
| { | |
| color: "var(--color-aqua-accent)", | |
| key: "cache_read_input_tokens", | |
| label: "Cache Read", | |
| }, | |
| { | |
| color: "var(--color-yellow-accent)", | |
| key: "cache_creation_input_tokens", | |
| label: "Cache Write", | |
| }, | |
| ]; | |
| } | |
| function getDailyTrendTotals(day, selectedModel) { | |
| if (selectedModel === ALL_MODELS_VALUE) { | |
| return day.totals || EMPTY_TOKEN_USAGE_TOTALS; | |
| } | |
| return ( | |
| day.byModel?.find((model) => model.model === selectedModel) | |
| || EMPTY_TOKEN_USAGE_TOTALS | |
| ); | |
| } | |
| function formatChartDate(value) { | |
| const parts = String(value).split("-"); | |
| return parts.length === 3 ? `${parts[1]}/${parts[2]}` : value; | |
| } | |
| function renderTokenUsageDailyTrend(dailySummary, error) { | |
| if (state.tokenUsagePeriod === "day") { | |
| return ""; | |
| } | |
| const models = dailySummary?.byModel || []; | |
| const selectedModel = models.some( | |
| (model) => model.model === state.tokenUsageTrendModel | |
| ) | |
| ? state.tokenUsageTrendModel | |
| : ALL_MODELS_VALUE; | |
| const options = [ | |
| `<option value="${ALL_MODELS_VALUE}">All models</option>`, | |
| ...models.map( | |
| (model) => | |
| `<option value="${escapeHtml(model.model)}" ${ | |
| selectedModel === model.model ? "selected" : "" | |
| }>${escapeHtml(model.model)}</option>` | |
| ), | |
| ].join(""); | |
| return ` | |
| <div class="mt-4 border" style="background-color: var(--color-bg); border-color: var(--color-bg-light-2);"> | |
| <div class="px-4 py-3 border-b flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between" style="background-color: var(--color-bg-soft); border-color: var(--color-bg-light-2);"> | |
| <p class="text-sm font-semibold" style="color: var(--color-fg-lightest);">Daily Trend</p> | |
| <select | |
| data-token-usage-trend-model | |
| class="px-3 py-1.5 border input-focus text-xs" | |
| style="background-color: var(--color-bg); border-color: var(--color-bg-light-2); color: var(--color-fg-light);" | |
| >${options}</select> | |
| </div> | |
| ${ | |
| error | |
| ? `<div class="p-4">${renderError( | |
| error, | |
| "Token usage daily trend failed" | |
| )}</div>` | |
| : "" | |
| } | |
| ${renderTokenUsageTrendChart(dailySummary, selectedModel)} | |
| </div> | |
| `; | |
| } | |
| function renderTokenUsageTrendChart(dailySummary, selectedModel) { | |
| const days = dailySummary?.days || []; | |
| const metrics = getTokenUsageTrendMetrics(); | |
| const visibleMetrics = | |
| state.tokenUsageTrendMetric === ALL_METRICS_VALUE | |
| ? metrics | |
| : metrics.filter( | |
| (metric) => metric.key === state.tokenUsageTrendMetric | |
| ); | |
| const hasUsage = days.some( | |
| (day) => getDailyTrendTotals(day, selectedModel).request_count > 0 | |
| ); | |
| const latestUsageIndex = days.reduce((latest, day, index) => { | |
| return getDailyTrendTotals(day, selectedModel).request_count > 0 | |
| ? index | |
| : latest; | |
| }, -1); | |
| const activeIndex = | |
| Number.isInteger(state.tokenUsageTrendDayIndex) | |
| && state.tokenUsageTrendDayIndex >= 0 | |
| && state.tokenUsageTrendDayIndex < days.length | |
| ? state.tokenUsageTrendDayIndex | |
| : latestUsageIndex >= 0 | |
| ? latestUsageIndex | |
| : Math.max(0, days.length - 1); | |
| const activeDay = days[activeIndex]; | |
| const activeTotals = activeDay | |
| ? getDailyTrendTotals(activeDay, selectedModel) | |
| : EMPTY_TOKEN_USAGE_TOTALS; | |
| if (state.isTokenUsageLoading && !dailySummary) { | |
| return `<div class="p-4 min-h-[14rem] text-sm animate-pulse" style="color: var(--color-gray);">Loading...</div>`; | |
| } | |
| if (!dailySummary || days.length === 0 || !hasUsage) { | |
| return renderEmptyState( | |
| "No token usage recorded for the selected period." | |
| ); | |
| } | |
| const width = 720; | |
| const height = 220; | |
| const padding = { bottom: 34, left: 58, right: 16, top: 16 }; | |
| const plotWidth = width - padding.left - padding.right; | |
| const plotHeight = height - padding.top - padding.bottom; | |
| const allValues = days.flatMap((day) => { | |
| const totals = getDailyTrendTotals(day, selectedModel); | |
| return visibleMetrics.map((metric) => totals[metric.key] || 0); | |
| }); | |
| const maxValue = Math.max(1, ...allValues); | |
| const xForIndex = (index) => | |
| days.length <= 1 | |
| ? padding.left + plotWidth / 2 | |
| : padding.left + (plotWidth * index) / (days.length - 1); | |
| const hitWidth = | |
| days.length <= 1 ? plotWidth : plotWidth / (days.length - 1); | |
| const yForValue = (value) => | |
| padding.top | |
| + plotHeight | |
| - (Math.max(0, value) / maxValue) * plotHeight; | |
| const labelEvery = Math.max(1, Math.ceil(days.length / 6)); | |
| const yTicks = [0, 0.25, 0.5, 0.75, 1].map((ratio) => | |
| Math.round(maxValue * ratio) | |
| ); | |
| const grid = yTicks | |
| .map((tick) => { | |
| const y = yForValue(tick); | |
| return ` | |
| <line x1="${padding.left}" x2="${ | |
| width - padding.right | |
| }" y1="${y}" y2="${y}" stroke="var(--color-bg-light-2)" stroke-width="1" /> | |
| <text x="${padding.left - 8}" y="${ | |
| y + 4 | |
| }" text-anchor="end" font-size="11" fill="var(--color-gray-accent)">${formatNumber( | |
| tick | |
| )}</text> | |
| `; | |
| }) | |
| .join(""); | |
| const xLabels = days | |
| .map((day, index) => { | |
| if (index % labelEvery !== 0 && index !== days.length - 1) { | |
| return ""; | |
| } | |
| return `<text x="${xForIndex(index)}" y="${ | |
| height - 10 | |
| }" text-anchor="middle" font-size="11" fill="var(--color-gray-accent)">${escapeHtml( | |
| formatChartDate(day.date) | |
| )}</text>`; | |
| }) | |
| .join(""); | |
| const lines = visibleMetrics | |
| .map((metric) => { | |
| const points = days | |
| .map((day, index) => { | |
| const totals = getDailyTrendTotals(day, selectedModel); | |
| return `${xForIndex(index)},${yForValue( | |
| totals[metric.key] || 0 | |
| )}`; | |
| }) | |
| .join(" "); | |
| const circles = days | |
| .map((day, index) => { | |
| const totals = getDailyTrendTotals(day, selectedModel); | |
| const value = totals[metric.key] || 0; | |
| return `<circle cx="${xForIndex(index)}" cy="${yForValue( | |
| value | |
| )}" r="2.5" fill="${ | |
| metric.color | |
| }" data-trend-day-index="${index}"> | |
| <title>${escapeHtml( | |
| `${formatChartDate(day.date)} ${metric.label}: ${formatNumber( | |
| value | |
| )}` | |
| )}</title> | |
| </circle>`; | |
| }) | |
| .join(""); | |
| return ` | |
| <polyline fill="none" points="${points}" stroke="${metric.color}" stroke-linecap="round" stroke-linejoin="round" stroke-width="2.25"> | |
| <title>${escapeHtml(metric.label)}</title> | |
| </polyline> | |
| ${circles} | |
| `; | |
| }) | |
| .join(""); | |
| const activeMarker = activeDay | |
| ? `<line x1="${xForIndex(activeIndex)}" x2="${xForIndex( | |
| activeIndex | |
| )}" y1="${padding.top}" y2="${ | |
| padding.top + plotHeight | |
| }" stroke="var(--color-bg-light-4)" stroke-dasharray="4 4" stroke-width="1" />` | |
| : ""; | |
| const hitRects = days | |
| .map((day, index) => { | |
| const x = xForIndex(index); | |
| const hitStart = Math.max(padding.left, x - hitWidth / 2); | |
| const hitEnd = Math.min( | |
| width - padding.right, | |
| x + hitWidth / 2 | |
| ); | |
| const totals = getDailyTrendTotals(day, selectedModel); | |
| const title = [ | |
| formatChartDate(day.date), | |
| ...visibleMetrics.map( | |
| (metric) => | |
| `${metric.label}: ${formatNumber( | |
| totals[metric.key] || 0 | |
| )}` | |
| ), | |
| ].join("\n"); | |
| return `<rect x="${hitStart}" y="${padding.top}" width="${ | |
| hitEnd - hitStart | |
| }" height="${plotHeight}" fill="transparent" style="cursor: pointer;" data-trend-day-index="${index}"> | |
| <title>${escapeHtml(title)}</title> | |
| </rect>`; | |
| }) | |
| .join(""); | |
| const legend = [ | |
| `<button type="button" data-trend-metric="${ALL_METRICS_VALUE}" class="inline-flex items-center border px-2 py-1 text-xs ${ | |
| state.tokenUsageTrendMetric === ALL_METRICS_VALUE | |
| ? "font-semibold" | |
| : "" | |
| }" style="background-color: ${ | |
| state.tokenUsageTrendMetric === ALL_METRICS_VALUE | |
| ? "var(--color-bg-light-1)" | |
| : "var(--color-bg)" | |
| }; border-color: var(--color-bg-light-2); color: var(--color-fg-light);">All</button>`, | |
| ...metrics.map( | |
| (metric) => ` | |
| <button type="button" data-trend-metric="${ | |
| metric.key | |
| }" class="inline-flex items-center gap-1.5 border px-2 py-1 text-xs ${ | |
| state.tokenUsageTrendMetric === metric.key | |
| ? "font-semibold" | |
| : "" | |
| }" style="background-color: ${ | |
| state.tokenUsageTrendMetric === metric.key | |
| ? "var(--color-bg-light-1)" | |
| : "var(--color-bg)" | |
| }; border-color: var(--color-bg-light-2); color: var(--color-fg-light);"> | |
| <span class="h-2 w-2 rounded-full" style="background-color: ${ | |
| metric.color | |
| };"></span> | |
| ${escapeHtml(metric.label)} | |
| </button> | |
| ` | |
| ), | |
| ].join(""); | |
| const activeValues = activeDay | |
| ? ` | |
| <div class="mt-2 flex flex-wrap gap-3 px-3 py-2 text-xs" style="background-color: var(--color-bg-soft); color: var(--color-fg-dark);"> | |
| <span class="font-semibold" style="color: var(--color-fg-lightest);">${escapeHtml( | |
| formatChartDate(activeDay.date) | |
| )}</span> | |
| ${visibleMetrics | |
| .map( | |
| (metric) => | |
| `<span>${escapeHtml(metric.label)}: ${formatNumber( | |
| activeTotals[metric.key] || 0 | |
| )}</span>` | |
| ) | |
| .join("")} | |
| </div> | |
| ` | |
| : ""; | |
| return ` | |
| <div class="p-4 ${state.isTokenUsageLoading ? "opacity-60" : ""}"> | |
| <svg class="w-full h-56" viewBox="0 0 ${width} ${height}" role="img"> | |
| ${grid} | |
| ${xLabels} | |
| ${activeMarker} | |
| ${lines} | |
| ${hitRects} | |
| </svg> | |
| <div class="mt-2 flex flex-wrap gap-3"> | |
| ${legend} | |
| </div> | |
| ${activeValues} | |
| </div> | |
| `; | |
| } | |
| function renderTokenUsageMetric(label, value, accentColor) { | |
| return ` | |
| <div class="p-4 border" style="background-color: var(--color-bg); border-color: var(--color-bg-light-2);"> | |
| <div class="text-lg font-bold" style="color: ${accentColor};">${formatNumber( | |
| value | |
| )}</div> | |
| <div class="mt-1 text-xs uppercase tracking-wide" style="color: var(--color-gray-accent);">${escapeHtml( | |
| label | |
| )}</div> | |
| </div> | |
| `; | |
| } | |
| function renderTokenUsageModelBreakdown(summary) { | |
| if (!summary || summary.byModel.length === 0) { | |
| return renderEmptyState( | |
| "No token usage recorded for the selected period." | |
| ); | |
| } | |
| const rows = summary.byModel | |
| .map((model) => { | |
| return ` | |
| <tr class="border-b last:border-b-0" style="border-color: var(--color-bg-light-1);"> | |
| <td class="px-4 py-2 max-w-[280px] truncate" style="color: var(--color-fg-lightest);" title="${escapeHtml( | |
| model.model | |
| )}">${escapeHtml(model.model)}</td> | |
| <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber( | |
| model.request_count | |
| )}</td> | |
| <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber( | |
| model.input_tokens | |
| )}</td> | |
| <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber( | |
| model.output_tokens | |
| )}</td> | |
| <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber( | |
| model.cache_read_input_tokens | |
| )}</td> | |
| <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber( | |
| model.cache_creation_input_tokens | |
| )}</td> | |
| <td class="px-4 py-2 text-right font-semibold" style="color: var(--color-yellow-accent);">${formatNumber( | |
| model.total_tokens | |
| )}</td> | |
| </tr> | |
| `; | |
| }) | |
| .join(""); | |
| return ` | |
| <div class="overflow-auto ${state.isTokenUsageLoading ? "opacity-60" : ""}"> | |
| <table class="w-full min-w-[760px] text-left text-xs sm:text-sm"> | |
| <thead style="background-color: var(--color-bg-light-1); color: var(--color-fg-medium);"> | |
| <tr> | |
| <th class="px-4 py-2 font-semibold">Model</th> | |
| <th class="px-4 py-2 text-right font-semibold">Requests</th> | |
| <th class="px-4 py-2 text-right font-semibold">Input</th> | |
| <th class="px-4 py-2 text-right font-semibold">Output</th> | |
| <th class="px-4 py-2 text-right font-semibold">Cache Read</th> | |
| <th class="px-4 py-2 text-right font-semibold">Cache Write</th> | |
| <th class="px-4 py-2 text-right font-semibold">Total</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| ${rows} | |
| </tbody> | |
| </table> | |
| </div> | |
| `; | |
| } | |
| function renderTokenUsageEventsTable(eventsPage) { | |
| if (!eventsPage || eventsPage.items.length === 0) { | |
| return renderEmptyState( | |
| "No token usage detail rows for the selected period." | |
| ); | |
| } | |
| const rows = eventsPage.items | |
| .map((event) => { | |
| const userId = formatCellText(event.user_id); | |
| const sessionId = formatCellText(event.session_id); | |
| const traceId = formatCellText(event.trace_id); | |
| return ` | |
| <tr class="border-b last:border-b-0" style="border-color: var(--color-bg-light-1);"> | |
| <td class="px-4 py-2 whitespace-nowrap" style="color: var(--color-fg-dark);" title="${escapeHtml( | |
| event.created_at_utc | |
| )}">${escapeHtml(formatDateTime(event.created_at_ms))}</td> | |
| <td class="px-4 py-2 max-w-[160px] truncate" style="color: var(--color-fg-lightest);" title="${escapeHtml( | |
| userId | |
| )}">${escapeHtml(userId)}</td> | |
| <td class="px-4 py-2 whitespace-nowrap" style="color: var(--color-fg-dark);">${escapeHtml( | |
| event.endpoint.replace(/_/g, " ") | |
| )}</td> | |
| <td class="px-4 py-2 max-w-[220px] truncate" style="color: var(--color-fg-lightest);" title="${escapeHtml( | |
| event.model | |
| )}">${escapeHtml(event.model)}</td> | |
| <td class="px-4 py-2 max-w-[180px] truncate font-mono" style="color: var(--color-fg-dark);" title="${escapeHtml( | |
| sessionId | |
| )}">${escapeHtml(sessionId)}</td> | |
| <td class="px-4 py-2 max-w-[200px] truncate font-mono" style="color: var(--color-fg-dark);" title="${escapeHtml( | |
| traceId | |
| )}">${escapeHtml(traceId)}</td> | |
| <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber( | |
| event.input_tokens | |
| )}</td> | |
| <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber( | |
| event.output_tokens | |
| )}</td> | |
| <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber( | |
| event.cache_read_input_tokens | |
| )}</td> | |
| <td class="px-4 py-2 text-right" style="color: var(--color-fg-dark);">${formatNumber( | |
| event.cache_creation_input_tokens | |
| )}</td> | |
| <td class="px-4 py-2 text-right font-semibold" style="color: var(--color-yellow-accent);">${formatNumber( | |
| event.total_tokens | |
| )}</td> | |
| </tr> | |
| `; | |
| }) | |
| .join(""); | |
| return ` | |
| <div class="overflow-auto ${state.isEventsLoading ? "opacity-60" : ""}"> | |
| <table class="w-full min-w-[1180px] text-left text-xs sm:text-sm"> | |
| <thead style="background-color: var(--color-bg-light-1); color: var(--color-fg-medium);"> | |
| <tr> | |
| <th class="px-4 py-2 font-semibold">Time</th> | |
| <th class="px-4 py-2 font-semibold">User</th> | |
| <th class="px-4 py-2 font-semibold">Endpoint</th> | |
| <th class="px-4 py-2 font-semibold">Model</th> | |
| <th class="px-4 py-2 font-semibold">Session</th> | |
| <th class="px-4 py-2 font-semibold">Trace</th> | |
| <th class="px-4 py-2 text-right font-semibold">Input</th> | |
| <th class="px-4 py-2 text-right font-semibold">Output</th> | |
| <th class="px-4 py-2 text-right font-semibold">Cache Read</th> | |
| <th class="px-4 py-2 text-right font-semibold">Cache Write</th> | |
| <th class="px-4 py-2 text-right font-semibold">Total</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| ${rows} | |
| </tbody> | |
| </table> | |
| </div> | |
| `; | |
| } | |
| function renderPaginationButton(action, label, disabled) { | |
| return ` | |
| <button | |
| type="button" | |
| data-page-action="${action}" | |
| class="px-3 py-1.5 border text-xs font-medium" | |
| style="background-color: var(--color-bg-darkest); border-color: var(--color-bg-light-2); color: var(--color-fg-light);" | |
| ${disabled ? "disabled" : ""} | |
| > | |
| ${label} | |
| </button> | |
| `; | |
| } | |
| function renderEmptyState(message) { | |
| return ` | |
| <div class="px-4 py-6 text-sm" style="color: var(--color-gray);"> | |
| ${escapeHtml(message)} | |
| </div> | |
| `; | |
| } | |
| /** | |
| * Renders a loading spinner. | |
| * @returns {string} HTML string for the spinner. | |
| */ | |
| function renderSpinner() { | |
| return ` | |
| <div class="flex justify-center items-center py-20"> | |
| <div class="animate-spin h-12 w-12 rounded-full border-4 border-transparent border-t-4" style="border-top-color: var(--color-blue);"></div> | |
| </div>`; | |
| } | |
| /** | |
| * Renders an error message box. | |
| * @param {string} message - The error message to display. | |
| * @returns {string} HTML string for the error message. | |
| */ | |
| function renderError(message, title = "An Error Occurred") { | |
| return ` | |
| <div | |
| class="p-3 border" | |
| style="background-color: rgba(204, 36, 29, 0.2); border-color: var(--color-red); color: var(--color-red-accent);" | |
| role="alert" | |
| > | |
| <div class="flex items-start"> | |
| <i data-lucide="alert-triangle" class="h-5 w-5 mr-3 mt-0.5"></i> | |
| <div> | |
| <p class="font-bold text-sm">${escapeHtml(title)}</p> | |
| <p class="text-xs">${escapeHtml(message)}</p> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| } | |
| /** | |
| * Renders a welcome message when the page first loads. | |
| * @returns {string} HTML string for the welcome message. | |
| */ | |
| function renderWelcomeMessage() { | |
| return ` | |
| <div class="text-center py-16 px-4 border" style="background-color: var(--color-bg-soft); border-color: var(--color-bg-light-2);"> | |
| <i data-lucide="info" class="mx-auto h-10 w-10" style="color: var(--color-gray-accent);"></i> | |
| <h3 class="mt-2 text-lg font-semibold" style="color: var(--color-fg-lightest);">Welcome!</h3> | |
| <p class="mt-1 text-sm" style="color: var(--color-gray);">Enter a usage endpoint, optionally provide an x-api-key, and click "Fetch" to load usage and token usage data.</p> | |
| </div> | |
| `; | |
| } | |
| // --- Data Fetching --- | |
| /** | |
| * Fetches usage data and token usage data from the specified API endpoint. | |
| */ | |
| async function fetchData(page = 1) { | |
| const endpoint = endpointUrlInput.value.trim(); | |
| if (!endpoint) { | |
| state.data = null; | |
| state.tokenUsageDailySummary = null; | |
| state.tokenUsageSummary = null; | |
| state.tokenUsageEventsPage = null; | |
| state.error = "Endpoint URL cannot be empty."; | |
| state.tokenUsageDailyError = | |
| "Token usage requires a valid endpoint URL."; | |
| state.tokenUsageSummaryError = | |
| "Token usage requires a valid endpoint URL."; | |
| state.tokenUsageEventsError = null; | |
| render(); | |
| return; | |
| } | |
| const period = getSelectedPeriod(); | |
| state.isLoading = true; | |
| state.error = null; | |
| state.tokenUsageDailyError = null; | |
| state.tokenUsageSummaryError = null; | |
| state.tokenUsageEventsError = null; | |
| render(); | |
| try { | |
| const usageUrl = resolveUsageUrl(endpoint); | |
| const [usageResult, summaryResult, dailyResult, eventsResult] = | |
| await Promise.allSettled([ | |
| fetchJson(usageUrl), | |
| fetchJson(buildTokenUsageSummaryUrl(usageUrl, period)), | |
| fetchJson(buildTokenUsageDailyUrl(usageUrl, period)), | |
| fetchJson(buildTokenUsageEventsUrl(usageUrl, period, page)), | |
| ]); | |
| if (usageResult.status === "fulfilled") { | |
| state.data = usageResult.value; | |
| } else { | |
| state.data = null; | |
| state.error = getErrorMessage(usageResult.reason); | |
| } | |
| if (summaryResult.status === "fulfilled") { | |
| state.tokenUsageSummary = summaryResult.value; | |
| } else if ( | |
| isMissingTokenUsageEndpointError(summaryResult.reason) | |
| ) { | |
| state.tokenUsageSummary = null; | |
| state.tokenUsageSummaryError = null; | |
| } else { | |
| state.tokenUsageSummary = null; | |
| state.tokenUsageSummaryError = getErrorMessage( | |
| summaryResult.reason | |
| ); | |
| } | |
| if (dailyResult.status === "fulfilled") { | |
| state.tokenUsageDailySummary = dailyResult.value; | |
| } else if ( | |
| isMissingTokenUsageEndpointError(dailyResult.reason) | |
| ) { | |
| state.tokenUsageDailySummary = null; | |
| state.tokenUsageDailyError = null; | |
| } else { | |
| state.tokenUsageDailySummary = null; | |
| state.tokenUsageDailyError = getErrorMessage( | |
| dailyResult.reason | |
| ); | |
| } | |
| if (eventsResult.status === "fulfilled") { | |
| state.tokenUsageEventsPage = eventsResult.value; | |
| } else if ( | |
| isMissingTokenUsageEndpointError(eventsResult.reason) | |
| ) { | |
| state.tokenUsageEventsPage = null; | |
| state.tokenUsageEventsError = null; | |
| } else { | |
| state.tokenUsageEventsPage = null; | |
| state.tokenUsageEventsError = getErrorMessage( | |
| eventsResult.reason | |
| ); | |
| } | |
| } catch (error) { | |
| console.error("Fetch error:", error); | |
| state.data = null; | |
| state.tokenUsageDailySummary = null; | |
| state.tokenUsageSummary = null; | |
| state.tokenUsageEventsPage = null; | |
| state.error = getErrorMessage(error); | |
| state.tokenUsageDailyError = getErrorMessage(error); | |
| state.tokenUsageSummaryError = getErrorMessage(error); | |
| state.tokenUsageEventsError = getErrorMessage(error); | |
| } finally { | |
| state.isLoading = false; | |
| render(); | |
| } | |
| } | |
| async function fetchTokenUsageSummaryAndEvents(page = 1) { | |
| const endpoint = endpointUrlInput.value.trim(); | |
| if (!endpoint) { | |
| state.tokenUsageDailySummary = null; | |
| state.tokenUsageSummary = null; | |
| state.tokenUsageEventsPage = null; | |
| state.tokenUsageDailyError = | |
| "Token usage requires a valid endpoint URL."; | |
| state.tokenUsageSummaryError = | |
| "Token usage requires a valid endpoint URL."; | |
| state.tokenUsageEventsError = null; | |
| render(); | |
| return; | |
| } | |
| const period = getSelectedPeriod(); | |
| state.isTokenUsageLoading = true; | |
| state.tokenUsageDailySummary = null; | |
| state.tokenUsageSummary = null; | |
| state.tokenUsageEventsPage = null; | |
| state.tokenUsageDailyError = null; | |
| state.tokenUsageSummaryError = null; | |
| state.tokenUsageEventsError = null; | |
| render(); | |
| try { | |
| const usageUrl = resolveUsageUrl(endpoint); | |
| const [summaryResult, dailyResult, eventsResult] = | |
| await Promise.allSettled([ | |
| fetchJson(buildTokenUsageSummaryUrl(usageUrl, period)), | |
| fetchJson(buildTokenUsageDailyUrl(usageUrl, period)), | |
| fetchJson(buildTokenUsageEventsUrl(usageUrl, period, page)), | |
| ]); | |
| if (summaryResult.status === "fulfilled") { | |
| state.tokenUsageSummary = summaryResult.value; | |
| } else if ( | |
| isMissingTokenUsageEndpointError(summaryResult.reason) | |
| ) { | |
| state.tokenUsageSummary = null; | |
| state.tokenUsageSummaryError = null; | |
| } else { | |
| state.tokenUsageSummary = null; | |
| state.tokenUsageSummaryError = getErrorMessage( | |
| summaryResult.reason | |
| ); | |
| } | |
| if (dailyResult.status === "fulfilled") { | |
| state.tokenUsageDailySummary = dailyResult.value; | |
| } else if ( | |
| isMissingTokenUsageEndpointError(dailyResult.reason) | |
| ) { | |
| state.tokenUsageDailySummary = null; | |
| state.tokenUsageDailyError = null; | |
| } else { | |
| state.tokenUsageDailySummary = null; | |
| state.tokenUsageDailyError = getErrorMessage( | |
| dailyResult.reason | |
| ); | |
| } | |
| if (eventsResult.status === "fulfilled") { | |
| state.tokenUsageEventsPage = eventsResult.value; | |
| } else if ( | |
| isMissingTokenUsageEndpointError(eventsResult.reason) | |
| ) { | |
| state.tokenUsageEventsPage = null; | |
| state.tokenUsageEventsError = null; | |
| } else { | |
| state.tokenUsageEventsPage = null; | |
| state.tokenUsageEventsError = getErrorMessage( | |
| eventsResult.reason | |
| ); | |
| } | |
| } catch (error) { | |
| console.error("Token usage fetch error:", error); | |
| state.tokenUsageDailySummary = null; | |
| state.tokenUsageSummary = null; | |
| state.tokenUsageEventsPage = null; | |
| state.tokenUsageDailyError = getErrorMessage(error); | |
| state.tokenUsageSummaryError = getErrorMessage(error); | |
| state.tokenUsageEventsError = getErrorMessage(error); | |
| } finally { | |
| state.isTokenUsageLoading = false; | |
| render(); | |
| } | |
| } | |
| async function fetchTokenUsageEventsPage(page) { | |
| const endpoint = endpointUrlInput.value.trim(); | |
| if (!endpoint) { | |
| state.tokenUsageEventsError = | |
| "Token usage requires a valid endpoint URL."; | |
| render(); | |
| return; | |
| } | |
| state.isEventsLoading = true; | |
| state.tokenUsageEventsError = null; | |
| render(); | |
| try { | |
| const usageUrl = resolveUsageUrl(endpoint); | |
| state.tokenUsageEventsPage = await fetchJson( | |
| buildTokenUsageEventsUrl(usageUrl, getSelectedPeriod(), page) | |
| ); | |
| } catch (error) { | |
| if (isMissingTokenUsageEndpointError(error)) { | |
| state.tokenUsageEventsPage = null; | |
| state.tokenUsageEventsError = null; | |
| } else { | |
| console.error("Token usage events fetch error:", error); | |
| state.tokenUsageEventsError = getErrorMessage(error); | |
| } | |
| } finally { | |
| state.isEventsLoading = false; | |
| render(); | |
| } | |
| } | |
| // --- Event Handlers & Initialization --- | |
| /** | |
| * Handles the form submission to trigger a data fetch. | |
| * @param {Event} event - The form submission event. | |
| */ | |
| function handleFormSubmit(event) { | |
| event.preventDefault(); | |
| storeApiKey(apiKeyInput.value); | |
| syncUrlState(); | |
| void fetchData(); | |
| } | |
| function handlePeriodChange() { | |
| syncUrlState(); | |
| void fetchTokenUsageSummaryAndEvents(1); | |
| } | |
| function handleContentAreaClick(event) { | |
| if (!(event.target instanceof Element)) { | |
| return; | |
| } | |
| const trendMetric = event.target.closest("[data-trend-metric]"); | |
| if (trendMetric) { | |
| state.tokenUsageTrendMetric = | |
| trendMetric.getAttribute("data-trend-metric") | |
| || ALL_METRICS_VALUE; | |
| render(); | |
| return; | |
| } | |
| const trendPoint = event.target.closest("[data-trend-day-index]"); | |
| if (trendPoint) { | |
| const index = Number.parseInt( | |
| trendPoint.getAttribute("data-trend-day-index") || "", | |
| 10 | |
| ); | |
| if (Number.isFinite(index) && index >= 0) { | |
| state.tokenUsageTrendDayIndex = index; | |
| render(); | |
| } | |
| return; | |
| } | |
| const actionButton = event.target.closest("[data-page-action]"); | |
| if (!actionButton || !state.tokenUsageEventsPage || state.isEventsLoading) { | |
| return; | |
| } | |
| const action = actionButton.getAttribute("data-page-action"); | |
| if ( | |
| action === "previous" | |
| && state.tokenUsageEventsPage.page > 1 | |
| ) { | |
| void fetchTokenUsageEventsPage(state.tokenUsageEventsPage.page - 1); | |
| } | |
| if ( | |
| action === "next" | |
| && state.tokenUsageEventsPage.page | |
| < state.tokenUsageEventsPage.total_pages | |
| ) { | |
| void fetchTokenUsageEventsPage(state.tokenUsageEventsPage.page + 1); | |
| } | |
| } | |
| function handleContentAreaChange(event) { | |
| if (!(event.target instanceof HTMLSelectElement)) { | |
| return; | |
| } | |
| if (!event.target.matches("[data-token-usage-trend-model]")) { | |
| return; | |
| } | |
| state.tokenUsageTrendModel = | |
| event.target.value || ALL_MODELS_VALUE; | |
| render(); | |
| } | |
| /** | |
| * Initializes the application. | |
| */ | |
| function init() { | |
| endpointForm.addEventListener("submit", handleFormSubmit); | |
| tokenUsagePeriodSelect.addEventListener("change", handlePeriodChange); | |
| apiKeyInput.addEventListener("input", () => { | |
| storeApiKey(apiKeyInput.value); | |
| }); | |
| contentArea.addEventListener("click", handleContentAreaClick); | |
| contentArea.addEventListener("change", handleContentAreaChange); | |
| apiKeyInput.value = loadStoredApiKey(); | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const periodFromUrl = normalizePeriod(urlParams.get("period")); | |
| const endpointFromUrl = urlParams.get("endpoint"); | |
| state.tokenUsagePeriod = periodFromUrl; | |
| tokenUsagePeriodSelect.value = periodFromUrl; | |
| if (endpointFromUrl) { | |
| endpointUrlInput.value = endpointFromUrl; | |
| void fetchData(); | |
| } else { | |
| endpointUrlInput.value = DEFAULT_ENDPOINT; | |
| render(); | |
| } | |
| } | |
| // Start the app | |
| init(); | |
| }); | |
| </script> | |
| <footer | |
| class="text-center py-4 text-xs" | |
| style="color: var(--color-gray-accent)" | |
| > | |
| <p> | |
| Vibe coded by | |
| <a | |
| href="https://gemini.google.com" | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| class="underline transition-colors" | |
| style="color: var(--color-fg-dark)" | |
| onmouseover="this.style.color='var(--color-fg-light)'" | |
| onmouseout="this.style.color='var(--color-fg-dark)'" | |
| > | |
| Gemini | |
| </a> | |
| - Based on | |
| <a | |
| href="https://github.com/uheej0625/copilot-usage-viewer" | |
| target="_blank" | |
| rel="noopener noreferrer" | |
| class="underline transition-colors" | |
| style="color: var(--color-fg-dark)" | |
| onmouseover="this.style.color='var(--color-fg-light)'" | |
| onmouseout="this.style.color='var(--color-fg-dark)'" | |
| > | |
| copilot-usage-viewer</a | |
| > | |
| </p> | |
| </footer> | |
| </body> | |
| </html> | |