diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 960895d..b0e644e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -36,6 +36,21 @@ The entire extension's logic is contained within the `CopilotTokenTracker` class - **Focused Modifications**: Make surgical, precise changes that address the specific requirements without affecting other functionality. - **Preserve Existing Structure**: Maintain the existing code organization and file structure. Don't refactor or reorganize code unless it's essential for the task. +## Logging Best Practices + +**CRITICAL**: Do NOT add debug logging statements like `log('[DEBUG] message')` for troubleshooting during development. This approach has been found to interfere with the output channel and can hide existing log messages from appearing. + +- **Use Existing Logs**: The extension already has comprehensive logging throughout. Review existing log statements to understand what's being tracked. +- **Minimal Logging**: Only add logging if absolutely necessary for a new feature. Keep messages concise and informative. +- **Remove Debug Logs**: Any temporary debug logging added during development MUST be removed before committing code. +- **Log Methods**: Use the appropriate method for the severity: + - `log(message)` - Standard informational messages + - `warn(message)` - Warnings or recoverable errors + - `error(message)` - Critical errors +- **No Debug Prefixes**: Avoid prefixing messages with `[DEBUG]` or similar markers. The log output is already timestamped and categorized. + +If you need to troubleshoot execution flow, prefer using VS Code's debugger with breakpoints rather than adding log statements. + ## Key Files & Conventions - **`src/extension.ts`**: The single source file containing all logic. diff --git a/src/backend/facade.ts b/src/backend/facade.ts index acebb69..5b8d47b 100644 --- a/src/backend/facade.ts +++ b/src/backend/facade.ts @@ -76,7 +76,8 @@ export class BackendFacade { getCopilotSessionFiles: deps.getCopilotSessionFiles, estimateTokensFromText: deps.estimateTokensFromText, getModelFromRequest: deps.getModelFromRequest, - getSessionFileDataCached: deps.getSessionFileDataCached + getSessionFileDataCached: deps.getSessionFileDataCached, + updateTokenStats: deps.updateTokenStats }, this.credentialService, this.dataPlaneService, @@ -226,6 +227,7 @@ export class BackendFacade { const settings = this.getSettings(); const result = await this.syncService.syncToBackendStore(force, settings, this.isConfigured(settings)); this.clearQueryCache(); + // UI update is now handled by syncService after successful completion return result; } @@ -478,8 +480,8 @@ export class BackendFacade { const next = applyDraftToSettings(previousSettings, draft, consentAt); await this.updateConfiguration(next); this.startTimerIfEnabled(); - this.deps.updateTokenStats?.(); this.clearQueryCache(); + // UI update happens automatically after sync completes via syncService callback return { state: await this.getConfigPanelState(), message: 'Settings saved.' }; } diff --git a/src/backend/services/syncService.ts b/src/backend/services/syncService.ts index 0ab2cfc..c2c1c2a 100644 --- a/src/backend/services/syncService.ts +++ b/src/backend/services/syncService.ts @@ -60,6 +60,8 @@ export interface SyncServiceDeps { getModelFromRequest: (request: ChatRequest) => string; // Cache integration for performance getSessionFileDataCached?: (sessionFilePath: string, mtime: number) => Promise; + // UI refresh callback after successful sync + updateTokenStats?: () => Promise; } /** @@ -128,8 +130,8 @@ export class SyncService { } }); }, intervalMs); - // Immediate initial sync - this.syncToBackendStore(false, settings, isConfigured).catch((e) => { + // Immediate initial sync (forced to ensure settings changes take effect) + this.syncToBackendStore(true, settings, isConfigured).catch((e) => { this.deps.warn(`Backend sync initial sync failed: ${e?.message ?? e}`); }); } catch (e) { @@ -163,9 +165,13 @@ export class SyncService { } /** - * Process a session file using cached data. + * Process a session file using cached data for token counts but extracting accurate timestamps. * Returns true if successful, false if cache miss (caller should parse file). * Validates all cached data at runtime to prevent injection/corruption. + * + * CRITICAL: We parse the file to extract actual interaction timestamps and create per-day + * rollups, but use cached token counts for performance. This ensures accurate day assignment + * while still benefiting from cached calculations. */ private async processCachedSessionFile( sessionFile: string, @@ -173,7 +179,9 @@ export class SyncService { workspaceId: string, machineId: string, userId: string | undefined, - rollups: Map + rollups: Map, + startMs: number, + now: Date ): Promise { try { const cachedData = await this.deps.getSessionFileDataCached!(sessionFile, fileMtimeMs); @@ -192,39 +200,123 @@ export class SyncService { return false; } - // Expand cached modelUsage into rollups - const dayKey = this.utility.toUtcDayKey(new Date(fileMtimeMs)); + // Parse the session file to get actual request timestamps and create per-day rollups + // This ensures accurate day assignment while using cached token counts + const content = await fs.promises.readFile(sessionFile, 'utf8'); - // CRITICAL FIX: Only assign interactions to first model to prevent inflation - // When a file has multiple models, interactions should be counted once, not per-model - let interactionsAssigned = false; + // Map to track per-day per-model interactions for proper distribution + const dayModelInteractions = new Map>(); - for (const [model, usage] of Object.entries(cachedData.modelUsage)) { - // Validate usage object structure - if (!usage || typeof usage !== 'object') { - this.deps.warn(`Backend sync: invalid usage object for model ${model} in ${sessionFile}`); - continue; + // Handle JSONL format (Copilot CLI) + if (sessionFile.endsWith('.jsonl')) { + const lines = content.trim().split('\n'); + const todayKey = this.utility.toUtcDayKey(now); + let lineCount = 0; + let processedLines = 0; + + for (const line of lines) { + lineCount++; + if (!line.trim()) { continue; } + try { + const event = JSON.parse(line); + if (!event || typeof event !== 'object') { continue; } + + const normalizedTs = this.utility.normalizeTimestampToMs(event.timestamp); + const eventMs = Number.isFinite(normalizedTs) ? normalizedTs : fileMtimeMs; + if (!eventMs || eventMs < startMs) { continue; } + + const dayKey = this.utility.toUtcDayKey(new Date(eventMs)); + const model = (event.model || 'gpt-4o').toString(); + const isFileFromToday = dayKey === todayKey; + if (isFileFromToday && processedLines < 3) { + this.deps.log(`Backend sync: file ${sessionFile.split(/[/\\]/).pop()} line ${lineCount}: eventMs=${new Date(eventMs).toISOString()}, dayKey=${dayKey}, type=${event.type}`); + processedLines++; } - if (!Number.isFinite(usage.inputTokens) || usage.inputTokens < 0) { - this.deps.warn(`Backend sync: invalid inputTokens for model ${model} in ${sessionFile}`); - continue; + // Track interaction for this day+model (count all events, not just user.message) + if (!dayModelInteractions.has(dayKey)) { + dayModelInteractions.set(dayKey, new Map()); + } + const dayMap = dayModelInteractions.get(dayKey)!; + dayMap.set(model, (dayMap.get(model) || 0) + 1); + } catch { + // skip malformed line + } } - if (!Number.isFinite(usage.outputTokens) || usage.outputTokens < 0) { - this.deps.warn(`Backend sync: invalid outputTokens for model ${model} in ${sessionFile}`); - continue; + } else { + // Handle JSON format (VS Code Copilot Chat) + try { + const sessionJson = JSON.parse(content); + if (!sessionJson || typeof sessionJson !== 'object') { + return false; + } + const sessionObj = sessionJson as Record; + const requests = Array.isArray(sessionObj.requests) ? (sessionObj.requests as unknown[]) : []; + + for (const request of requests) { + const req = request as ChatRequest; + const normalizedTs = this.utility.normalizeTimestampToMs( + typeof req.timestamp !== 'undefined' ? req.timestamp : (sessionObj.lastMessageDate as unknown) + ); + const eventMs = Number.isFinite(normalizedTs) ? normalizedTs : fileMtimeMs; + if (!eventMs || eventMs < startMs) { continue; } + + const dayKey = this.utility.toUtcDayKey(new Date(eventMs)); + const model = this.deps.getModelFromRequest(req); + + + // Track interaction for this day+model + if (!dayModelInteractions.has(dayKey)) { + dayModelInteractions.set(dayKey, new Map()); + } + const dayMap = dayModelInteractions.get(dayKey)!; + dayMap.set(model, (dayMap.get(model) || 0) + 1); + } + } catch (e) { + this.deps.warn(`Backend sync: failed to parse JSON for ${sessionFile}: ${e}`); + return false; } - - const key: DailyRollupKey = { day: dayKey, model, workspaceId, machineId, userId }; - // Only assign interactions to the first valid model to prevent inflation - const interactionsForThisModel = interactionsAssigned ? 0 : cachedData.interactions; - interactionsAssigned = true; - - upsertDailyRollup(rollups, key, { - inputTokens: usage.inputTokens, - outputTokens: usage.outputTokens, - interactions: interactionsForThisModel - }); } + + // Now distribute cached token counts proportionally across day+model combinations + // based on the actual interaction distribution we just calculated + for (const [dayKey, modelMap] of dayModelInteractions) { + for (const [model, interactions] of modelMap) { + const cachedUsage = cachedData.modelUsage[model]; + if (!cachedUsage) { continue; } + + // Validate usage object structure + if (!Number.isFinite(cachedUsage.inputTokens) || cachedUsage.inputTokens < 0) { + this.deps.warn(`Backend sync: invalid inputTokens for model ${model}`); + continue; + } + if (!Number.isFinite(cachedUsage.outputTokens) || cachedUsage.outputTokens < 0) { + this.deps.warn(`Backend sync: invalid outputTokens for model ${model}`); + continue; + } + + const key: DailyRollupKey = { day: dayKey, model, workspaceId, machineId, userId }; + + // For simplicity, if a file spans multiple days, distribute tokens proportionally + // In practice, most session files are from a single day, so this is accurate + const totalInteractionsForModel = Array.from(dayModelInteractions.values()) + .reduce((sum, m) => sum + (m.get(model) || 0), 0); + + const tokenRatio = totalInteractionsForModel > 0 ? interactions / totalInteractionsForModel : 1; + + upsertDailyRollup(rollups, key, { + inputTokens: Math.round(cachedUsage.inputTokens * tokenRatio), + outputTokens: Math.round(cachedUsage.outputTokens * tokenRatio), + interactions: interactions + }); + } + } + + // Log if this file had data for multiple days (for debugging) + if (dayModelInteractions.size > 1) { + const days = Array.from(dayModelInteractions.keys()).sort(); + this.deps.log(`Backend sync: file ${sessionFile.split(/[/\\]/).pop()} spans ${days.length} days: ${days.join(', ')}`); + } + return true; } catch (e) { // Differentiate between cache miss (expected) and errors (unexpected) @@ -308,6 +400,11 @@ export class SyncService { start.setUTCHours(0, 0, 0, 0); start.setUTCDate(start.getUTCDate() - (lookbackDays - 1)); const startMs = start.getTime(); + + // Log the date range being processed + const todayKey = this.utility.toUtcDayKey(now); + const startKey = this.utility.toUtcDayKey(start); + this.deps.log(`Backend sync: processing sessions from ${startKey} to ${todayKey} (lookback ${lookbackDays} days)`); const machineId = vscode.env.machineId; const rollups = new Map(); @@ -322,6 +419,10 @@ export class SyncService { const useCachedData = !!this.deps.getSessionFileDataCached; let cacheHits = 0; let cacheMisses = 0; + let filesSkipped = 0; + let filesProcessed = 0; + + this.deps.log(`Backend sync: analyzing ${sessionFiles.length} session files`); for (const sessionFile of sessionFiles) { let fileMtimeMs: number | undefined; @@ -330,10 +431,13 @@ export class SyncService { const fileStat = await fs.promises.stat(sessionFile); fileMtimeMs = fileStat.mtimeMs; + // Skip files older than lookback period if (fileMtimeMs < startMs) { + filesSkipped++; continue; } + filesProcessed++; } catch (e) { this.deps.warn(`Backend sync: failed to stat session file ${sessionFile}: ${e}`); continue; @@ -342,7 +446,9 @@ export class SyncService { const workspaceId = this.utility.extractWorkspaceIdFromSessionPath(sessionFile); await this.ensureWorkspaceNameResolved(workspaceId, sessionFile, workspaceNamesById); - // Try to use cached data first (much faster than parsing) + // Try to use cached data first (faster than full recomputation) + // Note: We still parse the file to get accurate day keys from timestamps, + // but use cached token counts for performance if (useCachedData) { const cacheSuccess = await this.processCachedSessionFile( sessionFile, @@ -350,7 +456,9 @@ export class SyncService { workspaceId, machineId, userId, - rollups + rollups, + startMs, + now ); if (cacheSuccess) { @@ -474,6 +582,8 @@ export class SyncService { if (useCachedData) { this.logCachePerformance(cacheHits, cacheMisses); } + + this.deps.log(`Backend sync: processed ${filesProcessed} files, skipped ${filesSkipped} files outside lookback period`); return { rollups, workspaceNamesById, machineNamesById }; } @@ -502,6 +612,8 @@ export class SyncService { // Avoid excessive syncing when UI refreshes frequently. const lastSyncAt = this.deps.context?.globalState.get('backend.lastSyncAt'); if (!force && lastSyncAt && Date.now() - lastSyncAt < BACKEND_SYNC_MIN_INTERVAL_MS) { + const secondsSinceLastSync = Math.round((Date.now() - lastSyncAt) / 1000); + this.deps.log(`Backend sync: skipping (last sync was ${secondsSinceLastSync}s ago, minimum interval is ${BACKEND_SYNC_MIN_INTERVAL_MS / 1000}s)`); return; } @@ -518,6 +630,17 @@ export class SyncService { const resolvedIdentity = await this.resolveEffectiveUserIdentityForSync(settings, sharingPolicy.includeUserDimension); const { rollups, workspaceNamesById, machineNamesById } = await this.computeDailyRollupsFromLocalSessions({ lookbackDays: settings.lookbackDays, userId: resolvedIdentity.userId }); + + // Log day keys being synced for better visibility + const dayKeys = new Set(); + for (const { key } of rollups.values()) { + dayKeys.add(key.day); + } + const sortedDays = Array.from(dayKeys).sort(); + if (sortedDays.length > 0) { + this.deps.log(`Backend sync: processing data for ${sortedDays.length} days: ${sortedDays.join(', ')}`); + } + this.deps.log(`Backend sync: upserting ${rollups.size} rollup entities (lookback ${settings.lookbackDays} days)`); const tableClient = this.dataPlaneService.createTableClient(settings, creds.tableCredential); @@ -570,6 +693,13 @@ export class SyncService { } this.deps.log('Backend sync: completed'); + + // Trigger UI refresh to update status bar and panels with latest sync data + try { + await this.deps.updateTokenStats?.(); + } catch (e) { + this.deps.warn(`Backend sync: failed to update UI: ${e}`); + } } catch (e: any) { // Keep local mode functional. const secretsToRedact = await this.credentialService.getBackendSecretsToRedactForError(settings); diff --git a/src/extension.ts b/src/extension.ts index bffac67..be25ca1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,6 +4,8 @@ import * as path from 'path'; import * as os from 'os'; import tokenEstimatorsData from './tokenEstimators.json'; import modelPricingData from './modelPricing.json'; +import { BackendFacade } from './backend/facade'; +import { BackendCommandHandler } from './backend/commands'; import * as packageJson from '../package.json'; import {getModelDisplayName} from './webview/shared/modelUtils'; @@ -366,8 +368,6 @@ class CopilotTokenTracker implements vscode.Disposable { public async clearCache(): Promise { try { - // Show the output channel so users can see what's happening - this.outputChannel.show(true); this.log('[DEBUG] clearCache() called'); this.log('Clearing session file cache...'); @@ -386,7 +386,6 @@ class CopilotTokenTracker implements vscode.Disposable { await this.updateTokenStats(); this.log('Token statistics reloaded successfully.'); } catch (error) { - this.outputChannel.show(true); this.error('Error clearing cache:', error); vscode.window.showErrorMessage('Failed to clear cache: ' + error); } @@ -397,6 +396,8 @@ class CopilotTokenTracker implements vscode.Disposable { this.context = context; // Create output channel for extension logs this.outputChannel = vscode.window.createOutputChannel('GitHub Copilot Token Tracker'); + // CRITICAL: Add output channel to context.subscriptions so VS Code doesn't dispose it + context.subscriptions.push(this.outputChannel); this.log('Constructor called'); // Load persisted cache from storage @@ -3128,38 +3129,9 @@ class CopilotTokenTracker implements vscode.Disposable { dirCounts.set(editorRoot, (dirCounts.get(editorRoot) || 0) + 1); } const sessionFolders = Array.from(dirCounts.entries()).map(([dir, count]) => ({ dir, count, editorName: this.getEditorTypeFromPath(dir) })); - // Update the existing diagnostics webview without replacing its HTML - try { - await this.diagnosticsPanel.webview.postMessage({ - command: 'updateDiagnosticsReport', - report, - sessionFileData, - sessionFolders - }); - this.log('Diagnostics webview updated via postMessage'); - } catch (err) { - this.log('Could not post update to diagnostics webview, falling back to reset'); - this.log(err instanceof Error ? err.message : String(err)); - this.diagnosticsPanel.webview.html = this.getDiagnosticReportHtml(this.diagnosticsPanel.webview, report, sessionFileData, [], sessionFolders); - } - // If we have previously loaded session files, re-send them to the webview - if (this.diagnosticsHasLoadedFiles && this.diagnosticsCachedFiles.length > 0) { - try { - await this.diagnosticsPanel.webview.postMessage({ - command: 'sessionFilesLoaded', - detailedSessionFiles: this.diagnosticsCachedFiles - }); - this.log('Re-sent cached diagnostics session files to webview'); - } catch (err) { - this.log('Could not re-send cached session files to diagnostics webview'); - this.log(err instanceof Error ? err.message : String(err)); - } - } else { - // Only reload session files in background if we haven't already loaded them - if (!this.diagnosticsHasLoadedFiles) { - this.loadSessionFilesInBackground(this.diagnosticsPanel, sessionFiles); - } - } + const backendStorageInfo = await this.getBackendStorageInfo(); + this.diagnosticsPanel.webview.html = this.getDiagnosticReportHtml(this.diagnosticsPanel.webview, report, sessionFileData, [], sessionFolders, backendStorageInfo); + this.loadSessionFilesInBackground(this.diagnosticsPanel, sessionFiles); return; } @@ -3197,6 +3169,8 @@ class CopilotTokenTracker implements vscode.Disposable { } const sessionFolders = Array.from(dirCounts.entries()).map(([dir, count]) => ({ dir, count, editorName: this.getEditorNameFromRoot(dir) })); + const backendStorageInfo = await this.getBackendStorageInfo(); + this.diagnosticsPanel = vscode.window.createWebviewPanel( 'copilotTokenDiagnostics', 'Diagnostic Report', @@ -3214,11 +3188,11 @@ class CopilotTokenTracker implements vscode.Disposable { this.log('✅ Diagnostic Report created successfully'); // Set the HTML content immediately with empty session files (shows loading state) - this.diagnosticsPanel.webview.html = this.getDiagnosticReportHtml(this.diagnosticsPanel.webview, report, sessionFileData, [], sessionFolders); + this.diagnosticsPanel.webview.html = this.getDiagnosticReportHtml(this.diagnosticsPanel.webview, report, sessionFileData, [], sessionFolders, backendStorageInfo); // Handle messages from the webview this.diagnosticsPanel.webview.onDidReceiveMessage(async (message) => { - this.log(`[DEBUG] Diagnostics webview message: ${JSON.stringify(message)}`); + this.log(`Diagnostics webview message: ${JSON.stringify(message)}`); switch (message.command) { case 'copyReport': await vscode.env.clipboard.writeText(report); @@ -3291,6 +3265,26 @@ class CopilotTokenTracker implements vscode.Disposable { await this.showDiagnosticReport(); } break; + case 'configureBackend': + // Execute the configureBackend command if it exists + try { + await vscode.commands.executeCommand('copilot-token-tracker.configureBackend'); + } catch (err) { + // If command is not registered, show settings + vscode.window.showInformationMessage( + 'Backend configuration is available in settings. Search for "Copilot Token Tracker: Backend" in settings.', + 'Open Settings' + ).then(choice => { + if (choice === 'Open Settings') { + vscode.commands.executeCommand('workbench.action.openSettings', 'copilotTokenTracker.backend'); + } + }); + } + break; + case 'openSettings': + this.log('[DEBUG] openSettings message received from diagnostics webview'); + await vscode.commands.executeCommand('workbench.action.openSettings', 'copilotTokenTracker.backend'); + break; } }); @@ -3375,12 +3369,67 @@ class CopilotTokenTracker implements vscode.Disposable { } } + /** + * Get backend storage information for diagnostics + */ + private async getBackendStorageInfo(): Promise { + const config = vscode.workspace.getConfiguration('copilotTokenTracker'); + const enabled = config.get('backend.enabled', false); + const storageAccount = config.get('backend.storageAccount', ''); + const subscriptionId = config.get('backend.subscriptionId', ''); + const resourceGroup = config.get('backend.resourceGroup', ''); + const aggTable = config.get('backend.aggTable', 'usageAggDaily'); + const eventsTable = config.get('backend.eventsTable', 'usageEvents'); + const authMode = config.get('backend.authMode', 'entraId'); + const sharingProfile = config.get('backend.sharingProfile', 'off'); + + // Get last sync time from global state + const lastSyncAt = this.context.globalState.get('backend.lastSyncAt'); + const lastSyncTime = lastSyncAt ? new Date(lastSyncAt).toISOString() : null; + + // Check if backend is configured (has required settings) + const isConfigured = enabled && storageAccount && subscriptionId && resourceGroup; + + // Get unique device count from session files (estimate based on unique workspace roots) + const sessionFiles = await this.getCopilotSessionFiles(); + const workspaceIds = new Set(); + const pathModule = require('path'); + + for (const file of sessionFiles) { + const parts = file.split(/[\\\/]/); + const workspaceStorageIdx = parts.findIndex(p => p.toLowerCase() === 'workspacestorage'); + if (workspaceStorageIdx >= 0 && workspaceStorageIdx < parts.length - 1) { + const workspaceId = parts[workspaceStorageIdx + 1]; + if (workspaceId && workspaceId.length > 10) { + workspaceIds.add(workspaceId); + } + } + } + + return { + enabled, + isConfigured, + storageAccount, + subscriptionId: subscriptionId ? subscriptionId.substring(0, 8) + '...' : '', + resourceGroup, + aggTable, + eventsTable, + authMode, + sharingProfile, + lastSyncTime, + deviceCount: workspaceIds.size, + sessionCount: sessionFiles.length, + recordCount: null // Will be populated from Azure if configured + }; + } + private getDiagnosticReportHtml( webview: vscode.Webview, report: string, sessionFiles: { file: string; size: number; modified: string }[], detailedSessionFiles: SessionFileDetails[], - sessionFolders: { dir: string; count: number }[] = [] + sessionFolders: { dir: string; count: number }[] = [], + backendStorageInfo: any = null ): string { const nonce = this.getNonce(); const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, 'dist', 'webview', 'diagnostics.js')); @@ -3449,7 +3498,7 @@ class CopilotTokenTracker implements vscode.Disposable { storagePath: storageFilePath }; - const initialData = JSON.stringify({ report, sessionFiles, detailedSessionFiles, sessionFolders, cacheInfo }).replace(/ @@ -3644,6 +3693,53 @@ export function activate(context: vscode.ExtensionContext) { // Create the token tracker const tokenTracker = new CopilotTokenTracker(context.extensionUri, context); + // Wire up backend facade and commands so the diagnostics webview can launch the + // configuration wizard. Uses tokenTracker logging and helpers via casting to any. + try { + const backendFacade = new BackendFacade({ + context, + log: (m: string) => (tokenTracker as any).log(m), + warn: (m: string) => (tokenTracker as any).warn(m), + updateTokenStats: () => (tokenTracker as any).updateTokenStats(), + calculateEstimatedCost: (modelUsage: any) => { + let total = 0; + const pricing = (modelPricingData as any).pricing || {}; + for (const [model, usage] of Object.entries(modelUsage || {})) { + const p = pricing[model] || pricing['gpt-4o-mini']; + if (!p) { continue; } + const usageData = usage as { inputTokens?: number; outputTokens?: number }; + total += ((usageData.inputTokens || 0) / 1_000_000) * p.inputCostPerMillion; + total += ((usageData.outputTokens || 0) / 1_000_000) * p.outputCostPerMillion; + } + return total; + }, + co2Per1kTokens: 0.2, + waterUsagePer1kTokens: 0.3, + co2AbsorptionPerTreePerYear: 21000, + getCopilotSessionFiles: () => (tokenTracker as any).getCopilotSessionFiles(), + estimateTokensFromText: (text: string, model?: string) => (tokenTracker as any).estimateTokensFromText(text, model), + getModelFromRequest: (req: any) => (tokenTracker as any).getModelFromRequest(req), + getSessionFileDataCached: (p: string, m: number) => (tokenTracker as any).getSessionFileDataCached(p, m) + }); + + const backendHandler = new BackendCommandHandler({ + facade: backendFacade as any, + integration: undefined, + calculateEstimatedCost: (mu: any) => 0, + warn: (m: string) => (tokenTracker as any).warn(m), + log: (m: string) => (tokenTracker as any).log(m) + }); + + const configureBackendCommand = vscode.commands.registerCommand('copilot-token-tracker.configureBackend', async () => { + await backendHandler.handleConfigureBackend(); + }); + + context.subscriptions.push(configureBackendCommand); + } catch (err) { + // If backend wiring fails for any reason, don't block activation - fall back to settings behavior. + (tokenTracker as any).warn('Failed to wire backend commands: ' + String(err)); + } + // Register the refresh command const refreshCommand = vscode.commands.registerCommand('copilot-token-tracker.refresh', async () => { tokenTracker.log('Refresh command called'); diff --git a/src/webview/diagnostics/main.ts b/src/webview/diagnostics/main.ts index efedf35..d33fb8b 100644 --- a/src/webview/diagnostics/main.ts +++ b/src/webview/diagnostics/main.ts @@ -33,11 +33,28 @@ type CacheInfo = { storagePath?: string | null; }; +type BackendStorageInfo = { + enabled: boolean; + isConfigured: boolean; + storageAccount: string; + subscriptionId: string; + resourceGroup: string; + aggTable: string; + eventsTable: string; + authMode: string; + sharingProfile: string; + lastSyncTime: string | null; + deviceCount: number; + sessionCount: number; + recordCount: number | null; +}; + type DiagnosticsData = { report: string; sessionFiles: { file: string; size: number; modified: string }[]; detailedSessionFiles?: SessionFileDetails[]; cacheInfo?: CacheInfo; + backendStorageInfo?: BackendStorageInfo; }; type DiagnosticsViewState = { @@ -286,6 +303,149 @@ function renderSessionTable(detailedFiles: SessionFileDetails[], isLoading: bool `; } +function renderBackendStoragePanel(backendInfo: BackendStorageInfo | undefined): string { + if (!backendInfo) { + return ` +
+
☁️ Azure Storage Backend
+
+ Backend storage information is not available. This may be a temporary issue. +
+
+ `; + } + + const statusColor = backendInfo.isConfigured ? '#2d6a4f' : (backendInfo.enabled ? '#d97706' : '#666'); + const statusIcon = backendInfo.isConfigured ? '✅' : (backendInfo.enabled ? '⚠️' : '⚪'); + const statusText = backendInfo.isConfigured ? 'Configured & Enabled' : (backendInfo.enabled ? 'Enabled but Not Configured' : 'Disabled'); + + const configButtonText = backendInfo.isConfigured ? '⚙️ Manage Backend' : '🔧 Configure Backend'; + const configButtonStyle = backendInfo.isConfigured ? 'secondary' : ''; + + return ` +
+
☁️ Azure Storage Backend
+
+ Sync your token usage data to Azure Storage Tables for team-wide reporting and multi-device access. + Configure Azure resources and authentication settings to enable cloud synchronization. +
+
+ +
+
+
${statusIcon} Backend Status
+
${statusText}
+
+
+
🔐 Auth Mode
+
${backendInfo.authMode === 'entraId' ? 'Entra ID' : 'Shared Key'}
+
+
+
👥 Sharing Profile
+
${escapeHtml(backendInfo.sharingProfile)}
+
+
+
🕒 Last Sync
+
${backendInfo.lastSyncTime ? getTimeSince(backendInfo.lastSyncTime) : 'Never'}
+
+
+ + ${backendInfo.isConfigured ? ` +
+

📊 Configuration Details

+ + + + + + + + + + + + + + + + + + + + + + + +
Storage Account${escapeHtml(backendInfo.storageAccount)}
Subscription ID${escapeHtml(backendInfo.subscriptionId)}
Resource Group${escapeHtml(backendInfo.resourceGroup)}
Aggregation Table${escapeHtml(backendInfo.aggTable)}
Events Table${escapeHtml(backendInfo.eventsTable)}
+
+ +
+

📈 Local Session Statistics

+
+
+
💻 Unique Devices
+
${backendInfo.deviceCount}
+
Based on workspace IDs
+
+
+
📁 Total Sessions
+
${backendInfo.sessionCount}
+
Local session files
+
+
+
☁️ Cloud Records
+
${backendInfo.recordCount !== null ? backendInfo.recordCount : '—'}
+
Azure Storage records
+
+
+
🔄 Sync Status
+
${backendInfo.lastSyncTime ? formatDate(backendInfo.lastSyncTime) : 'Never'}
+
+
+
+ +
+

ℹ️ About Azure Storage Backend

+

+ The Azure Storage backend enables: +

+
    +
  • Team-wide token usage reporting and analytics
  • +
  • Multi-device synchronization of your usage data
  • +
  • Long-term storage and historical analysis
  • +
  • Configurable privacy levels (anonymous, pseudonymous, or identified)
  • +
+
+ ` : ` +
+

🚀 Get Started with Azure Storage

+

+ To enable cloud synchronization, you'll need to configure an Azure Storage account. + The setup wizard will guide you through the process. +

+
    +
  • Azure subscription with Storage Account access
  • +
  • Appropriate permissions (Storage Table Data Contributor or Storage Account Key)
  • +
  • VS Code signed in with your Azure account (for Entra ID auth)
  • +
+
+ `} + +
+ + ${backendInfo.isConfigured ? ` + + ` : ''} +
+ `; +} + function renderLayout(data: DiagnosticsData): void { const root = document.getElementById('root'); if (!root) { @@ -612,6 +772,7 @@ function renderLayout(data: DiagnosticsData): void { +
@@ -697,6 +858,10 @@ function renderLayout(data: DiagnosticsData): void {
+ +
+ ${renderBackendStoragePanel(data.backendStorageInfo)} +
`; @@ -964,6 +1129,17 @@ function setupStorageLinkHandlers(): void { document.getElementById('btn-usage')?.addEventListener('click', () => vscode.postMessage({ command: 'showUsageAnalysis' })); document.getElementById('btn-details')?.addEventListener('click', () => vscode.postMessage({ command: 'showDetails' })); + // Backend configuration buttons + document.getElementById('btn-configure-backend')?.addEventListener('click', () => { + console.log('[DEBUG] Configure backend button clicked'); + vscode.postMessage({ command: 'configureBackend' }); + }); + + document.getElementById('btn-open-settings')?.addEventListener('click', () => { + console.log('[DEBUG] Open settings button clicked'); + vscode.postMessage({ command: 'openSettings' }); + }); + setupSortHandlers(); setupEditorFilterHandlers(); setupFileLinks();