Spaces:
Sleeping
Sleeping
| <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); | |
| } | |
| </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="flex flex-col sm:flex-row items-center gap-3" | |
| > | |
| <label | |
| for="endpoint-url" | |
| class="font-semibold whitespace-nowrap text-sm" | |
| style="color: var(--color-fg-lightest)" | |
| >API Endpoint URL</label | |
| > | |
| <input | |
| type="text" | |
| id="endpoint-url" | |
| class="w-full px-3 py-1.5 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" | |
| /> | |
| <button | |
| id="fetch-button" | |
| type="submit" | |
| class="w-full sm:w-auto font-bold py-1.5 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> | |
| </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 contentArea = document.getElementById("content-area"); | |
| const fetchButton = document.getElementById("fetch-button"); | |
| // 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"; | |
| // --- State Management --- | |
| const state = { | |
| isLoading: false, | |
| error: null, | |
| data: null, | |
| }; | |
| // --- Rendering Logic --- | |
| /** | |
| * Safely calls lucide.createIcons() if the library is available. | |
| */ | |
| function createIcons() { | |
| if (typeof lucide !== "undefined") { | |
| lucide.createIcons(); | |
| } | |
| } | |
| /** | |
| * Renders the entire UI based on the current state. | |
| */ | |
| function render() { | |
| if (state.isLoading) { | |
| contentArea.innerHTML = renderSpinner(); | |
| return; | |
| } | |
| if (state.error) { | |
| contentArea.innerHTML = renderError(state.error); | |
| } else if (state.data) { | |
| contentArea.innerHTML = ` | |
| ${renderUsageQuotas(state.data.quota_snapshots)} | |
| ${renderDetailedData(state.data)} | |
| `; | |
| } else { | |
| contentArea.innerHTML = renderWelcomeMessage(); | |
| } | |
| // 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);">${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);">${JSON.stringify(obj)}</span>`; | |
| } | |
| return ( | |
| '<div class="pl-4">' + | |
| Object.entries(obj) | |
| .map(([key, value]) => { | |
| const formattedKey = 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)"}">${value}</span>`; | |
| } else { | |
| displayValue = `<span style="color: var(--color-blue-accent);">${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> Detailed Information | |
| </h2> | |
| <div class="border p-4 relative font-mono text-xs" style="background-color: var(--color-bg-darkest); border-color: var(--color-bg-light-2);"> | |
| ${formattedDetails} | |
| </div> | |
| </section> | |
| `; | |
| } | |
| /** | |
| * 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) { | |
| const container = document.createElement("div"); | |
| container.className = "p-3 border"; | |
| container.style.backgroundColor = "rgba(204, 36, 29, 0.2)"; | |
| container.style.borderColor = "var(--color-red)"; | |
| container.style.color = "var(--color-red-accent)"; | |
| container.setAttribute("role", "alert"); | |
| container.innerHTML = ` | |
| <div class="flex items-center"> | |
| <i data-lucide="alert-triangle" class="h-5 w-5 mr-3"></i> | |
| <div> | |
| <p class="font-bold text-sm">An Error Occurred</p> | |
| <p class="text-xs">${message}</p> | |
| </div> | |
| </div> | |
| `; | |
| // Must create icons *after* innerHTML is set | |
| setTimeout( | |
| () => | |
| lucide.createIcons({ | |
| nodes: [container.querySelector("[data-lucide]")], | |
| }), | |
| 0 | |
| ); | |
| return container.outerHTML; | |
| } | |
| /** | |
| * 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 an API endpoint URL and click "Fetch" to see usage data.</p> | |
| </div> | |
| `; | |
| } | |
| // --- Data Fetching --- | |
| /** | |
| * Fetches data from the specified API endpoint. | |
| */ | |
| async function fetchData() { | |
| const url = endpointUrlInput.value.trim(); | |
| if (!url) { | |
| state.error = "Endpoint URL cannot be empty."; | |
| state.isLoading = false; | |
| render(); | |
| return; | |
| } | |
| state.isLoading = true; | |
| state.error = null; | |
| render(); | |
| try { | |
| const response = await fetch(url); | |
| if (!response.ok) { | |
| throw new Error( | |
| `Request failed with status ${response.status}: ${response.statusText}` | |
| ); | |
| } | |
| const jsonData = await response.json(); | |
| state.data = jsonData; | |
| } catch (error) { | |
| console.error("Fetch error:", error); | |
| state.data = null; | |
| state.error = error.message; | |
| } finally { | |
| state.isLoading = 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(); | |
| const url = endpointUrlInput.value.trim(); | |
| // Update URL query parameter, catching potential security errors in sandboxed environments | |
| try { | |
| const currentUrl = new URL(window.location); | |
| currentUrl.searchParams.set("endpoint", url); | |
| window.history.pushState({}, "", currentUrl); | |
| } catch (e) { | |
| console.warn("Could not update URL: ", e.message); | |
| } | |
| fetchData(); | |
| } | |
| /** | |
| * Initializes the application. | |
| */ | |
| function init() { | |
| endpointForm.addEventListener("submit", handleFormSubmit); | |
| // Get endpoint from URL param on load | |
| const urlParams = new URLSearchParams(window.location.search); | |
| const endpointFromUrl = urlParams.get("endpoint"); | |
| if (endpointFromUrl) { | |
| endpointUrlInput.value = endpointFromUrl; | |
| fetchData(); | |
| } else { | |
| endpointUrlInput.value = DEFAULT_ENDPOINT; | |
| render(); // Render initial welcome message | |
| } | |
| } | |
| // 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> | |