From 8ba8606295afa2477f48433d12fe22e761024d4c Mon Sep 17 00:00:00 2001 From: bbbugg Date: Thu, 23 Apr 2026 22:45:47 +0800 Subject: [PATCH 1/7] style: add usage stats download functionality to StatusPage --- src/routes/StatusRoutes.js | 22 ++++++++ ui/app/pages/StatusPage.vue | 101 +++++++++++++++++++++++++++++++++--- ui/locales/en.json | 3 ++ ui/locales/zh.json | 3 ++ 4 files changed, 123 insertions(+), 6 deletions(-) diff --git a/src/routes/StatusRoutes.js b/src/routes/StatusRoutes.js index 8540742..7a24636 100644 --- a/src/routes/StatusRoutes.js +++ b/src/routes/StatusRoutes.js @@ -182,6 +182,28 @@ class StatusRoutes { res.json(snapshot || UsageStatsService.createEmptySnapshot()); }); + app.get("/api/usage-stats/download", isAuthenticated, async (req, res) => { + try { + const usageStatsService = this.serverSystem.usageStatsService; + const statsFilePath = + usageStatsService?.statsFilePath || path.join(process.cwd(), "data", "usage-stats.jsonl"); + + if (usageStatsService?.appendPromise) { + await usageStatsService.appendPromise.catch(() => {}); + } + + if (!fs.existsSync(statsFilePath)) { + return res.status(404).json({ message: "usageStatsDownloadNoData" }); + } + + res.setHeader("Content-Type", "application/x-ndjson; charset=utf-8"); + res.download(statsFilePath, "usage-stats.jsonl"); + } catch (error) { + this.logger.error(`[WebUI] Failed to download usage stats: ${error.message}`); + res.status(500).json({ error: error.message, message: "usageStatsDownloadFailed" }); + } + }); + app.put("/api/accounts/current", isAuthenticated, async (req, res) => { try { if (this._rejectIfSystemBusy(res)) return; diff --git a/ui/app/pages/StatusPage.vue b/ui/app/pages/StatusPage.vue index 1f96d47..cb6d2ac 100644 --- a/ui/app/pages/StatusPage.vue +++ b/ui/app/pages/StatusPage.vue @@ -1541,6 +1541,30 @@

{{ t("usageStats") }}

+ { window.location.href = `/api/files/auth-${accountIndex}.json`; }; -// Download current logs -const downloadCurrentLogs = () => { - if (!state.logs) return; - - const blob = new Blob([state.logs], { type: "text/plain" }); +const formatDownloadTimestamp = () => { const now = new Date(); const YYYY = now.getFullYear(); const MM = String(now.getMonth() + 1).padStart(2, "0"); @@ -4697,7 +4718,61 @@ const downloadCurrentLogs = () => { const mm = String(now.getMinutes()).padStart(2, "0"); const ss = String(now.getSeconds()).padStart(2, "0"); - const filename = `AIStudioProxy_${YYYY}-${MM}-${DD}_${HH}${mm}${ss}_${state.logCount}.log`; + return `${YYYY}-${MM}-${DD}_${HH}${mm}${ss}`; +}; + +// Download persisted usage stats JSONL +const downloadUsageStats = async () => { + if (isDownloadingUsageStats.value) return; + isDownloadingUsageStats.value = true; + + try { + const res = await fetch("/api/usage-stats/download"); + if (res.redirected) { + window.location.href = res.url; + return; + } + if (res.status === 401) { + window.location.href = "/login"; + return; + } + if (!res.ok) { + let message = "usageStatsDownloadFailed"; + try { + const data = await res.json(); + message = data.message || message; + ElMessage.error(t(message, { error: data.error || `HTTP ${res.status}` })); + return; + } catch { + // Ignore non-JSON error responses. + } + ElMessage.error(t(message, { error: `HTTP ${res.status}` })); + return; + } + + const blob = await res.blob(); + const filename = `AIStudioToAPI_usage-stats_${formatDownloadTimestamp()}.jsonl`; + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (error) { + ElMessage.error(t("usageStatsDownloadFailed", { error: error.message })); + } finally { + isDownloadingUsageStats.value = false; + } +}; + +// Download current logs +const downloadCurrentLogs = () => { + if (!state.logs) return; + + const blob = new Blob([state.logs], { type: "text/plain" }); + const filename = `AIStudioToAPI_${formatDownloadTimestamp()}_${state.logCount}.log`; const url = URL.createObjectURL(blob); const a = document.createElement("a"); @@ -5685,6 +5760,20 @@ watchEffect(() => { } } +.stats-download-button { + justify-content: center; + width: 34px; + height: 34px; + padding: 0; + cursor: pointer; + font-family: inherit; + + &:disabled { + cursor: wait; + opacity: 0.65; + } +} + .time-range-select { width: 130px; diff --git a/ui/locales/en.json b/ui/locales/en.json index ae5e86c..8cdfd33 100644 --- a/ui/locales/en.json +++ b/ui/locales/en.json @@ -109,6 +109,7 @@ "disconnected": "Disconnected", "download": "Download Auth", "downloadLogs": "Download Logs", + "downloadUsageStats": "Download usage stats", "duplicateAuth": "Duplicated (Auto Ignored)", "duplicateAuthHint": "Duplicate, keep #{index}", "emptyValue": "Empty", @@ -266,6 +267,8 @@ "usageCount": "Usage Count", "usageFilters": "Usage Filters", "usageStats": "Usage Stats", + "usageStatsDownloadFailed": "Failed to download usage stats: {error}", + "usageStatsDownloadNoData": "No usage stats file is available to download.", "usernamePlaceholder": "Username", "versionInfo": "Version Info", "viewRelease": "View Release", diff --git a/ui/locales/zh.json b/ui/locales/zh.json index 9ff8f8b..56d986f 100644 --- a/ui/locales/zh.json +++ b/ui/locales/zh.json @@ -109,6 +109,7 @@ "disconnected": "连接中断", "download": "下载 Auth", "downloadLogs": "下载日志", + "downloadUsageStats": "下载统计数据", "duplicateAuth": "重复账号(已自动忽略)", "duplicateAuthHint": "重复,保留 #{index}", "emptyValue": "\u7a7a\u503c", @@ -266,6 +267,8 @@ "usageCount": "使用次数", "usageFilters": "\u7edf\u8ba1\u7b5b\u9009", "usageStats": "使用统计", + "usageStatsDownloadFailed": "下载统计数据失败:{error}", + "usageStatsDownloadNoData": "暂无可下载的统计数据文件。", "usernamePlaceholder": "用户名", "versionInfo": "版本信息", "viewRelease": "查看版本发布", From 6453d4ff22769ad05fad3e14cb1bb87c392b4de5 Mon Sep 17 00:00:00 2001 From: bbbugg Date: Fri, 24 Apr 2026 00:46:08 +0800 Subject: [PATCH 2/7] style: add usage stats import functionality to StatusPage --- src/core/UsageStatsService.js | 211 +++++++++++++++++++++++++++++++--- src/routes/StatusRoutes.js | 33 ++++++ ui/app/pages/StatusPage.vue | 111 +++++++++++++++++- ui/locales/en.json | 13 ++- ui/locales/zh.json | 13 ++- 5 files changed, 353 insertions(+), 28 deletions(-) diff --git a/src/core/UsageStatsService.js b/src/core/UsageStatsService.js index d9cf3a1..fed46b5 100644 --- a/src/core/UsageStatsService.js +++ b/src/core/UsageStatsService.js @@ -16,6 +16,7 @@ class UsageStatsService { this.statsFilePath = path.join(this.dataDir, "usage-stats.jsonl"); this.enabled = enabled !== false; this.appendPromise = Promise.resolve(); + this.isImportingStats = false; if (this.enabled) { // Ensure data directory exists @@ -131,6 +132,12 @@ class UsageStatsService { if (!tracker) return null; this.activeRequests.delete(requestId); + if (this.isImportingStats) { + if (this.logger) { + this.logger.info(`[UsageStats] Dropped request ${requestId} because stats import is in progress.`); + } + return null; + } const finishedAtMs = Date.now(); const lastAttempt = tracker.attempts[tracker.attempts.length - 1] || null; @@ -240,28 +247,42 @@ class UsageStatsService { }; } - _loadFromFile() { - if (!this.enabled) return; - try { - if (!fs.existsSync(this.statsFilePath)) return; + /** + * Public import entry point. Drops newly finished stats during import, waits + * for already queued appends, then rewrites the stats file from a stable baseline. + */ + importJsonl(content) { + if (!this.enabled) { + throw new Error("Usage stats are disabled"); + } + if (typeof content !== "string") { + throw new Error("Invalid JSONL content"); + } - const lines = fs.readFileSync(this.statsFilePath, "utf-8").split("\n").filter(Boolean); - this.records = []; - this.sequence = 0; + this.isImportingStats = true; - for (const line of lines) { - try { - const record = this._normalizeLoadedRecord(JSON.parse(line)); - this.records.push(record); - if (record.sequence > this.sequence) { - this.sequence = record.sequence; - } - } catch { - // Skip malformed lines - } - } + const importPromise = this.appendPromise + .catch(() => {}) + .then(() => this._importJsonlContent(content)) + .finally(() => { + this.isImportingStats = false; + }); + + this.appendPromise = importPromise.catch(() => {}); + return importPromise; + } + + /** + * Load persisted JSONL records during startup and rebuild derived in-memory + * aggregates from the records that can be parsed. + */ + _loadFromFile() { + try { + const { records } = this._readRecordsFromFile(); + if (records.length === 0 && !fs.existsSync(this.statsFilePath)) return; // Recalculate aggregates from all loaded records + this._replaceRecords(records); this._recalculateFromRecords(); if (this.logger) { @@ -287,6 +308,151 @@ class UsageStatsService { }); } + /** + * Import JSONL content, deduplicate by requestId, merge with current records, + * rewrite the file in finishedAt order, and rebuild memory state. + */ + _importJsonlContent(content) { + if (!fs.existsSync(this.dataDir)) { + fs.mkdirSync(this.dataDir, { recursive: true }); + } + + const { records: existingRecords } = this._readRecordsFromFile(); + const seenRequestIds = new Set(); + for (const record of existingRecords) { + const requestId = this._normalizeRequestId(record.requestId); + if (requestId) { + seenRequestIds.add(requestId); + } + } + + const importedRecords = []; + let duplicateCount = 0; + let invalidLineCount = 0; + let missingRequestIdCount = 0; + const lines = content.split(/\r?\n/); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + + let record; + try { + record = this._normalizeLoadedRecord(JSON.parse(trimmed)); + } catch { + invalidLineCount += 1; + continue; + } + + const requestId = this._normalizeRequestId(record.requestId); + if (!requestId) { + missingRequestIdCount += 1; + continue; + } + record.requestId = requestId; + + if (seenRequestIds.has(requestId)) { + duplicateCount += 1; + continue; + } + + seenRequestIds.add(requestId); + importedRecords.push(record); + } + + const mergedRecords = this._normalizeImportedRecords(existingRecords.concat(importedRecords)); + const fileContent = mergedRecords.map(record => JSON.stringify(record)).join("\n"); + fs.writeFileSync(this.statsFilePath, fileContent ? `${fileContent}\n` : ""); + this._replaceRecords(mergedRecords); + this._recalculateFromRecords(); + + if (this.logger) { + this.logger.info( + `[UsageStats] Imported ${importedRecords.length} records from JSONL. ` + + `Skipped ${duplicateCount} duplicates, ${invalidLineCount} invalid lines, ` + + `${missingRequestIdCount} without requestId.` + ); + } + + return { + duplicateCount, + importedCount: importedRecords.length, + invalidLineCount, + missingRequestIdCount, + totalRecords: mergedRecords.length, + }; + } + + /** + * Read valid records from the persisted usage-stats JSONL file. Malformed + * lines are ignored so one bad line does not prevent loading the rest. + */ + _readRecordsFromFile() { + const records = []; + if (!fs.existsSync(this.statsFilePath)) { + return { records }; + } + + const lines = fs.readFileSync(this.statsFilePath, "utf-8").split(/\r?\n/).filter(Boolean); + for (const line of lines) { + try { + records.push(this._normalizeLoadedRecord(JSON.parse(line))); + } catch { + // Skip malformed lines + } + } + + return { records }; + } + + /** + * Normalize imported data by finished time and assign continuous sequence + * numbers from 1..N after records from multiple files have been merged. + */ + _normalizeImportedRecords(records) { + return records + .map((record, originalIndex) => ({ originalIndex, record })) + .sort((a, b) => { + const aTime = this._getRecordSortTime(a.record); + const bTime = this._getRecordSortTime(b.record); + if (aTime !== bTime) return aTime - bTime; + return a.originalIndex - b.originalIndex; + }) + .map((item, index) => ({ + ...item.record, + sequence: index + 1, + })); + } + + /** + * Return the timestamp used for import ordering: finishedAt first, then + * startedAt, then the previous sequence as a final fallback. + */ + _getRecordSortTime(record) { + const finishedAtMs = Date.parse(record?.finishedAt); + if (Number.isFinite(finishedAtMs)) return finishedAtMs; + + const startedAtMs = Date.parse(record?.startedAt); + if (Number.isFinite(startedAtMs)) return startedAtMs; + + const sequence = Number(record?.sequence); + return Number.isFinite(sequence) ? sequence : Number.MAX_SAFE_INTEGER; + } + + /** + * Replace the in-memory record list and reset sequence to the highest record + * sequence so new records continue after the current data set. + */ + _replaceRecords(records) { + this.records = records; + this.sequence = 0; + for (const record of records) { + if (record.sequence > this.sequence) { + this.sequence = record.sequence; + } + } + } + _recalculateFromRecords() { this.startedAtMs = Date.now(); this.startedAt = new Date(this.startedAtMs).toISOString(); @@ -400,6 +566,15 @@ class UsageStatsService { }; } + /** + * Normalize requestId for import deduplication. Non-string IDs are treated + * as missing so they cannot be used as merge keys. + */ + _normalizeRequestId(value) { + if (typeof value !== "string") return ""; + return value.trim(); + } + _normalizeAuthIndex(value) { return Number.isInteger(value) && value >= 0 ? value : null; } diff --git a/src/routes/StatusRoutes.js b/src/routes/StatusRoutes.js index 7a24636..3cd1fa2 100644 --- a/src/routes/StatusRoutes.js +++ b/src/routes/StatusRoutes.js @@ -185,6 +185,9 @@ class StatusRoutes { app.get("/api/usage-stats/download", isAuthenticated, async (req, res) => { try { const usageStatsService = this.serverSystem.usageStatsService; + if (!usageStatsService?.enabled) { + return res.status(403).json({ message: "usageStatsDisabled" }); + } const statsFilePath = usageStatsService?.statsFilePath || path.join(process.cwd(), "data", "usage-stats.jsonl"); @@ -204,6 +207,36 @@ class StatusRoutes { } }); + app.post("/api/usage-stats/import", isAuthenticated, async (req, res) => { + try { + const usageStatsService = this.serverSystem.usageStatsService; + if (!usageStatsService?.enabled) { + return res.status(403).json({ message: "usageStatsDisabled" }); + } + + const { content, filename } = req.body || {}; + if (typeof filename !== "string" || !filename.toLowerCase().endsWith(".jsonl")) { + return res.status(400).json({ message: "usageStatsImportJsonlOnly" }); + } + if (typeof content !== "string") { + return res.status(400).json({ message: "usageStatsImportInvalidFile" }); + } + + const result = await usageStatsService.importJsonl(content); + res.json({ + duplicateCount: result.duplicateCount, + importedCount: result.importedCount, + invalidLineCount: result.invalidLineCount, + message: "usageStatsImportSuccess", + missingRequestIdCount: result.missingRequestIdCount, + totalRecords: result.totalRecords, + }); + } catch (error) { + this.logger.error(`[WebUI] Failed to import usage stats: ${error.message}`); + res.status(500).json({ error: error.message, message: "usageStatsImportFailed" }); + } + }); + app.put("/api/accounts/current", isAuthenticated, async (req, res) => { try { if (this._rejectIfSystemBusy(res)) return; diff --git a/ui/app/pages/StatusPage.vue b/ui/app/pages/StatusPage.vue index cb6d2ac..1cf6cd5 100644 --- a/ui/app/pages/StatusPage.vue +++ b/ui/app/pages/StatusPage.vue @@ -1541,12 +1541,43 @@

{{ t("usageStats") }}

+ +