diff --git a/docs-site/src/content/docs/guides/web-dashboard.md b/docs-site/src/content/docs/guides/web-dashboard.md index ed8f4cb709..004eb309a3 100644 --- a/docs-site/src/content/docs/guides/web-dashboard.md +++ b/docs-site/src/content/docs/guides/web-dashboard.md @@ -76,6 +76,23 @@ one column and model/effort controls share another. On narrower screens, control labels in the same reading order. Long version labels are shortened visually; hover the version badge or the version value to read the full value. +### Local monitor + +The **Usage → Menu bar settings** section (`#usage/monitor`) displays the optional menu-bar +monitor’s usage and settings inside the existing dashboard, without a separate native window. +The section follows **Coverage breakdown** and has **Usage** and **Providers** tabs, using the dashboard’s shared tab and select styles. +Display settings appear directly below the Usage graph; provider visibility controls live in Providers. +The full JSON editor is collapsed under Display settings until expanded. +The panel refreshes every five seconds while the tab is visible and shows token history, compact +account and provider quota bars, and summary totals. If the monitor is unavailable, the rest of +the OpenCodex dashboard continues to work. + +Use the graph, provider-visibility and model-filter controls, or edit the complete configuration +as JSON under **Monitor settings**. This includes the +chart style and grouping, time range, bucket size, aggregation, token metric, model filters, +provider visibility, display text, colours, and graph dimensions. Saving uses the configuration +revision currently shown by the monitor; if somebody else changed it first, discard the stale draft and reapply your edits. Chart regeneration happens asynchronously after a successful save. + ## What you can do | Area | What it does | diff --git a/docs/pr-assets/macos-menu-bar-monitor/providers.jpg b/docs/pr-assets/macos-menu-bar-monitor/providers.jpg new file mode 100644 index 0000000000..6974091a46 Binary files /dev/null and b/docs/pr-assets/macos-menu-bar-monitor/providers.jpg differ diff --git a/docs/pr-assets/macos-menu-bar-monitor/usage-integrated.png b/docs/pr-assets/macos-menu-bar-monitor/usage-integrated.png new file mode 100644 index 0000000000..182aa0d9a6 Binary files /dev/null and b/docs/pr-assets/macos-menu-bar-monitor/usage-integrated.png differ diff --git a/docs/pr-assets/macos-menu-bar-monitor/usage.jpg b/docs/pr-assets/macos-menu-bar-monitor/usage.jpg new file mode 100644 index 0000000000..f389210e8f Binary files /dev/null and b/docs/pr-assets/macos-menu-bar-monitor/usage.jpg differ diff --git a/gui/.eslint/i18n-allowlist.ts b/gui/.eslint/i18n-allowlist.ts index 805d7dcfbc..e9b442e790 100644 --- a/gui/.eslint/i18n-allowlist.ts +++ b/gui/.eslint/i18n-allowlist.ts @@ -40,6 +40,7 @@ const TECHNICAL_UNITS = new Set([ "ms", "k", "1M", + "M", // Decimal token unit used by the native monitor. "c", "w", "v", @@ -53,6 +54,8 @@ const TECHNICAL_UNITS = new Set([ /** Non-UI technical strings (API paths, CSS, shell, headers, debug fields). */ export function isTechnicalLiteral(value: string): boolean { + // Literal template field suffixes, not translatable UI copy. + if ([".raw", ".compact", ".formatted"].includes(value)) return true; const trimmed = value.trim(); if (!trimmed) return true; diff --git a/gui/src/app-routing.ts b/gui/src/app-routing.ts index 9dbf4d28bc..1e1b412b2c 100644 --- a/gui/src/app-routing.ts +++ b/gui/src/app-routing.ts @@ -33,6 +33,7 @@ export function readPageFromHash(hash?: string): Page { const raw = normalizeHashPath( hash ?? (typeof window !== "undefined" ? window.location.hash : ""), ); + if (raw === "monitor" || raw.startsWith("monitor/")) return "usage"; // Sub-views use a "/" suffix (e.g. #logs/debug); the first segment is the page id. const pageId = raw.split("/")[0] as Page; // Legacy: Debug used to be a standalone page; it now lives as a tab on Logs. @@ -109,6 +110,7 @@ export const INTEGRATION_TAB_HASHES = [ export function hashBelongsToPage(rawHash: string, page: Page): boolean { return rawHash === page + || (page === "usage" && ["usage/monitor", "usage/monitor/providers", "usage/monitor/settings"].includes(rawHash)) || (page === "logs" && rawHash === "logs/debug") || (page === "codex-set" && rawHash === "codex-set/prompt") || (page === "models" && (MODELS_TAB_HASHES as readonly string[]).includes(rawHash)) @@ -132,6 +134,9 @@ export type AppHashChangeAction = { */ export function resolveAppHashChange(rawHash: string): AppHashChangeAction { const nextPage = readPageFromHash(rawHash); + if (rawHash === "monitor" || rawHash.startsWith("monitor/")) { + return { page: "usage", replaceTo: rawHash === "monitor/providers" ? "usage/monitor/providers" : "usage/monitor" }; + } // Legacy: Debug used to be a standalone page. if (rawHash === "debug" || rawHash.startsWith("debug/")) { diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 8a48248ba7..21b7f018f0 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -5,6 +5,49 @@ import type { TKey } from "./en"; * German i18n catalog, generated from en.ts. Must match the `TKey` set (compile-checked). */ export const de: Record = { + "monitor.menuBarSettings": "Menüleisteneinstellungen", + "monitor.averageNote": "Values are per-series request averages/maxima; stacked height is not the average/maximum of all requests.", + "nav.monitor": "Graphs · accounts · display", + "monitor.showToday": "Today’s usage", + "monitor.showAccounts": "Account and provider usage", + "monitor.showModels": "Today’s model list", + "monitor.showCost": "Estimated cost", + "monitor.modelFilter": "Graph model filter", + "monitor.allModels": "All models (include new models)", + "monitor.displaySettings": "Anzeigeeinstellungen", + "monitor.title": "Local monitor", + "monitor.graph": "Token usage graph", + "monitor.empty": "No usage recorded for this range.", + "monitor.shortQuota": "Short window", + "monitor.weeklyQuota": "Weekly", + "monitor.settings": "Monitor settings", + "monitor.config": "Configuration JSON", + "monitor.unavailable": "The local monitor is unavailable.", + "monitor.invalidConfig": "Configuration must be valid JSON.", + "monitor.stale": "Settings changed elsewhere. Refresh and save again.", + "monitor.saveFailed": "Could not save monitor settings.", + "monitor.chartStyle": "Chart style", + "monitor.line": "Line", + "monitor.stackedBar": "Stacked bars", + "monitor.grouping": "Group by", + "monitor.model": "Model", + "monitor.modelAccount": "Model and account", + "monitor.hours": "History", + "monitor.bucket": "Bucket minutes", + "monitor.aggregation": "Aggregation", + "monitor.sum": "Sum", + "monitor.average": "Average", + "monitor.max": "Maximum", + "monitor.metric": "Token metric", + "monitor.total": "Total", + "monitor.input": "Input", + "monitor.output": "Output", + "monitor.cached": "Cached", + "monitor.display": "Show graph", + "monitor.on": "On", + "monitor.off": "Off", + "monitor.tokensAxis": "Tokens", + "monitor.timeAxis": "Time", "usage.incomplete": "Einige Nutzungsdatensätze konnten nicht berücksichtigt werden. Anzahlen, Datumsangaben und Ranglisten beruhen nur auf lesbaren Datensätzen.", "models.pickerOrder.usageIncomplete": "Die Reihenfolge nach Nutzung kann wegen unvollständiger Nutzungsdaten nicht gespeichert werden. Wählen Sie eine andere Reihenfolge oder reparieren Sie zuerst den Verlauf.", "api.attribution.noRecordedUse": "Keine Nutzung in lesbaren Datensätzen", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 9be7b42263..426f5a8349 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -6,6 +6,49 @@ * `{var}` are plain interpolations. */ export const en = { + "monitor.menuBarSettings": "Menu bar settings", + "monitor.averageNote": "Values are per-series request averages/maxima; stacked height is not the average/maximum of all requests.", + "nav.monitor": "Graphs · accounts · display", + "monitor.showToday": "Today’s usage", + "monitor.showAccounts": "Account and provider usage", + "monitor.showModels": "Today’s model list", + "monitor.showCost": "Estimated cost", + "monitor.modelFilter": "Graph model filter", + "monitor.allModels": "All models (include new models)", + "monitor.displaySettings": "Display settings", + "monitor.title": "Local monitor", + "monitor.graph": "Token usage graph", + "monitor.empty": "No usage recorded for this range.", + "monitor.shortQuota": "Short window", + "monitor.weeklyQuota": "Weekly", + "monitor.settings": "Monitor settings", + "monitor.config": "Configuration JSON", + "monitor.unavailable": "The local monitor is unavailable.", + "monitor.invalidConfig": "Configuration must be valid JSON.", + "monitor.stale": "Settings changed elsewhere. Refresh and save again.", + "monitor.saveFailed": "Could not save monitor settings.", + "monitor.chartStyle": "Chart style", + "monitor.line": "Line", + "monitor.stackedBar": "Stacked bars", + "monitor.grouping": "Group by", + "monitor.model": "Model", + "monitor.modelAccount": "Model and account", + "monitor.hours": "History", + "monitor.bucket": "Bucket minutes", + "monitor.aggregation": "Aggregation", + "monitor.sum": "Sum", + "monitor.average": "Average", + "monitor.max": "Maximum", + "monitor.metric": "Token metric", + "monitor.total": "Total", + "monitor.input": "Input", + "monitor.output": "Output", + "monitor.cached": "Cached", + "monitor.display": "Show graph", + "monitor.on": "On", + "monitor.off": "Off", + "monitor.tokensAxis": "Tokens", + "monitor.timeAxis": "Time", "usage.incomplete": "Some usage records could not be included. Counts, dates, and rankings reflect readable records only.", "models.pickerOrder.usageIncomplete": "Cannot save most-used order because usage history is incomplete. Choose another order or repair the history first.", "api.attribution.noRecordedUse": "No use in readable records", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 9c119d17d3..7b6f4702eb 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -4,6 +4,49 @@ import type { TKey } from "./en"; * French i18n catalog. Must match the `TKey` set. */ export const fr: Record = { + "monitor.menuBarSettings": "Paramètres de la barre des menus", + "monitor.averageNote": "Les valeurs sont des moyennes ou maxima par requête et par série ; la hauteur empilée ne représente pas la moyenne ou le maximum de toutes les requêtes.", + "nav.monitor": "Graphiques, comptes et affichage", + "monitor.showToday": "Utilisation du jour", + "monitor.showAccounts": "Utilisation des comptes et fournisseurs", + "monitor.showModels": "Modèles utilisés aujourd’hui", + "monitor.showCost": "Coût estimé", + "monitor.modelFilter": "Filtrer les modèles du graphique", + "monitor.allModels": "Tous les modèles, y compris les nouveaux", + "monitor.displaySettings": "Paramètres d’affichage", + "monitor.title": "Moniteur local", + "monitor.graph": "Graphique des jetons utilisés", + "monitor.empty": "Aucune utilisation enregistrée sur cette période.", + "monitor.shortQuota": "Période courte", + "monitor.weeklyQuota": "Hebdomadaire", + "monitor.settings": "Paramètres du moniteur", + "monitor.config": "Configuration au format JSON", + "monitor.unavailable": "Le moniteur local est indisponible.", + "monitor.invalidConfig": "La configuration doit être un document JSON valide.", + "monitor.stale": "Les paramètres ont été modifiés ailleurs. Actualisez puis enregistrez à nouveau.", + "monitor.saveFailed": "Impossible d’enregistrer les paramètres du moniteur.", + "monitor.chartStyle": "Type de graphique", + "monitor.line": "Courbe", + "monitor.stackedBar": "Barres empilées", + "monitor.grouping": "Regrouper par", + "monitor.model": "Modèle", + "monitor.modelAccount": "Modèle et compte", + "monitor.hours": "Historique", + "monitor.bucket": "Intervalle en minutes", + "monitor.aggregation": "Méthode de calcul", + "monitor.sum": "Somme", + "monitor.average": "Moyenne", + "monitor.max": "Valeur maximale", + "monitor.metric": "Mesure des jetons", + "monitor.total": "Total des jetons", + "monitor.input": "Entrée", + "monitor.output": "Sortie", + "monitor.cached": "En cache", + "monitor.display": "Afficher le graphique", + "monitor.on": "Activé", + "monitor.off": "Désactivé", + "monitor.tokensAxis": "Jetons", + "monitor.timeAxis": "Temps", "usage.incomplete": "Certains enregistrements d’utilisation n’ont pas pu être inclus. Les totaux, dates et classements reposent uniquement sur les enregistrements lisibles.", "models.pickerOrder.usageIncomplete": "Impossible d’enregistrer l’ordre par utilisation : l’historique est incomplet. Choisissez un autre ordre ou réparez d’abord l’historique.", "api.attribution.noRecordedUse": "Aucune utilisation dans les enregistrements lisibles", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index dd283a9299..11ffd1b973 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -4,6 +4,49 @@ import type { TKey } from "./en"; * Japanese i18n catalog; must match the `TKey` set (compile-checked). */ export const ja: Record = { + "monitor.menuBarSettings": "メニューバー設定", + "monitor.averageNote": "Values are per-series request averages/maxima; stacked height is not the average/maximum of all requests.", + "nav.monitor": "Graphs · accounts · display", + "monitor.showToday": "Today’s usage", + "monitor.showAccounts": "Account and provider usage", + "monitor.showModels": "Today’s model list", + "monitor.showCost": "Estimated cost", + "monitor.modelFilter": "Graph model filter", + "monitor.allModels": "All models (include new models)", + "monitor.displaySettings": "表示設定", + "monitor.title": "Local monitor", + "monitor.graph": "Token usage graph", + "monitor.empty": "No usage recorded for this range.", + "monitor.shortQuota": "Short window", + "monitor.weeklyQuota": "Weekly", + "monitor.settings": "Monitor settings", + "monitor.config": "Configuration JSON", + "monitor.unavailable": "The local monitor is unavailable.", + "monitor.invalidConfig": "Configuration must be valid JSON.", + "monitor.stale": "Settings changed elsewhere. Refresh and save again.", + "monitor.saveFailed": "Could not save monitor settings.", + "monitor.chartStyle": "Chart style", + "monitor.line": "Line", + "monitor.stackedBar": "Stacked bars", + "monitor.grouping": "Group by", + "monitor.model": "Model", + "monitor.modelAccount": "Model and account", + "monitor.hours": "History", + "monitor.bucket": "Bucket minutes", + "monitor.aggregation": "Aggregation", + "monitor.sum": "Sum", + "monitor.average": "Average", + "monitor.max": "Maximum", + "monitor.metric": "Token metric", + "monitor.total": "Total", + "monitor.input": "Input", + "monitor.output": "Output", + "monitor.cached": "Cached", + "monitor.display": "Show graph", + "monitor.on": "On", + "monitor.off": "Off", + "monitor.tokensAxis": "Tokens", + "monitor.timeAxis": "Time", "usage.incomplete": "一部の使用履歴を集計できませんでした。回数、日付、順位は読み取れる記録のみを反映しています。", "models.pickerOrder.usageIncomplete": "使用履歴が不完全なため、使用回数順を保存できません。別の順序を選ぶか、履歴を修復してください。", "api.attribution.noRecordedUse": "読み取れる記録に使用履歴なし", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 1a1909a74f..fab3c71151 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -4,6 +4,49 @@ import type { TKey } from "./en"; * Korean i18n catalog; must match the `TKey` set (compile-checked). */ export const ko: Record = { + "monitor.menuBarSettings": "상단바 설정", + "monitor.averageNote": "각 범례의 요청당 평균/최대입니다. 누적 막대 전체 높이는 전체 요청의 평균/최대가 아닙니다.", + "nav.monitor": "그래프·계정·표시 설정", + "monitor.showToday": "오늘 사용량", + "monitor.showAccounts": "계정·프로바이더 사용량", + "monitor.showModels": "오늘 모델별 목록", + "monitor.showCost": "추정 비용", + "monitor.modelFilter": "그래프 모델 필터", + "monitor.allModels": "모든 모델 (새 모델 자동 포함)", + "monitor.displaySettings": "표시 설정", + "monitor.title": "로컬 모니터", + "monitor.graph": "토큰 사용량 그래프", + "monitor.empty": "이 기간에는 기록된 사용량이 없습니다.", + "monitor.shortQuota": "세션", + "monitor.weeklyQuota": "주간", + "monitor.settings": "그래프 · 계정 · 표시 설정", + "monitor.config": "문구 · 색상 · 크기 등 전체 설정 (JSON)", + "monitor.unavailable": "메뉴 막대의 OpenCodex Monitor를 실행하면 연결됩니다.", + "monitor.invalidConfig": "구성은 유효한 JSON이어야 합니다.", + "monitor.stale": "다른 곳에서 설정이 바뀌었습니다. 새로 고친 뒤 다시 저장하세요.", + "monitor.saveFailed": "모니터 설정을 저장하지 못했습니다.", + "monitor.chartStyle": "그래프 형태", + "monitor.line": "선", + "monitor.stackedBar": "누적 막대", + "monitor.grouping": "범례", + "monitor.model": "모델별", + "monitor.modelAccount": "모델 + 계정별", + "monitor.hours": "조회 범위 (시간)", + "monitor.bucket": "집계 간격 (분)", + "monitor.aggregation": "계산 방식", + "monitor.sum": "토큰 합계", + "monitor.average": "요청당 평균", + "monitor.max": "요청당 최대", + "monitor.metric": "토큰 종류", + "monitor.total": "전체", + "monitor.input": "입력", + "monitor.output": "출력", + "monitor.cached": "캐시 읽기", + "monitor.display": "그래프 표시", + "monitor.on": "켜기", + "monitor.off": "끄기", + "monitor.tokensAxis": "토큰 수", + "monitor.timeAxis": "시간", "usage.incomplete": "일부 사용량 기록을 집계하지 못했습니다. 횟수, 날짜, 순위는 읽을 수 있는 기록만 반영합니다.", "models.pickerOrder.usageIncomplete": "사용량 이력이 불완전해 많이 사용한 순서를 저장할 수 없습니다. 다른 순서를 선택하거나 이력을 복구하세요.", "api.attribution.noRecordedUse": "읽을 수 있는 기록에 사용 내역 없음", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 485c0b302a..170d155de5 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -4,6 +4,49 @@ import type { TKey } from "./en"; * Russian i18n catalog; must match the `TKey` set (compile-checked). */ export const ru: Record = { + "monitor.menuBarSettings": "Настройки строки меню", + "monitor.averageNote": "Values are per-series request averages/maxima; stacked height is not the average/maximum of all requests.", + "nav.monitor": "Graphs · accounts · display", + "monitor.showToday": "Today’s usage", + "monitor.showAccounts": "Account and provider usage", + "monitor.showModels": "Today’s model list", + "monitor.showCost": "Estimated cost", + "monitor.modelFilter": "Graph model filter", + "monitor.allModels": "All models (include new models)", + "monitor.displaySettings": "Настройки отображения", + "monitor.title": "Local monitor", + "monitor.graph": "Token usage graph", + "monitor.empty": "No usage recorded for this range.", + "monitor.shortQuota": "Short window", + "monitor.weeklyQuota": "Weekly", + "monitor.settings": "Monitor settings", + "monitor.config": "Configuration JSON", + "monitor.unavailable": "The local monitor is unavailable.", + "monitor.invalidConfig": "Configuration must be valid JSON.", + "monitor.stale": "Settings changed elsewhere. Refresh and save again.", + "monitor.saveFailed": "Could not save monitor settings.", + "monitor.chartStyle": "Chart style", + "monitor.line": "Line", + "monitor.stackedBar": "Stacked bars", + "monitor.grouping": "Group by", + "monitor.model": "Model", + "monitor.modelAccount": "Model and account", + "monitor.hours": "History", + "monitor.bucket": "Bucket minutes", + "monitor.aggregation": "Aggregation", + "monitor.sum": "Sum", + "monitor.average": "Average", + "monitor.max": "Maximum", + "monitor.metric": "Token metric", + "monitor.total": "Total", + "monitor.input": "Input", + "monitor.output": "Output", + "monitor.cached": "Cached", + "monitor.display": "Show graph", + "monitor.on": "On", + "monitor.off": "Off", + "monitor.tokensAxis": "Tokens", + "monitor.timeAxis": "Time", "usage.incomplete": "Часть записей об использовании не удалось учесть. Счётчики, даты и рейтинги основаны только на читаемых записях.", "models.pickerOrder.usageIncomplete": "Нельзя сохранить порядок по частоте использования: история неполная. Выберите другой порядок или сначала восстановите историю.", "api.attribution.noRecordedUse": "В читаемых записях использование не найдено", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index a1d59281c9..60200967b0 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -5,6 +5,49 @@ import type { TKey } from "./en"; * Turkish i18n catalog. Must match the `TKey` set (compile-checked). */ export const tr: Record = { + "monitor.menuBarSettings": "Menü çubuğu ayarları", + "monitor.averageNote": "Values are per-series request averages/maxima; stacked height is not the average/maximum of all requests.", + "nav.monitor": "Graphs · accounts · display", + "monitor.showToday": "Today’s usage", + "monitor.showAccounts": "Account and provider usage", + "monitor.showModels": "Today’s model list", + "monitor.showCost": "Estimated cost", + "monitor.modelFilter": "Graph model filter", + "monitor.allModels": "All models (include new models)", + "monitor.displaySettings": "Görünüm ayarları", + "monitor.title": "Local monitor", + "monitor.graph": "Token usage graph", + "monitor.empty": "No usage recorded for this range.", + "monitor.shortQuota": "Short window", + "monitor.weeklyQuota": "Weekly", + "monitor.settings": "Monitor settings", + "monitor.config": "Configuration JSON", + "monitor.unavailable": "The local monitor is unavailable.", + "monitor.invalidConfig": "Configuration must be valid JSON.", + "monitor.stale": "Settings changed elsewhere. Refresh and save again.", + "monitor.saveFailed": "Could not save monitor settings.", + "monitor.chartStyle": "Chart style", + "monitor.line": "Line", + "monitor.stackedBar": "Stacked bars", + "monitor.grouping": "Group by", + "monitor.model": "Model", + "monitor.modelAccount": "Model and account", + "monitor.hours": "History", + "monitor.bucket": "Bucket minutes", + "monitor.aggregation": "Aggregation", + "monitor.sum": "Sum", + "monitor.average": "Average", + "monitor.max": "Maximum", + "monitor.metric": "Token metric", + "monitor.total": "Total", + "monitor.input": "Input", + "monitor.output": "Output", + "monitor.cached": "Cached", + "monitor.display": "Show graph", + "monitor.on": "On", + "monitor.off": "Off", + "monitor.tokensAxis": "Tokens", + "monitor.timeAxis": "Time", "usage.incomplete": "Bazı kullanım kayıtları dahil edilemedi. Sayılar, tarihler ve sıralamalar yalnızca okunabilir kayıtlara dayanır.", "models.pickerOrder.usageIncomplete": "Kullanım geçmişi eksik olduğundan en çok kullanılan sıralaması kaydedilemiyor. Başka bir sıralama seçin veya önce geçmişi onarın.", "api.attribution.noRecordedUse": "Okunabilir kayıtlarda kullanım yok", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index a6ef50b7f8..69987b03ca 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -6,6 +6,49 @@ import type { TKey } from "./en"; * Technical terms and model identifiers intentionally remain English. */ export const vi: Record = { + "monitor.menuBarSettings": "Cài đặt thanh menu", + "monitor.averageNote": "Values are per-series request averages/maxima; stacked height is not the average/maximum of all requests.", + "nav.monitor": "Graphs · accounts · display", + "monitor.showToday": "Today’s usage", + "monitor.showAccounts": "Account and provider usage", + "monitor.showModels": "Today’s model list", + "monitor.showCost": "Estimated cost", + "monitor.modelFilter": "Graph model filter", + "monitor.allModels": "All models (include new models)", + "monitor.displaySettings": "Display settings", + "monitor.title": "Local monitor", + "monitor.graph": "Token usage graph", + "monitor.empty": "No usage recorded for this range.", + "monitor.shortQuota": "Short window", + "monitor.weeklyQuota": "Weekly", + "monitor.settings": "Monitor settings", + "monitor.config": "Configuration JSON", + "monitor.unavailable": "The local monitor is unavailable.", + "monitor.invalidConfig": "Configuration must be valid JSON.", + "monitor.stale": "Settings changed elsewhere. Refresh and save again.", + "monitor.saveFailed": "Could not save monitor settings.", + "monitor.chartStyle": "Chart style", + "monitor.line": "Line", + "monitor.stackedBar": "Stacked bars", + "monitor.grouping": "Group by", + "monitor.model": "Model", + "monitor.modelAccount": "Model and account", + "monitor.hours": "History", + "monitor.bucket": "Bucket minutes", + "monitor.aggregation": "Aggregation", + "monitor.sum": "Sum", + "monitor.average": "Average", + "monitor.max": "Maximum", + "monitor.metric": "Token metric", + "monitor.total": "Total", + "monitor.input": "Input", + "monitor.output": "Output", + "monitor.cached": "Cached", + "monitor.display": "Show graph", + "monitor.on": "On", + "monitor.off": "Off", + "monitor.tokensAxis": "Tokens", + "monitor.timeAxis": "Time", "usage.incomplete": "Không thể đưa một số bản ghi sử dụng vào. Số lượng, ngày tháng và thứ hạng chỉ phản ánh các bản ghi có thể đọc được.", "models.pickerOrder.usageIncomplete": "Không thể lưu thứ tự dùng nhiều nhất vì lịch sử sử dụng không đầy đủ. Hãy chọn thứ tự khác hoặc sửa lịch sử trước.", "api.attribution.noRecordedUse": "Không có lượt sử dụng trong các bản ghi có thể đọc được", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 0ca62560f4..bd88c3fd00 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -2,6 +2,49 @@ import type { TKey } from "./en"; /** Traditional Chinese (Taiwan) UI strings — keys must match `en.ts` 1:1. */ export const zhTW: Record = { + "monitor.menuBarSettings": "選單列設定", + "monitor.averageNote": "數值為各序列每次請求的平均值或最大值;堆疊高度不代表所有請求的平均值或最大值。", + "nav.monitor": "圖表 · 帳戶 · 顯示", + "monitor.showToday": "今日用量", + "monitor.showAccounts": "帳戶與供應商用量", + "monitor.showModels": "今日模型清單", + "monitor.showCost": "預估費用", + "monitor.modelFilter": "篩選圖表模型", + "monitor.allModels": "所有模型(包含新模型)", + "monitor.displaySettings": "顯示設定", + "monitor.title": "本機監控", + "monitor.graph": "權杖用量圖表", + "monitor.empty": "此時段尚無用量紀錄。", + "monitor.shortQuota": "短期額度", + "monitor.weeklyQuota": "每週額度", + "monitor.settings": "監控設定", + "monitor.config": "JSON 設定", + "monitor.unavailable": "本機監控目前無法使用。", + "monitor.invalidConfig": "設定必須是有效的 JSON。", + "monitor.stale": "設定已在其他地方變更,請重新整理後再儲存。", + "monitor.saveFailed": "無法儲存監控設定。", + "monitor.chartStyle": "圖表類型", + "monitor.line": "折線圖", + "monitor.stackedBar": "堆疊長條圖", + "monitor.grouping": "分組依據", + "monitor.model": "模型", + "monitor.modelAccount": "模型與帳戶", + "monitor.hours": "歷史範圍", + "monitor.bucket": "時間間隔(分鐘)", + "monitor.aggregation": "計算方式", + "monitor.sum": "總和", + "monitor.average": "平均值", + "monitor.max": "最大值", + "monitor.metric": "權杖指標", + "monitor.total": "總計", + "monitor.input": "輸入", + "monitor.output": "輸出", + "monitor.cached": "快取", + "monitor.display": "顯示圖表", + "monitor.on": "開啟", + "monitor.off": "關閉", + "monitor.tokensAxis": "權杖數", + "monitor.timeAxis": "時間", "usage.incomplete": "部分用量記錄無法納入。次數、日期和排名僅反映可讀取的記錄。", "models.pickerOrder.usageIncomplete": "用量歷史不完整,無法儲存最常用排序。請選擇其他排序或先修復歷史記錄。", "api.attribution.noRecordedUse": "可讀取的記錄中沒有使用記錄", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 277cda2e64..146ac23309 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -4,6 +4,49 @@ import type { TKey } from "./en"; * Chinese i18n catalog; must match the `TKey` set (compile-checked). */ export const zh: Record = { + "monitor.menuBarSettings": "菜单栏设置", + "monitor.averageNote": "Values are per-series request averages/maxima; stacked height is not the average/maximum of all requests.", + "nav.monitor": "Graphs · accounts · display", + "monitor.showToday": "Today’s usage", + "monitor.showAccounts": "Account and provider usage", + "monitor.showModels": "Today’s model list", + "monitor.showCost": "Estimated cost", + "monitor.modelFilter": "Graph model filter", + "monitor.allModels": "All models (include new models)", + "monitor.displaySettings": "显示设置", + "monitor.title": "Local monitor", + "monitor.graph": "Token usage graph", + "monitor.empty": "No usage recorded for this range.", + "monitor.shortQuota": "Short window", + "monitor.weeklyQuota": "Weekly", + "monitor.settings": "Monitor settings", + "monitor.config": "Configuration JSON", + "monitor.unavailable": "The local monitor is unavailable.", + "monitor.invalidConfig": "Configuration must be valid JSON.", + "monitor.stale": "Settings changed elsewhere. Refresh and save again.", + "monitor.saveFailed": "Could not save monitor settings.", + "monitor.chartStyle": "Chart style", + "monitor.line": "Line", + "monitor.stackedBar": "Stacked bars", + "monitor.grouping": "Group by", + "monitor.model": "Model", + "monitor.modelAccount": "Model and account", + "monitor.hours": "History", + "monitor.bucket": "Bucket minutes", + "monitor.aggregation": "Aggregation", + "monitor.sum": "Sum", + "monitor.average": "Average", + "monitor.max": "Maximum", + "monitor.metric": "Token metric", + "monitor.total": "Total", + "monitor.input": "Input", + "monitor.output": "Output", + "monitor.cached": "Cached", + "monitor.display": "Show graph", + "monitor.on": "On", + "monitor.off": "Off", + "monitor.tokensAxis": "Tokens", + "monitor.timeAxis": "Time", "usage.incomplete": "部分用量记录无法计入。次数、日期和排名仅反映可读取的记录。", "models.pickerOrder.usageIncomplete": "用量历史不完整,无法保存最常用排序。请选择其他排序或先修复历史记录。", "api.attribution.noRecordedUse": "可读取的记录中没有使用记录", diff --git a/gui/src/pages/MonitorDashboardCard.tsx b/gui/src/pages/MonitorDashboardCard.tsx new file mode 100644 index 0000000000..586e2bc2ac --- /dev/null +++ b/gui/src/pages/MonitorDashboardCard.tsx @@ -0,0 +1,197 @@ +import { useEffect, useRef, useState } from "react"; +import { createBoundedFetch, type BoundedFetch } from "../bounded-fetch"; +import { Select } from "../ui"; +import { navigateHash } from "../hash-routing"; +import { useI18n } from "../i18n/shared"; +import { startVisibilityPoll } from "../visibility-poll"; +import { monitorNumberFields, monitorTemplateRows, monitorTemplate, monitorRemaining, monitorSeriesPaths, monitorStackedBars, type MonitorPoint } from "./monitor-dashboard-utils"; + +const MONITOR_BASE = "http://127.0.0.1:10101"; + +type MonitorConfig = Record; +type MonitorState = { + config: MonitorConfig; + revision: string; + graph?: { start: number; end: number; points: MonitorPoint[]; availableModels?: string[]; seriesSources?: Record; aggregation?: string; bucketSeconds?: number; missing?: number; skipped?: number; truncated?: boolean }; + usage?: { summary?: { requests?: number; totalTokens?: number; inputTokens?: number; outputTokens?: number; estimatedCostUsd?: number; unpricedRequests?: number; unmeteredRequests?: number }; models?: Array<{provider:string;model:string;requests:number;totalTokens:number}>; providers?: Array<{provider:string;requests:number;totalTokens:number}> }; + accounts?: Array<{ id: string; email?: string; alias?: string; logLabel?: string; plan?: string; name?: string }>; + quotas?: Record; + providerNames?: string[]; + providerWindows?: Record>; + providerErrors?: Record; + updated?: number; + failure?: string; + graphError?: string; + quotaError?: string; +}; + +function themeColor(value: unknown): string | undefined { + return value === "primary" ? "var(--text)" : value === "secondary" ? "var(--muted)" : typeof value === "string" && /^#[0-9a-f]{6}$/i.test(value) ? value : undefined; +} + +function QuotaBar({ label, value, reset, config }: { label: string; value: number | undefined; reset?: number; config: MonitorConfig }) { + const remaining = monitorRemaining(value, reset); + return
{label}
{remaining === null ? "—" : `${Math.round(remaining)}%`}
; +} + +function readMonitorTab() { return window.location.hash === "#usage/monitor/providers" ? "providers" : "usage"; } + +export function MonitorDashboardCard() { + const { locale, t } = useI18n(); + const [tab, setTab] = useState(readMonitorTab); + useEffect(() => { + const sync = () => setTab(readMonitorTab()); + window.addEventListener("hashchange", sync); + window.addEventListener("popstate", sync); + return () => { window.removeEventListener("hashchange", sync); window.removeEventListener("popstate", sync); }; + }, []); + const tabs = [{id:"usage",label:t("nav.usage")},{id:"providers",label:t("nav.providers")}]; + const selectTab = (id: string) => { setTab(id); navigateHash(id === "usage" ? "usage/monitor" : `usage/monitor/${id}`); }; + + const [state, setState] = useState(null); + const [draft, setDraft] = useState(""); + const [dirty, setDirty] = useState(false); + const [saving, setSaving] = useState(false); + const [connectionError, setConnectionError] = useState(null); + const [error, setError] = useState(null); + const dirtyRef = useRef(false); + const requestGeneration = useRef(0); + const revisionRef = useRef(null); + const refreshRef = useRef<() => Promise>(async () => undefined); + + useEffect(() => { dirtyRef.current = dirty; }, [dirty]); + useEffect(() => { + let cancelled = false; + let inFlight = false; + let active: BoundedFetch | null = null; + const refresh = async () => { + if (inFlight) return; + inFlight = true; + const generation = requestGeneration.current; + const bounded = createBoundedFetch(4_000); + active = bounded; + try { + const response = await fetch(`${MONITOR_BASE}/state`, { credentials: "omit", cache: "no-store", signal: bounded.signal }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const next = await response.json() as MonitorState; + if (cancelled || generation !== requestGeneration.current) return; + setState(next); + setConnectionError(null); + if (!dirtyRef.current) { + revisionRef.current = next.revision; + setDraft(JSON.stringify(next.config, null, 2)); + } + } catch { + if (!cancelled) setConnectionError(t("monitor.unavailable")); + } finally { + bounded.clear(); + if (active === bounded) active = null; + inFlight = false; + } + }; + refreshRef.current = refresh; + void refresh(); + const stop = startVisibilityPoll(() => void refresh(), 5_000); + return () => { cancelled = true; active?.controller.abort(); active?.clear(); stop(); }; + }, [t]); + + const save = async () => { + if (!state) return; + let config: MonitorConfig; + try { config = JSON.parse(draft) as MonitorConfig; if (!config || typeof config !== "object" || Array.isArray(config)) throw new Error(); } + catch { setError(t("monitor.invalidConfig")); return; } + requestGeneration.current += 1; + setSaving(true); setError(null); + const bounded = createBoundedFetch(6_000); + try { + const response = await fetch(`${MONITOR_BASE}/config`, { method: "POST", credentials: "omit", signal: bounded.signal, headers: { "Content-Type": "application/json", "X-Monitor-Request": "1" }, body: JSON.stringify({ config, revision: revisionRef.current ?? state.revision }) }); + if (response.status === 409) throw new Error(t("monitor.stale")); + if (!response.ok) throw new Error(t("monitor.saveFailed")); + const next = await response.json() as MonitorState | undefined; + requestGeneration.current += 1; + if (next?.config) { setState(next); revisionRef.current = next.revision; setDraft(JSON.stringify(next.config, null, 2)); } + dirtyRef.current = false; + setDirty(false); + await refreshRef.current(); + } catch (cause) { setError(cause instanceof Error ? cause.message : t("monitor.saveFailed")); } + finally { bounded.clear(); setSaving(false); } + }; + + let config = state?.config ?? {}; + try { const parsed: unknown = JSON.parse(draft); if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) config = parsed as MonitorConfig; } catch { /* retain the last valid server config while editing malformed JSON */ } + const setDraftConfig = (patch: MonitorConfig) => { + let current = config; + try { current = JSON.parse(draft) as MonitorConfig; } catch { /* preserve the raw invalid JSON for correction */ } + setDraft(JSON.stringify({ ...current, ...patch }, null, 2)); + dirtyRef.current = true; + setDirty(true); + }; + const show = (key: string) => config[key] !== false; + const chartHeight = typeof config.dashboardGraphHeight === "number" ? Math.min(800, Math.max(150, config.dashboardGraphHeight)) : 300; + const palette = Array.isArray(config.graphPalette) ? config.graphPalette.filter((color): color is string => typeof color === "string") : []; + const modelColors = typeof config.modelColors === "object" && config.modelColors ? config.modelColors as Record : {}; + const colorFor = (name: string, index: number) => themeColor(modelColors[name] ?? modelColors[state?.graph?.seriesSources?.[name] ?? ""] ?? palette[index % palette.length]) ?? `var(--monitor-${index % 6})`; + const selectedModels = Array.isArray(config.models) ? new Set(config.models) : null; + const hiddenProviders = new Set(Array.isArray(config.hiddenProviders) ? config.hiddenProviders.filter((name): name is string => typeof name === "string") : []); + const paths = monitorSeriesPaths(state?.graph?.points ?? [], 720, chartHeight, state?.graph); + const bars = monitorStackedBars(state?.graph?.points ?? [], 720, chartHeight, state?.graph); + const summary = state?.usage?.summary; + const fields = monitorNumberFields(summary ?? {}, locale); + const lines = (key: string) => Array.isArray(config[key]) ? (config[key] as unknown[]).filter((line): line is string => typeof line === "string") : []; + const buckets = new Map(); + let maximum = 1, start = Infinity, end = -Infinity; + for (const point of state?.graph?.points ?? []) { + start = Math.min(start, point.date); end = Math.max(end, point.date); + maximum = Math.max(maximum, point.tokens); + buckets.set(point.date, (buckets.get(point.date) ?? 0) + point.tokens); + } + if (config.chartStyle === "stackedBar") for (const total of buckets.values()) maximum = Math.max(maximum, total); + if (state?.graph && Number.isFinite(state.graph.start) && state.graph.end > state.graph.start) { start = state.graph.start; end = state.graph.end; } + else if (config.chartStyle === "stackedBar") end += state?.graph?.bucketSeconds ?? 0; + const axisNumber = (value: number) => new Intl.NumberFormat(locale, {notation:"compact", maximumFractionDigits:1}).format(value); + const timeLabel = (value: number) => new Date(value * 1000).toLocaleString(locale, {month:"numeric",day:"numeric",hour:"2-digit",minute:"2-digit"}); + + return <> +
+ {tabs.map((item,index) => )} +
+
+

{tabs.find(item => item.id === tab)?.label}

{state?.updated ? new Date(state.updated * 1000).toLocaleTimeString(locale) : t("common.loading")}
+ {connectionError &&

{connectionError}

} + {[state?.failure, state?.graphError, state?.quotaError].filter(Boolean).map((message,index) =>

{message}

)} + {error &&

{error}

} + {tab === "usage" && show("showToday") &&
+ {monitorTemplate(String(config.todayTitle ?? ""), fields)} +
{monitorTemplateRows(lines("todayLines")).map(line => {monitorTemplate(line.text, fields)})}
+ {show("showCost") &&
{monitorTemplateRows(lines("costLines")).map(line => {monitorTemplate(line.text, fields)})}
} +
} + {tab === "usage" && show("showChart") &&
+ {paths.length ? {t("monitor.graph")}{[0, 0.5, 1].map(ratio => {axisNumber(maximum*ratio)})}{Number.isFinite(start) && [0,0.5,1].map(ratio => {timeLabel(start+(end-start)*ratio)})}{t("monitor.tokensAxis")}{t("monitor.timeAxis")}{config.chartStyle === "stackedBar" ? bars.map((bar, index) => ) : paths.map((series, index) => )} :

{t("monitor.empty")}

} + {paths.length > 0 &&
{paths.map((series, index) => {series.name})}
} +
} + {tab === "usage" && show("showChart") && state?.graph && state.graph.aggregation !== "sum" &&

{t("monitor.averageNote")}

} + {tab === "providers" &&
{(state?.providerNames ?? []).map(name => )}
} + {tab === "providers" &&
+ {!hiddenProviders.has("openai") && (state?.accounts ?? []).map(account => { const quota = state?.quotas?.[account.id]; return
{account.name ?? account.email ?? account.id}{(account.email || account.plan) && {[account.email, account.plan].filter(Boolean).join(" · ")}}
; })} + {(state?.providerNames ?? []).filter(provider => provider !== "openai" && !hiddenProviders.has(provider)).map(provider =>
{provider}{monitorTemplate(String(config.providerUsageTemplate ?? ""), monitorNumberFields(state?.usage?.providers?.find(item => item.provider === provider) ?? {}, locale))}{typeof state?.providerErrors?.[provider] === "string" && {String(state.providerErrors[provider])}}{(state?.providerWindows?.[provider] ?? []).map(window => )}
)} +
} + {tab === "usage" && show("showModels") &&
{(state?.usage?.models ?? []).map(model =>
{model.provider}/{model.model}{monitorTemplate(String(config.providerUsageTemplate ?? ""),monitorNumberFields(model,locale))}
)}
} + {tab === "usage" &&

{t("monitor.displaySettings")}

+
{t("monitor.chartStyle")} setDraftConfig({chartGrouping:value})} options={[{value:"model",label:t("monitor.model")},{value:"modelAccount",label:t("monitor.modelAccount")}]} />
+
{t("monitor.hours")} setDraftConfig({ bucketMinutes: Math.min(1440, Math.max(1, Number(event.target.value) || 1)) })} /> +
{t("monitor.aggregation")} setDraftConfig({tokenMetric:value})} options={[{value:"total",label:t("monitor.total")},{value:"input",label:t("monitor.input")},{value:"output",label:t("monitor.output")},{value:"cached",label:t("monitor.cached")}]} />
+
{t("monitor.display")} setDraftConfig({[key]:event.target.checked})}/>{t(label)})}
{t("monitor.modelFilter")}
{(state?.graph?.availableModels ?? []).map(model => )}
{t("monitor.config")}