From 66acc37fffaaaed3e664e7521efd7a2def32825b Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Mon, 6 Jul 2026 10:44:26 -0700 Subject: [PATCH 1/3] Make logs entitlements aware --- src/cloud/api.ts | 23 ++ src/cloud/commands/logs.ts | 219 +++++++++++++++++-- src/cloud/types.ts | 18 ++ src/cloud/ui/panel/styles.css | 55 +++++ src/cloud/ui/panel/webview.ts | 100 ++++++++- src/test/cloud/api.test.ts | 57 +++++ src/test/cloud/commands/logs.test.ts | 315 ++++++++++++++++++++++++++- src/test/testUtils.ts | 15 ++ 8 files changed, 782 insertions(+), 20 deletions(-) diff --git a/src/cloud/api.ts b/src/cloud/api.ts index 39b24bf..93f230c 100644 --- a/src/cloud/api.ts +++ b/src/cloud/api.ts @@ -8,6 +8,7 @@ import type { Deployment, ListResponse, Team, + TeamAccess, UploadInfo, User, } from "./types" @@ -21,6 +22,12 @@ export interface AppLogEntry { timestamp: string message: string level: string + timestamp_ns?: string +} + +export interface AppLogsResponse { + logs: AppLogEntry[] + has_more: boolean } export class StreamLogError extends Error { @@ -105,6 +112,10 @@ export class ApiService { return this.request(`/teams/${teamId}/`) } + async getTeamAccess(teamId: string): Promise { + return this.request(`/teams/${teamId}/access`) + } + async getApps(teamId: string): Promise { const data = await this.request>( `/apps/?team_id=${teamId}`, @@ -145,6 +156,18 @@ export class ApiService { return this.request(`/deployments/${deploymentId}`) } + async getAppLogs(options: { + appId: string + beforeNs?: string + limit?: number + }): Promise { + const params = new URLSearchParams() + if (options.beforeNs) params.set("before_ns", options.beforeNs) + if (options.limit) params.set("limit", String(options.limit)) + const query = params.size > 0 ? `?${params}` : "" + return this.request(`/apps/${options.appId}/logs${query}`) + } + async *streamAppLogs(options: { appId: string tail: number diff --git a/src/cloud/commands/logs.ts b/src/cloud/commands/logs.ts index baa721f..f08135b 100644 --- a/src/cloud/commands/logs.ts +++ b/src/cloud/commands/logs.ts @@ -3,19 +3,56 @@ import { log } from "../../utils/logger" import { trackCloudLogsOpened } from "../../utils/telemetry" import { type ApiService, type AppLogEntry, StreamLogError } from "../api" import type { ConfigService } from "../config" +import type { Config } from "../types" const DEFAULT_TAIL = 100 +const HISTORY_PAGE_SIZE = 500 export const LOGS_VIEW_ID = "fastapi-cloud-logs" // --- Log formatting --- -const SINCE_OPTIONS = [ +export interface SinceOption { + label: string + value: string +} + +const BASE_SINCE_OPTIONS: SinceOption[] = [ { label: "5 minutes", value: "5m" }, { label: "30 minutes", value: "30m" }, { label: "1 hour", value: "1h" }, { label: "1 day", value: "1d" }, ] +function dayOption(days: number): SinceOption { + return { label: `${days} days`, value: `${days}d` } +} + +export function getSinceOptions(logRetentionDays = 1): SinceOption[] { + const options = [...BASE_SINCE_OPTIONS] + const retentionDays = Math.floor(logRetentionDays) + const extraDays = [7, 14].filter((days) => days <= retentionDays) + if (retentionDays > 1 && !extraDays.includes(retentionDays)) { + extraDays.push(retentionDays) + } + + for (const days of extraDays.sort((a, b) => a - b)) { + options.push(dayOption(days)) + } + return options +} + +function parseSinceMs(since: string): number { + const match = since.match(/^(\d+)([smhd])$/) + if (!match) return 5 * 60 * 1000 + + const value = Number(match[1]) + const unit = match[2] + if (unit === "s") return value * 1000 + if (unit === "m") return value * 60 * 1000 + if (unit === "h") return value * 60 * 60 * 1000 + return value * 24 * 60 * 60 * 1000 +} + // Levels recognized when inferring a log's level from its message prefix. // Pipe colors for these live in the webview stylesheet, keyed on [data-level]. const KNOWN_LEVELS = [ @@ -64,10 +101,34 @@ export function formatLogEntry(entry: AppLogEntry): AppLogEntry { return { level, timestamp: formatTimestamp(entry.timestamp), + timestamp_ns: entry.timestamp_ns, message: entry.message, } } +function getTimestampMs(entry: AppLogEntry): number { + const timestampMs = new Date(entry.timestamp).getTime() + return Number.isNaN(timestampMs) ? 0 : timestampMs +} + +function getCursorNs(entry: AppLogEntry): string | undefined { + if (entry.timestamp_ns) return entry.timestamp_ns + const timestampMs = getTimestampMs(entry) + return timestampMs > 0 ? `${timestampMs}000000` : undefined +} + +function currentTimeNs(): string { + return `${Date.now()}000000` +} + +interface HistoryState { + appId: string + beforeNs: string + windowStartMs: number + hasOlder: boolean + checked: boolean +} + // --- Webview HTML --- function getLevelChipsHtml(): string { @@ -78,10 +139,12 @@ function getLevelChipsHtml(): string { } function getSinceOptionsHtml(): string { - return SINCE_OPTIONS.map( - (o, i) => - ``, - ).join("") + return getSinceOptions() + .map( + (o, i) => + ``, + ) + .join("") } export function getWebviewHtml( @@ -102,11 +165,11 @@ export function getWebviewHtml( - -
- -
- + +
+ +
+
@@ -123,10 +186,14 @@ export function getWebviewHtml(
-
- -
-
Click "Start" to stream logs.
+
+ +
+ +
Click "Start" to stream logs.
` @@ -135,6 +202,7 @@ export function getWebviewHtml( export class LogsViewProvider implements vscode.WebviewViewProvider { private view: vscode.WebviewView | undefined private activeAbortController: AbortController | undefined + private historyState: HistoryState | undefined constructor( private extensionUri: vscode.Uri, @@ -156,6 +224,7 @@ export class LogsViewProvider implements vscode.WebviewViewProvider { webviewView.webview, this.extensionUri, ) + void this.updateSinceOptions() webviewView.webview.onDidReceiveMessage(async (msg) => { if (msg.type === "startStream") { @@ -163,6 +232,8 @@ export class LogsViewProvider implements vscode.WebviewViewProvider { await this.streamLogs({ since, tail: DEFAULT_TAIL }) } else if (msg.type === "stopStream") { this.stopStreaming() + } else if (msg.type === "loadOlder") { + await this.loadOlderLogs() } }) @@ -204,6 +275,110 @@ export class LogsViewProvider implements vscode.WebviewViewProvider { return activeFolder } + private async updateSinceOptionsForConfig(config: Config): Promise { + try { + const access = await this.apiService.getTeamAccess(config.team_id) + this.view?.webview.postMessage({ + type: "sinceOptions", + options: getSinceOptions(access.entitlements.log_retention_days), + }) + } catch (err) { + log(`Failed to fetch team entitlements: ${err}`) + } + } + + private async updateSinceOptions(): Promise { + const workspaceRoot = await this.resolveWorkspaceFolder() + if (!workspaceRoot) return + + const config = await this.configService.getConfig(workspaceRoot) + if (!config?.app_id) return + + await this.updateSinceOptionsForConfig(config) + } + + private postHistoryState( + hasOlder: boolean, + loading: boolean, + checked = this.historyState?.checked ?? false, + ): void { + this.view?.webview.postMessage({ + type: "historyState", + hasOlder, + loading, + checked, + }) + } + + private updateHistoryCursor(entry: AppLogEntry): void { + const cursor = getCursorNs(entry) + if (!cursor || !this.historyState) return + if (BigInt(cursor) < BigInt(this.historyState.beforeNs)) { + this.historyState.beforeNs = cursor + } + } + + private filterLogsWithinWindow(logs: AppLogEntry[]): { + logs: AppLogEntry[] + reachedWindowStart: boolean + } { + const windowStartMs = this.historyState?.windowStartMs ?? 0 + let reachedWindowStart = false + const filteredLogs = logs.filter((entry) => { + const timestampMs = getTimestampMs(entry) + const inWindow = timestampMs >= windowStartMs + if (!inWindow) reachedWindowStart = true + return inWindow + }) + return { logs: filteredLogs, reachedWindowStart } + } + + async loadOlderLogs(): Promise { + if (!this.historyState?.hasOlder) return + const state = this.historyState + + this.postHistoryState(true, true) + try { + const response = await this.apiService.getAppLogs({ + appId: state.appId, + beforeNs: state.beforeNs, + limit: HISTORY_PAGE_SIZE, + }) + if (this.historyState !== state) return + + const { logs, reachedWindowStart } = this.filterLogsWithinWindow( + response.logs, + ) + if (logs.length > 0) { + state.beforeNs = getCursorNs(logs[0]) ?? state.beforeNs + this.view?.webview.postMessage({ + type: "olderLogs", + entries: logs.map(formatLogEntry), + }) + } + + state.checked = true + state.hasOlder = response.has_more && !reachedWindowStart + this.postHistoryState(state.hasOlder, false, state.checked) + if (logs.length === 0 && !state.hasOlder) { + this.view?.webview.postMessage({ + type: "historyNotice", + text: "No earlier logs in this range.", + }) + } + } catch (error) { + if (this.historyState !== state) return + + const message = error instanceof Error ? error.message : String(error) + log(`Failed to load older logs: ${message}`) + this.postHistoryState(state.hasOlder, false, state.checked) + this.view?.webview.postMessage({ + type: "historyNotice", + text: `Failed to load earlier logs: ${message}`, + }) + } + } + async streamLogs(options?: { since?: string; tail?: number }): Promise { const workspaceRoot = await this.resolveWorkspaceFolder() @@ -221,6 +396,8 @@ export class LogsViewProvider implements vscode.WebviewViewProvider { return } + await this.updateSinceOptionsForConfig(config) + const since = options?.since ?? "5m" const tail = options?.tail ?? 100 @@ -237,9 +414,17 @@ export class LogsViewProvider implements vscode.WebviewViewProvider { const appLabel = config.app_slug ?? workspaceRoot.path.split("/").pop() ?? "" + this.historyState = { + appId, + beforeNs: currentTimeNs(), + windowStartMs: Date.now() - parseSinceMs(since), + hasOlder: true, + checked: false, + } if (this.view) { this.view.webview.postMessage({ type: "clear" }) + this.postHistoryState(false, false) this.view.webview.postMessage({ type: "status", text: "Connecting to log stream...", @@ -268,6 +453,7 @@ export class LogsViewProvider implements vscode.WebviewViewProvider { type: "status", text: "Connected. Waiting for new logs...", }) + this.postHistoryState(this.historyState?.hasOlder ?? false, false) } }, 2000) @@ -275,6 +461,10 @@ export class LogsViewProvider implements vscode.WebviewViewProvider { if (count === 0) clearTimeout(connectedTimer) if (!this.view) return count++ + this.updateHistoryCursor(entry) + if (count === 1) { + this.postHistoryState(this.historyState?.hasOlder ?? false, false) + } this.view.webview.postMessage({ type: "log", entry: formatLogEntry(entry), @@ -292,6 +482,7 @@ export class LogsViewProvider implements vscode.WebviewViewProvider { text: "Stream ended.", }) } + this.postHistoryState(this.historyState?.hasOlder ?? false, false) } catch (error) { if (signal.aborted) return if (error instanceof StreamLogError) { diff --git a/src/cloud/types.ts b/src/cloud/types.ts index df97bf5..7a87bfb 100644 --- a/src/cloud/types.ts +++ b/src/cloud/types.ts @@ -4,6 +4,24 @@ export interface Team { slug: string } +export interface Entitlements { + max_apps: number + max_replicas: number + max_custom_domains: number + max_seats: number + log_retention_days: number + metrics_retention_days: number + advanced_metrics_enabled: boolean +} + +export interface TeamAccess { + role: string + is_owner: boolean + permissions: string[] + assignable_roles?: string[] + entitlements: Entitlements +} + export interface App { id: string slug: string diff --git a/src/cloud/ui/panel/styles.css b/src/cloud/ui/panel/styles.css index 4e936d4..061814b 100644 --- a/src/cloud/ui/panel/styles.css +++ b/src/cloud/ui/panel/styles.css @@ -72,10 +72,65 @@ body { background: var(--vscode-button-secondaryHoverBackground, #505050); } +.secondary-btn:disabled { + cursor: default; + opacity: 0.5; +} + +.secondary-btn:disabled:hover { + background: var(--vscode-button-secondaryBackground, #3c3c3c); +} + .secondary-btn.active { outline: 1px solid var(--vscode-focusBorder, #007fd4); } +.history-bar { + display: flex; + justify-content: center; + align-items: center; + min-height: 22px; + padding: 2px 8px; + border-bottom: 1px solid var(--vscode-panel-border, rgba(128, 128, 128, 0.16)); + color: var(--vscode-descriptionForeground, rgba(128, 128, 128, 0.85)); + font-size: 0.85em; +} + +.history-bar.hidden { + display: none; +} + +.history-note.hidden, +.link-btn.hidden { + display: none; +} + +.link-btn { + background: transparent; + color: var(--vscode-textLink-foreground, #3794ff); + border: none; + padding: 0 6px; + cursor: pointer; + font-family: inherit; + font-size: inherit; +} + +.link-btn:hover { + color: var(--vscode-textLink-activeForeground, #3794ff); + text-decoration: underline; +} + +.link-btn:disabled { + cursor: default; + opacity: 0.65; + text-decoration: none; +} + +.history-note { + font-style: normal; + opacity: 0.85; +} + #app-label { font-size: 0.85em; opacity: 0.6; diff --git a/src/cloud/ui/panel/webview.ts b/src/cloud/ui/panel/webview.ts index 09671a8..f8d2124 100644 --- a/src/cloud/ui/panel/webview.ts +++ b/src/cloud/ui/panel/webview.ts @@ -5,11 +5,21 @@ import type { AppLogEntry } from "../../api" declare function acquireVsCodeApi(): { postMessage(msg: unknown): void } +interface SinceOption { + label: string + value: string +} + const vscode = acquireVsCodeApi() const logs = document.getElementById("logs")! const sinceFilter = document.getElementById("since-filter") as HTMLSelectElement const searchInput = document.getElementById("search-input") as HTMLInputElement const streamBtn = document.getElementById("stream-btn")! +const historyBar = document.getElementById("history-bar")! +const loadOlderBtn = document.getElementById( + "load-older-btn", +) as HTMLButtonElement +const historyNote = document.getElementById("history-note")! const clearBtn = document.getElementById("clear-btn")! const filterBtn = document.getElementById("filter-btn")! const filterPopup = document.getElementById("filter-popup")! @@ -58,6 +68,15 @@ clearBtn.addEventListener("click", () => { firstEntry = true }) +loadOlderBtn.addEventListener("click", () => { + loadOlderBtn.disabled = true + loadOlderBtn.textContent = + loadOlderBtn.dataset.checked === "true" + ? "Loading earlier logs..." + : "Checking earlier logs..." + vscode.postMessage({ type: "loadOlder" }) +}) + function isNearBottom(): boolean { return document.body.scrollHeight - window.innerHeight - window.scrollY < 8 } @@ -131,6 +150,53 @@ function setStreamingState(streaming: boolean, appLabel?: string): void { streaming && appLabel ? `Streaming logs for ${appLabel}...` : "" } +function setHistoryState( + hasOlder: boolean, + loading: boolean, + checked: boolean, +): void { + historyNote.textContent = "" + historyNote.classList.add("hidden") + loadOlderBtn.classList.remove("hidden") + loadOlderBtn.dataset.checked = checked ? "true" : "false" + historyBar.classList.toggle("hidden", !hasOlder && !loading) + loadOlderBtn.disabled = !hasOlder || loading + if (loading) { + loadOlderBtn.textContent = checked + ? "Loading earlier logs..." + : "Checking earlier logs..." + } else { + loadOlderBtn.textContent = checked + ? "Load earlier logs" + : "Check earlier logs" + } + loadOlderBtn.title = checked + ? "Load more earlier logs in the selected range" + : "Check whether earlier logs exist in the selected range" +} + +function showHistoryNotice(text: string): void { + historyBar.classList.remove("hidden") + loadOlderBtn.classList.add("hidden") + historyNote.textContent = text + historyNote.classList.remove("hidden") +} + +function updateSinceOptions(options: SinceOption[]): void { + const previousValue = sinceFilter.value + sinceFilter.innerHTML = "" + for (const option of options) { + const optionEl = document.createElement("option") + optionEl.value = option.value + optionEl.textContent = option.label + sinceFilter.append(optionEl) + } + + if (options.some((option) => option.value === previousValue)) { + sinceFilter.value = previousValue + } +} + // Build the log line as a DOM node. The untrusted message is set as a text // node, so it is never parsed as HTML — no sanitization needed. function buildLogLine(entry: AppLogEntry): HTMLElement { @@ -147,6 +213,12 @@ function buildLogLine(entry: AppLogEntry): HTMLElement { return line } +function applyCurrentFilters(line: HTMLElement): void { + if (!shouldShow(line, getSelectedLevels(), searchInput.value.toLowerCase())) { + line.classList.add("filtered") + } +} + window.addEventListener("message", (event) => { const msg = event.data if (msg.type === "log") { @@ -157,12 +229,20 @@ window.addEventListener("message", (event) => { const wasAtBottom = isNearBottom() const line = buildLogLine(msg.entry) logs.append(line) - if ( - !shouldShow(line, getSelectedLevels(), searchInput.value.toLowerCase()) - ) { - line.classList.add("filtered") - } + applyCurrentFilters(line) if (wasAtBottom) window.scrollTo(0, document.body.scrollHeight) + } else if (msg.type === "olderLogs" && Array.isArray(msg.entries)) { + if (firstEntry) { + logs.innerHTML = "" + firstEntry = false + } + const fragment = document.createDocumentFragment() + for (const entry of msg.entries) { + const line = buildLogLine(entry) + applyCurrentFilters(line) + fragment.append(line) + } + logs.prepend(fragment) } else if (msg.type === "status") { const safe = esc(msg.text) if (firstEntry) { @@ -175,5 +255,15 @@ window.addEventListener("message", (event) => { firstEntry = true } else if (msg.type === "streamingState") { setStreamingState(msg.streaming, msg.appLabel) + } else if (msg.type === "sinceOptions" && Array.isArray(msg.options)) { + updateSinceOptions(msg.options) + } else if (msg.type === "historyNotice") { + showHistoryNotice(String(msg.text ?? "")) + } else if (msg.type === "historyState") { + setHistoryState( + Boolean(msg.hasOlder), + Boolean(msg.loading), + Boolean(msg.checked), + ) } }) diff --git a/src/test/cloud/api.test.ts b/src/test/cloud/api.test.ts index 4b63813..46b0d79 100644 --- a/src/test/cloud/api.test.ts +++ b/src/test/cloud/api.test.ts @@ -240,6 +240,34 @@ suite("cloud/api", () => { assert.strictEqual(teams[0].slug, "team-1") }) + test("getTeamAccess returns entitlements", async () => { + const fetchStub = sinon.stub(globalThis, "fetch").resolves( + mockResponse({ + role: "owner", + is_owner: true, + permissions: ["app:write"], + entitlements: { + max_apps: 25, + max_replicas: 100, + max_custom_domains: 20, + max_seats: 10, + log_retention_days: 14, + metrics_retention_days: 14, + advanced_metrics_enabled: true, + }, + }), + ) + + const access = await api.getTeamAccess("team-id") + + assert.strictEqual( + fetchStub.firstCall.args[0], + `${DEFAULT_BASE_URL}/teams/team-id/access`, + ) + assert.strictEqual(access.entitlements.log_retention_days, 14) + assert.strictEqual(access.entitlements.advanced_metrics_enabled, true) + }) + test("throws when not authenticated", async () => { mockSession(null) @@ -302,6 +330,35 @@ suite("cloud/api", () => { assert.strictEqual(apps[0].slug, "app-1") }) + test("getAppLogs requests paginated logs", async () => { + const fetchStub = sinon.stub(globalThis, "fetch").resolves( + mockResponse({ + logs: [ + { + timestamp: "2025-01-15T10:30:00Z", + timestamp_ns: "1736937000000000000", + message: "line 1", + level: "info", + }, + ], + has_more: true, + }), + ) + + const response = await api.getAppLogs({ + appId: "app-id", + beforeNs: "1736937001000000000", + limit: 500, + }) + + assert.strictEqual( + fetchStub.firstCall.args[0], + `${DEFAULT_BASE_URL}/apps/app-id/logs?before_ns=1736937001000000000&limit=500`, + ) + assert.strictEqual(response.logs.length, 1) + assert.strictEqual(response.has_more, true) + }) + test("createApp sends POST", async () => { const fetchStub = sinon .stub(globalThis, "fetch") diff --git a/src/test/cloud/commands/logs.test.ts b/src/test/cloud/commands/logs.test.ts index 4be9b88..341e35f 100644 --- a/src/test/cloud/commands/logs.test.ts +++ b/src/test/cloud/commands/logs.test.ts @@ -4,6 +4,7 @@ import * as vscode from "vscode" import { StreamLogError } from "../../../cloud/api" import { formatLogEntry, + getSinceOptions, getWebviewHtml, LogsViewProvider, } from "../../../cloud/commands/logs" @@ -70,6 +71,22 @@ function createProvider( suite("cloud/commands/logs", () => { teardown(() => sinon.restore()) + suite("getSinceOptions", () => { + test("limits hobby teams to the base retention options", () => { + assert.deepStrictEqual( + getSinceOptions(1).map((option) => option.value), + ["5m", "30m", "1h", "1d"], + ) + }) + + test("includes weekly and full-retention options for pro teams", () => { + assert.deepStrictEqual( + getSinceOptions(14).map((option) => option.value), + ["5m", "30m", "1h", "1d", "7d", "14d"], + ) + }) + }) + suite("resolveWebviewView", () => { test("sets up webview options and html", () => { const { provider } = createProvider() @@ -268,6 +285,299 @@ suite("cloud/commands/logs", () => { assert.strictEqual(opts.tail, 200) assert.strictEqual(opts.follow, true) }) + + test("enables older log loading after stream cursor is available", async () => { + sinon.useFakeTimers(new Date("2025-01-15T10:40:00Z")) + const { provider, configService, apiService } = createProvider() + const { view, messages } = createWebviewView() + provider.resolveWebviewView(view) + configService.getConfig.resolves({ app_id: "a1", team_id: "t1" }) + + async function* fakeStream() { + yield { + timestamp: "2025-01-15T10:30:00Z", + timestamp_ns: "1736937000000000000", + message: "line 1", + level: "info", + } + } + apiService.streamAppLogs.returns(fakeStream()) + sinon.stub(vscode.commands, "executeCommand").resolves() + + await provider.streamLogs({ since: "1h" }) + + assert.ok( + messages.some( + (m) => + m.type === "historyState" && + m.hasOlder === true && + m.loading === false && + m.checked === false, + ), + ) + }) + + test("loads older logs before the oldest current cursor", async () => { + sinon.useFakeTimers(new Date("2025-01-15T10:40:00Z")) + const { provider, configService, apiService } = createProvider() + const { view, messages } = createWebviewView() + provider.resolveWebviewView(view) + configService.getConfig.resolves({ app_id: "a1", team_id: "t1" }) + + async function* fakeStream() { + yield { + timestamp: "2025-01-15T10:30:00Z", + timestamp_ns: "1736937000000000000", + message: "newer", + level: "info", + } + } + apiService.streamAppLogs.returns(fakeStream()) + apiService.getAppLogs.resolves({ + logs: [ + { + timestamp: "2025-01-15T10:20:00Z", + timestamp_ns: "1736936400000000000", + message: "older", + level: "info", + }, + ], + has_more: false, + }) + sinon.stub(vscode.commands, "executeCommand").resolves() + + await provider.streamLogs({ since: "1h" }) + await provider.loadOlderLogs() + + assert.deepStrictEqual(apiService.getAppLogs.firstCall.args[0], { + appId: "a1", + beforeNs: "1736937000000000000", + limit: 500, + }) + const olderMessages = messages.filter((m) => m.type === "olderLogs") + assert.strictEqual(olderMessages.length, 1) + assert.strictEqual(olderMessages[0].entries[0].message, "older") + assert.ok( + messages.some( + (m) => + m.type === "historyState" && + m.hasOlder === false && + m.loading === false && + m.checked === true, + ), + ) + }) + + test("keeps loading available as a load action after history confirms more pages", async () => { + sinon.useFakeTimers(new Date("2025-01-15T10:40:00Z")) + const { provider, configService, apiService } = createProvider() + const { view, messages } = createWebviewView() + provider.resolveWebviewView(view) + configService.getConfig.resolves({ app_id: "a1", team_id: "t1" }) + + async function* fakeStream() { + yield { + timestamp: "2025-01-15T10:30:00Z", + timestamp_ns: "1736937000000000000", + message: "newer", + level: "info", + } + } + apiService.streamAppLogs.returns(fakeStream()) + apiService.getAppLogs.resolves({ + logs: [ + { + timestamp: "2025-01-15T10:20:00Z", + timestamp_ns: "1736936400000000000", + message: "older", + level: "info", + }, + ], + has_more: true, + }) + sinon.stub(vscode.commands, "executeCommand").resolves() + + await provider.streamLogs({ since: "1h" }) + await provider.loadOlderLogs() + + assert.ok( + messages.some( + (m) => + m.type === "historyState" && + m.hasOlder === true && + m.loading === false && + m.checked === true, + ), + ) + }) + + test("does not render older logs outside selected range", async () => { + sinon.useFakeTimers(new Date("2025-01-15T10:40:00Z")) + const { provider, configService, apiService } = createProvider() + const { view, messages } = createWebviewView() + provider.resolveWebviewView(view) + configService.getConfig.resolves({ app_id: "a1", team_id: "t1" }) + + async function* fakeStream() { + yield { + timestamp: "2025-01-15T10:30:00Z", + timestamp_ns: "1736937000000000000", + message: "newer", + level: "info", + } + } + apiService.streamAppLogs.returns(fakeStream()) + apiService.getAppLogs.resolves({ + logs: [ + { + timestamp: "2025-01-15T10:00:00Z", + timestamp_ns: "1736935200000000000", + message: "too old", + level: "info", + }, + ], + has_more: true, + }) + sinon.stub(vscode.commands, "executeCommand").resolves() + + await provider.streamLogs({ since: "30m" }) + await provider.loadOlderLogs() + + assert.ok(!messages.some((m) => m.type === "olderLogs")) + assert.ok( + messages.some( + (m) => + m.type === "historyNotice" && + m.text === "No earlier logs in this range.", + ), + ) + assert.ok( + !messages.some( + (m) => + m.type === "status" && m.text === "No older logs in this range.", + ), + ) + assert.ok( + messages.some( + (m) => + m.type === "historyState" && + m.hasOlder === false && + m.loading === false, + ), + ) + }) + + test("ignores older log responses after a new stream starts", async () => { + sinon.useFakeTimers(new Date("2025-01-15T10:40:00Z")) + const { provider, configService, apiService } = createProvider() + const { view, messages } = createWebviewView() + provider.resolveWebviewView(view) + configService.getConfig.resolves({ app_id: "a1", team_id: "t1" }) + + async function* firstStream() { + yield { + timestamp: "2025-01-15T10:30:00Z", + timestamp_ns: "1736937000000000000", + message: "first stream", + level: "info", + } + } + + async function* secondStream() { + yield { + timestamp: "2025-01-15T10:35:00Z", + timestamp_ns: "1736937300000000000", + message: "second stream", + level: "info", + } + } + + let resolveOlderLogs: + | ((response: { + logs: { + timestamp: string + timestamp_ns: string + message: string + level: string + }[] + has_more: boolean + }) => void) + | undefined + const olderLogsPromise = new Promise<{ + logs: { + timestamp: string + timestamp_ns: string + message: string + level: string + }[] + has_more: boolean + }>((resolve) => { + resolveOlderLogs = resolve + }) + + apiService.streamAppLogs.onFirstCall().returns(firstStream()) + apiService.streamAppLogs.onSecondCall().returns(secondStream()) + apiService.getAppLogs.returns(olderLogsPromise) + sinon.stub(vscode.commands, "executeCommand").resolves() + + await provider.streamLogs({ since: "1h" }) + const loadPromise = provider.loadOlderLogs() + await provider.streamLogs({ since: "5m" }) + + resolveOlderLogs?.({ + logs: [ + { + timestamp: "2025-01-15T10:20:00Z", + timestamp_ns: "1736936400000000000", + message: "stale older", + level: "info", + }, + ], + has_more: false, + }) + await loadPromise + + assert.ok( + !messages.some( + (m) => + m.type === "olderLogs" && + m.entries.some((entry: { message: string }) => + entry.message.includes("stale older"), + ), + ), + ) + }) + + test("keeps older log fetch errors visible after restoring history state", async () => { + sinon.useFakeTimers(new Date("2025-01-15T10:40:00Z")) + const { provider, configService, apiService } = createProvider() + const { view, messages } = createWebviewView() + provider.resolveWebviewView(view) + configService.getConfig.resolves({ app_id: "a1", team_id: "t1" }) + + async function* fakeStream() { + yield { + timestamp: "2025-01-15T10:30:00Z", + timestamp_ns: "1736937000000000000", + message: "newer", + level: "info", + } + } + + apiService.streamAppLogs.returns(fakeStream()) + apiService.getAppLogs.rejects(new Error("network failed")) + sinon.stub(vscode.commands, "executeCommand").resolves() + + await provider.streamLogs({ since: "1h" }) + await provider.loadOlderLogs() + + const historyMessages = messages.filter( + (m) => m.type === "historyState" || m.type === "historyNotice", + ) + const lastHistoryMessage = historyMessages.at(-1) + assert.strictEqual(lastHistoryMessage?.type, "historyNotice") + assert.ok(lastHistoryMessage?.text.includes("network failed")) + }) }) suite("multi-root workspace resolution", () => { @@ -548,9 +858,12 @@ suite("cloud/commands/logs", () => { assert.ok(html.includes("https://test-csp-source")) }) - test("includes toolbar controls", () => { + test("includes log controls", () => { const html = getWebviewHtml(createMockWebview(), testExtensionUri) assert.ok(html.includes('id="since-filter"')) + assert.ok(html.includes('id="load-older-btn"')) + assert.ok(html.includes('id="history-note"')) + assert.ok(html.includes("Check earlier logs")) assert.ok(html.includes('id="stream-btn"')) assert.ok(html.includes('id="filter-btn"')) assert.ok(html.includes('id="clear-btn"')) diff --git a/src/test/testUtils.ts b/src/test/testUtils.ts index e9564fa..d53a1f3 100644 --- a/src/test/testUtils.ts +++ b/src/test/testUtils.ts @@ -201,6 +201,21 @@ export function mockApiService( stub.getUser.resolves(null) stub.getTeams.resolves([]) stub.getApps.resolves([]) + stub.getAppLogs.resolves({ logs: [], has_more: false }) + stub.getTeamAccess.resolves({ + role: "owner", + is_owner: true, + permissions: [], + entitlements: { + max_apps: 3, + max_replicas: 3, + max_custom_domains: 1, + max_seats: 1, + log_retention_days: 1, + metrics_retention_days: 1, + advanced_metrics_enabled: false, + }, + }) Object.assign(stub, overrides) return stub From 80b35939b8b7dbd93f4043f5fc56af105b1a8dc4 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Mon, 6 Jul 2026 11:37:54 -0700 Subject: [PATCH 2/3] Add load older with scroll --- src/cloud/commands/logs.ts | 121 ++++++----- src/cloud/ui/panel/webview.ts | 102 ++++----- src/test/cloud/commands/logs.test.ts | 303 +++++++++++++++++---------- 3 files changed, 316 insertions(+), 210 deletions(-) diff --git a/src/cloud/commands/logs.ts b/src/cloud/commands/logs.ts index f08135b..b0e11e4 100644 --- a/src/cloud/commands/logs.ts +++ b/src/cloud/commands/logs.ts @@ -22,23 +22,18 @@ const BASE_SINCE_OPTIONS: SinceOption[] = [ { label: "1 hour", value: "1h" }, { label: "1 day", value: "1d" }, ] +const EXTRA_SINCE_OPTION_DAYS = [7, 14] function dayOption(days: number): SinceOption { return { label: `${days} days`, value: `${days}d` } } export function getSinceOptions(logRetentionDays = 1): SinceOption[] { - const options = [...BASE_SINCE_OPTIONS] const retentionDays = Math.floor(logRetentionDays) - const extraDays = [7, 14].filter((days) => days <= retentionDays) - if (retentionDays > 1 && !extraDays.includes(retentionDays)) { - extraDays.push(retentionDays) - } - - for (const days of extraDays.sort((a, b) => a - b)) { - options.push(dayOption(days)) - } - return options + const extraOptions = EXTRA_SINCE_OPTION_DAYS.filter( + (days) => days <= retentionDays, + ).map(dayOption) + return [...BASE_SINCE_OPTIONS, ...extraOptions] } function parseSinceMs(since: string): number { @@ -53,6 +48,23 @@ function parseSinceMs(since: string): number { return value * 24 * 60 * 60 * 1000 } +function formatSinceLabel(since: string): string { + const match = since.match(/^(\d+)([smhd])$/) + if (!match) return since + + const value = Number(match[1]) + const unit = match[2] + const unitLabel = + unit === "s" + ? "second" + : unit === "m" + ? "minute" + : unit === "h" + ? "hour" + : "day" + return `${value} ${unitLabel}${value === 1 ? "" : "s"}` +} + // Levels recognized when inferring a log's level from its message prefix. // Pipe colors for these live in the webview stylesheet, keyed on [data-level]. const KNOWN_LEVELS = [ @@ -106,27 +118,24 @@ export function formatLogEntry(entry: AppLogEntry): AppLogEntry { } } -function getTimestampMs(entry: AppLogEntry): number { +function getTimestampEpochMs(entry: AppLogEntry): number { const timestampMs = new Date(entry.timestamp).getTime() return Number.isNaN(timestampMs) ? 0 : timestampMs } -function getCursorNs(entry: AppLogEntry): string | undefined { +function getPaginationCursorNs(entry: AppLogEntry): string | undefined { if (entry.timestamp_ns) return entry.timestamp_ns - const timestampMs = getTimestampMs(entry) + const timestampMs = getTimestampEpochMs(entry) return timestampMs > 0 ? `${timestampMs}000000` : undefined } -function currentTimeNs(): string { - return `${Date.now()}000000` -} - interface HistoryState { appId: string beforeNs: string - windowStartMs: number + windowStartEpochMs: number + rangeLabel: string hasOlder: boolean - checked: boolean + hasLogs: boolean } // --- Webview HTML --- @@ -138,12 +147,14 @@ function getLevelChipsHtml(): string { ).join("\n") } +function getSinceOptionHtml(option: SinceOption, selected: boolean): string { + const selectedAttr = selected ? " selected" : "" + return `` +} + function getSinceOptionsHtml(): string { return getSinceOptions() - .map( - (o, i) => - ``, - ) + .map((option, index) => getSinceOptionHtml(option, index === 0)) .join("") } @@ -190,7 +201,7 @@ export function getWebviewHtml(
Click "Start" to stream logs.
@@ -297,21 +308,20 @@ export class LogsViewProvider implements vscode.WebviewViewProvider { await this.updateSinceOptionsForConfig(config) } - private postHistoryState( - hasOlder: boolean, - loading: boolean, - checked = this.historyState?.checked ?? false, - ): void { + private postHistoryState(options?: { + hasOlder?: boolean + loading?: boolean + }): void { + const state = this.historyState this.view?.webview.postMessage({ type: "historyState", - hasOlder, - loading, - checked, + hasOlder: options?.hasOlder ?? state?.hasOlder ?? false, + loading: options?.loading ?? false, }) } private updateHistoryCursor(entry: AppLogEntry): void { - const cursor = getCursorNs(entry) + const cursor = getPaginationCursorNs(entry) if (!cursor || !this.historyState) return if (BigInt(cursor) < BigInt(this.historyState.beforeNs)) { this.historyState.beforeNs = cursor @@ -322,22 +332,29 @@ export class LogsViewProvider implements vscode.WebviewViewProvider { logs: AppLogEntry[] reachedWindowStart: boolean } { - const windowStartMs = this.historyState?.windowStartMs ?? 0 + const windowStartEpochMs = this.historyState?.windowStartEpochMs ?? 0 let reachedWindowStart = false const filteredLogs = logs.filter((entry) => { - const timestampMs = getTimestampMs(entry) - const inWindow = timestampMs >= windowStartMs + const timestampMs = getTimestampEpochMs(entry) + const inWindow = timestampMs >= windowStartEpochMs if (!inWindow) reachedWindowStart = true return inWindow }) return { logs: filteredLogs, reachedWindowStart } } + private getNoOlderLogsText(state: HistoryState): string { + if (state.hasLogs) { + return `No earlier logs in the last ${state.rangeLabel}.` + } + return `No logs found in the last ${state.rangeLabel}. Choose a longer range to see older logs.` + } + async loadOlderLogs(): Promise { if (!this.historyState?.hasOlder) return const state = this.historyState - this.postHistoryState(true, true) + this.postHistoryState({ loading: true }) try { const response = await this.apiService.getAppLogs({ appId: state.appId, @@ -350,20 +367,20 @@ export class LogsViewProvider implements vscode.WebviewViewProvider { response.logs, ) if (logs.length > 0) { - state.beforeNs = getCursorNs(logs[0]) ?? state.beforeNs + state.hasLogs = true + state.beforeNs = getPaginationCursorNs(logs[0]) ?? state.beforeNs this.view?.webview.postMessage({ type: "olderLogs", entries: logs.map(formatLogEntry), }) } - state.checked = true state.hasOlder = response.has_more && !reachedWindowStart - this.postHistoryState(state.hasOlder, false, state.checked) + this.postHistoryState() if (logs.length === 0 && !state.hasOlder) { this.view?.webview.postMessage({ type: "historyNotice", - text: "No earlier logs in this range.", + text: this.getNoOlderLogsText(state), }) } } catch (error) { @@ -371,7 +388,7 @@ export class LogsViewProvider implements vscode.WebviewViewProvider { const message = error instanceof Error ? error.message : String(error) log(`Failed to load older logs: ${message}`) - this.postHistoryState(state.hasOlder, false, state.checked) + this.postHistoryState() this.view?.webview.postMessage({ type: "historyNotice", text: `Failed to load earlier logs: ${message}`, @@ -414,17 +431,19 @@ export class LogsViewProvider implements vscode.WebviewViewProvider { const appLabel = config.app_slug ?? workspaceRoot.path.split("/").pop() ?? "" + const nowEpochMs = Date.now() this.historyState = { appId, - beforeNs: currentTimeNs(), - windowStartMs: Date.now() - parseSinceMs(since), + beforeNs: `${nowEpochMs}000000`, + windowStartEpochMs: nowEpochMs - parseSinceMs(since), + rangeLabel: formatSinceLabel(since), hasOlder: true, - checked: false, + hasLogs: false, } if (this.view) { this.view.webview.postMessage({ type: "clear" }) - this.postHistoryState(false, false) + this.postHistoryState({ hasOlder: false }) this.view.webview.postMessage({ type: "status", text: "Connecting to log stream...", @@ -446,24 +465,24 @@ export class LogsViewProvider implements vscode.WebviewViewProvider { signal, }) - // If no entries arrive quickly, update status so user knows we're connected const connectedTimer = setTimeout(() => { if (count === 0 && this.view && !signal.aborted) { this.view.webview.postMessage({ type: "status", text: "Connected. Waiting for new logs...", }) - this.postHistoryState(this.historyState?.hasOlder ?? false, false) + this.postHistoryState() } - }, 2000) + }, 1000) for await (const entry of logStream) { if (count === 0) clearTimeout(connectedTimer) if (!this.view) return count++ + if (this.historyState) this.historyState.hasLogs = true this.updateHistoryCursor(entry) if (count === 1) { - this.postHistoryState(this.historyState?.hasOlder ?? false, false) + this.postHistoryState() } this.view.webview.postMessage({ type: "log", @@ -482,7 +501,7 @@ export class LogsViewProvider implements vscode.WebviewViewProvider { text: "Stream ended.", }) } - this.postHistoryState(this.historyState?.hasOlder ?? false, false) + this.postHistoryState() } catch (error) { if (signal.aborted) return if (error instanceof StreamLogError) { diff --git a/src/cloud/ui/panel/webview.ts b/src/cloud/ui/panel/webview.ts index f8d2124..615d2c9 100644 --- a/src/cloud/ui/panel/webview.ts +++ b/src/cloud/ui/panel/webview.ts @@ -10,6 +10,11 @@ interface SinceOption { value: string } +type HistoryRowState = + | { kind: "hidden" } + | { kind: "button"; loading: boolean } + | { kind: "notice"; text: string } + const vscode = acquireVsCodeApi() const logs = document.getElementById("logs")! const sinceFilter = document.getElementById("since-filter") as HTMLSelectElement @@ -28,10 +33,6 @@ const appLabelEl = document.getElementById("app-label")! let firstEntry = true let isStreaming = false -function esc(s: string): string { - return s.replace(/&/g, "&").replace(//g, ">") -} - function getSelectedLevels(): string[] { return Array.from( levelList.querySelectorAll(".level-item.selected"), @@ -64,16 +65,13 @@ filterBtn.addEventListener("click", (e) => { }) clearBtn.addEventListener("click", () => { - logs.innerHTML = "" + logs.replaceChildren() firstEntry = true }) loadOlderBtn.addEventListener("click", () => { loadOlderBtn.disabled = true - loadOlderBtn.textContent = - loadOlderBtn.dataset.checked === "true" - ? "Loading earlier logs..." - : "Checking earlier logs..." + loadOlderBtn.textContent = "Loading logs..." vscode.postMessage({ type: "loadOlder" }) }) @@ -150,41 +148,42 @@ function setStreamingState(streaming: boolean, appLabel?: string): void { streaming && appLabel ? `Streaming logs for ${appLabel}...` : "" } -function setHistoryState( - hasOlder: boolean, - loading: boolean, - checked: boolean, -): void { - historyNote.textContent = "" - historyNote.classList.add("hidden") - loadOlderBtn.classList.remove("hidden") - loadOlderBtn.dataset.checked = checked ? "true" : "false" - historyBar.classList.toggle("hidden", !hasOlder && !loading) - loadOlderBtn.disabled = !hasOlder || loading - if (loading) { - loadOlderBtn.textContent = checked - ? "Loading earlier logs..." - : "Checking earlier logs..." - } else { - loadOlderBtn.textContent = checked - ? "Load earlier logs" - : "Check earlier logs" +function renderHistoryRow(state: HistoryRowState): void { + historyBar.classList.toggle("hidden", state.kind === "hidden") + loadOlderBtn.classList.toggle("hidden", state.kind !== "button") + historyNote.classList.toggle("hidden", state.kind !== "notice") + + if (state.kind === "hidden") { + historyNote.textContent = "" + return + } + + if (state.kind === "notice") { + historyNote.textContent = state.text + return } - loadOlderBtn.title = checked - ? "Load more earlier logs in the selected range" - : "Check whether earlier logs exist in the selected range" + + historyNote.textContent = "" + loadOlderBtn.disabled = state.loading + loadOlderBtn.textContent = state.loading + ? "Loading logs..." + : "Load earlier logs" + loadOlderBtn.title = "Load earlier logs in the selected range" +} + +function setHistoryState(hasOlder: boolean, loading: boolean): void { + renderHistoryRow( + hasOlder || loading ? { kind: "button", loading } : { kind: "hidden" }, + ) } function showHistoryNotice(text: string): void { - historyBar.classList.remove("hidden") - loadOlderBtn.classList.add("hidden") - historyNote.textContent = text - historyNote.classList.remove("hidden") + renderHistoryRow({ kind: "notice", text }) } function updateSinceOptions(options: SinceOption[]): void { const previousValue = sinceFilter.value - sinceFilter.innerHTML = "" + sinceFilter.replaceChildren() for (const option of options) { const optionEl = document.createElement("option") optionEl.value = option.value @@ -213,6 +212,13 @@ function buildLogLine(entry: AppLogEntry): HTMLElement { return line } +function buildStatusLine(text: string): HTMLElement { + const status = document.createElement("div") + status.className = "status" + status.textContent = text + return status +} + function applyCurrentFilters(line: HTMLElement): void { if (!shouldShow(line, getSelectedLevels(), searchInput.value.toLowerCase())) { line.classList.add("filtered") @@ -223,7 +229,7 @@ window.addEventListener("message", (event) => { const msg = event.data if (msg.type === "log") { if (firstEntry) { - logs.innerHTML = "" + logs.replaceChildren() firstEntry = false } const wasAtBottom = isNearBottom() @@ -233,9 +239,11 @@ window.addEventListener("message", (event) => { if (wasAtBottom) window.scrollTo(0, document.body.scrollHeight) } else if (msg.type === "olderLogs" && Array.isArray(msg.entries)) { if (firstEntry) { - logs.innerHTML = "" + logs.replaceChildren() firstEntry = false } + const previousScrollHeight = document.body.scrollHeight + const previousScrollY = window.scrollY const fragment = document.createDocumentFragment() for (const entry of msg.entries) { const line = buildLogLine(entry) @@ -243,15 +251,19 @@ window.addEventListener("message", (event) => { fragment.append(line) } logs.prepend(fragment) + window.scrollTo( + 0, + previousScrollY + document.body.scrollHeight - previousScrollHeight, + ) } else if (msg.type === "status") { - const safe = esc(msg.text) + const status = buildStatusLine(String(msg.text ?? "")) if (firstEntry) { - logs.innerHTML = `${safe}` + logs.replaceChildren(status) } else { - logs.insertAdjacentHTML("beforeend", `
${safe}
`) + logs.append(status) } } else if (msg.type === "clear") { - logs.innerHTML = "" + logs.replaceChildren() firstEntry = true } else if (msg.type === "streamingState") { setStreamingState(msg.streaming, msg.appLabel) @@ -260,10 +272,6 @@ window.addEventListener("message", (event) => { } else if (msg.type === "historyNotice") { showHistoryNotice(String(msg.text ?? "")) } else if (msg.type === "historyState") { - setHistoryState( - Boolean(msg.hasOlder), - Boolean(msg.loading), - Boolean(msg.checked), - ) + setHistoryState(Boolean(msg.hasOlder), Boolean(msg.loading)) } }) diff --git a/src/test/cloud/commands/logs.test.ts b/src/test/cloud/commands/logs.test.ts index 341e35f..ce5f879 100644 --- a/src/test/cloud/commands/logs.test.ts +++ b/src/test/cloud/commands/logs.test.ts @@ -14,6 +14,13 @@ import { mockApiService, mockConfigService } from "../../testUtils" const testWorkspaceUri = vscode.Uri.file("/tmp/test") const testExtensionUri = vscode.Uri.file("/tmp/extension") +interface TestLogEntry { + timestamp: string + timestamp_ns: string + message: string + level: string +} + function createWebviewView() { const messages: any[] = [] const messageHandlers: ((msg: any) => void)[] = [] @@ -68,6 +75,18 @@ function createProvider( return { provider, configService, apiService } } +function createLogEntry( + timestamp: string, + timestamp_ns: string, + message: string, +): TestLogEntry { + return { timestamp, timestamp_ns, message, level: "info" } +} + +async function* streamEntries(entries: TestLogEntry[]) { + for (const entry of entries) yield entry +} + suite("cloud/commands/logs", () => { teardown(() => sinon.restore()) @@ -181,6 +200,50 @@ suite("cloud/commands/logs", () => { assert.ok(statusMessages.some((m) => m.text.includes("No logs found"))) }) + test("enables manual older log loading when the initial stream stays empty", async () => { + const clock = sinon.useFakeTimers(new Date("2025-01-15T10:40:00Z")) + const { provider, configService, apiService } = createProvider() + const { view, messages } = createWebviewView() + provider.resolveWebviewView(view) + configService.getConfig.resolves({ app_id: "a1", team_id: "t1" }) + + let releaseStream: (() => void) | undefined + async function* delayedStream() { + await new Promise((resolve) => { + releaseStream = resolve + }) + yield createLogEntry( + "2025-01-15T10:35:00Z", + "1736937300000000000", + "eventual log", + ) + } + apiService.streamAppLogs.returns(delayedStream()) + apiService.getAppLogs.resolves({ + logs: [], + has_more: false, + }) + sinon.stub(vscode.commands, "executeCommand").resolves() + + const streamPromise = provider.streamLogs({ since: "30m" }) + await Promise.resolve() + await Promise.resolve() + await clock.tickAsync(1000) + + assert.strictEqual(apiService.getAppLogs.called, false) + assert.ok( + messages.some( + (m) => + m.type === "historyState" && + m.hasOlder === true && + m.loading === false, + ), + ) + + releaseStream?.() + await streamPromise + }) + test("handles StreamLogError", async () => { const { provider, configService, apiService } = createProvider() const { view, messages } = createWebviewView() @@ -293,15 +356,15 @@ suite("cloud/commands/logs", () => { provider.resolveWebviewView(view) configService.getConfig.resolves({ app_id: "a1", team_id: "t1" }) - async function* fakeStream() { - yield { - timestamp: "2025-01-15T10:30:00Z", - timestamp_ns: "1736937000000000000", - message: "line 1", - level: "info", - } - } - apiService.streamAppLogs.returns(fakeStream()) + apiService.streamAppLogs.returns( + streamEntries([ + createLogEntry( + "2025-01-15T10:30:00Z", + "1736937000000000000", + "line 1", + ), + ]), + ) sinon.stub(vscode.commands, "executeCommand").resolves() await provider.streamLogs({ since: "1h" }) @@ -311,8 +374,7 @@ suite("cloud/commands/logs", () => { (m) => m.type === "historyState" && m.hasOlder === true && - m.loading === false && - m.checked === false, + m.loading === false, ), ) }) @@ -324,23 +386,22 @@ suite("cloud/commands/logs", () => { provider.resolveWebviewView(view) configService.getConfig.resolves({ app_id: "a1", team_id: "t1" }) - async function* fakeStream() { - yield { - timestamp: "2025-01-15T10:30:00Z", - timestamp_ns: "1736937000000000000", - message: "newer", - level: "info", - } - } - apiService.streamAppLogs.returns(fakeStream()) + apiService.streamAppLogs.returns( + streamEntries([ + createLogEntry( + "2025-01-15T10:30:00Z", + "1736937000000000000", + "newer", + ), + ]), + ) apiService.getAppLogs.resolves({ logs: [ - { - timestamp: "2025-01-15T10:20:00Z", - timestamp_ns: "1736936400000000000", - message: "older", - level: "info", - }, + createLogEntry( + "2025-01-15T10:20:00Z", + "1736936400000000000", + "older", + ), ], has_more: false, }) @@ -362,8 +423,7 @@ suite("cloud/commands/logs", () => { (m) => m.type === "historyState" && m.hasOlder === false && - m.loading === false && - m.checked === true, + m.loading === false, ), ) }) @@ -375,23 +435,22 @@ suite("cloud/commands/logs", () => { provider.resolveWebviewView(view) configService.getConfig.resolves({ app_id: "a1", team_id: "t1" }) - async function* fakeStream() { - yield { - timestamp: "2025-01-15T10:30:00Z", - timestamp_ns: "1736937000000000000", - message: "newer", - level: "info", - } - } - apiService.streamAppLogs.returns(fakeStream()) + apiService.streamAppLogs.returns( + streamEntries([ + createLogEntry( + "2025-01-15T10:30:00Z", + "1736937000000000000", + "newer", + ), + ]), + ) apiService.getAppLogs.resolves({ logs: [ - { - timestamp: "2025-01-15T10:20:00Z", - timestamp_ns: "1736936400000000000", - message: "older", - level: "info", - }, + createLogEntry( + "2025-01-15T10:20:00Z", + "1736936400000000000", + "older", + ), ], has_more: true, }) @@ -405,8 +464,7 @@ suite("cloud/commands/logs", () => { (m) => m.type === "historyState" && m.hasOlder === true && - m.loading === false && - m.checked === true, + m.loading === false, ), ) }) @@ -418,23 +476,22 @@ suite("cloud/commands/logs", () => { provider.resolveWebviewView(view) configService.getConfig.resolves({ app_id: "a1", team_id: "t1" }) - async function* fakeStream() { - yield { - timestamp: "2025-01-15T10:30:00Z", - timestamp_ns: "1736937000000000000", - message: "newer", - level: "info", - } - } - apiService.streamAppLogs.returns(fakeStream()) + apiService.streamAppLogs.returns( + streamEntries([ + createLogEntry( + "2025-01-15T10:30:00Z", + "1736937000000000000", + "newer", + ), + ]), + ) apiService.getAppLogs.resolves({ logs: [ - { - timestamp: "2025-01-15T10:00:00Z", - timestamp_ns: "1736935200000000000", - message: "too old", - level: "info", - }, + createLogEntry( + "2025-01-15T10:00:00Z", + "1736935200000000000", + "too old", + ), ], has_more: true, }) @@ -448,7 +505,7 @@ suite("cloud/commands/logs", () => { messages.some( (m) => m.type === "historyNotice" && - m.text === "No earlier logs in this range.", + m.text === "No earlier logs in the last 30 minutes.", ), ) assert.ok( @@ -467,56 +524,80 @@ suite("cloud/commands/logs", () => { ) }) - test("ignores older log responses after a new stream starts", async () => { + test("suggests choosing a longer range when no loaded logs are in the selected range", async () => { sinon.useFakeTimers(new Date("2025-01-15T10:40:00Z")) const { provider, configService, apiService } = createProvider() const { view, messages } = createWebviewView() provider.resolveWebviewView(view) configService.getConfig.resolves({ app_id: "a1", team_id: "t1" }) - async function* firstStream() { - yield { - timestamp: "2025-01-15T10:30:00Z", - timestamp_ns: "1736937000000000000", - message: "first stream", - level: "info", - } - } + async function* emptyStream() {} + apiService.streamAppLogs.returns(emptyStream()) + apiService.getAppLogs.resolves({ + logs: [ + createLogEntry( + "2025-01-15T10:00:00Z", + "1736935200000000000", + "outside selected range", + ), + ], + has_more: true, + }) + sinon.stub(vscode.commands, "executeCommand").resolves() - async function* secondStream() { - yield { - timestamp: "2025-01-15T10:35:00Z", - timestamp_ns: "1736937300000000000", - message: "second stream", - level: "info", - } - } + await provider.streamLogs({ since: "30m" }) + await provider.loadOlderLogs() + + assert.ok(!messages.some((m) => m.type === "olderLogs")) + assert.ok( + messages.some( + (m) => + m.type === "historyNotice" && + m.text === + "No logs found in the last 30 minutes. Choose a longer range to see older logs.", + ), + ) + }) + + test("ignores older log responses after a new stream starts", async () => { + sinon.useFakeTimers(new Date("2025-01-15T10:40:00Z")) + const { provider, configService, apiService } = createProvider() + const { view, messages } = createWebviewView() + provider.resolveWebviewView(view) + configService.getConfig.resolves({ app_id: "a1", team_id: "t1" }) let resolveOlderLogs: - | ((response: { - logs: { - timestamp: string - timestamp_ns: string - message: string - level: string - }[] - has_more: boolean - }) => void) + | ((response: { logs: TestLogEntry[]; has_more: boolean }) => void) | undefined const olderLogsPromise = new Promise<{ - logs: { - timestamp: string - timestamp_ns: string - message: string - level: string - }[] + logs: TestLogEntry[] has_more: boolean }>((resolve) => { resolveOlderLogs = resolve }) - apiService.streamAppLogs.onFirstCall().returns(firstStream()) - apiService.streamAppLogs.onSecondCall().returns(secondStream()) + apiService.streamAppLogs + .onFirstCall() + .returns( + streamEntries([ + createLogEntry( + "2025-01-15T10:30:00Z", + "1736937000000000000", + "first stream", + ), + ]), + ) + apiService.streamAppLogs + .onSecondCall() + .returns( + streamEntries([ + createLogEntry( + "2025-01-15T10:35:00Z", + "1736937300000000000", + "second stream", + ), + ]), + ) apiService.getAppLogs.returns(olderLogsPromise) sinon.stub(vscode.commands, "executeCommand").resolves() @@ -526,12 +607,11 @@ suite("cloud/commands/logs", () => { resolveOlderLogs?.({ logs: [ - { - timestamp: "2025-01-15T10:20:00Z", - timestamp_ns: "1736936400000000000", - message: "stale older", - level: "info", - }, + createLogEntry( + "2025-01-15T10:20:00Z", + "1736936400000000000", + "stale older", + ), ], has_more: false, }) @@ -555,16 +635,15 @@ suite("cloud/commands/logs", () => { provider.resolveWebviewView(view) configService.getConfig.resolves({ app_id: "a1", team_id: "t1" }) - async function* fakeStream() { - yield { - timestamp: "2025-01-15T10:30:00Z", - timestamp_ns: "1736937000000000000", - message: "newer", - level: "info", - } - } - - apiService.streamAppLogs.returns(fakeStream()) + apiService.streamAppLogs.returns( + streamEntries([ + createLogEntry( + "2025-01-15T10:30:00Z", + "1736937000000000000", + "newer", + ), + ]), + ) apiService.getAppLogs.rejects(new Error("network failed")) sinon.stub(vscode.commands, "executeCommand").resolves() @@ -863,7 +942,7 @@ suite("cloud/commands/logs", () => { assert.ok(html.includes('id="since-filter"')) assert.ok(html.includes('id="load-older-btn"')) assert.ok(html.includes('id="history-note"')) - assert.ok(html.includes("Check earlier logs")) + assert.ok(html.includes("Load earlier logs")) assert.ok(html.includes('id="stream-btn"')) assert.ok(html.includes('id="filter-btn"')) assert.ok(html.includes('id="clear-btn"')) From a9aa2a399750c56ce6a62ce92fc45a7518b0f145 Mon Sep 17 00:00:00 2001 From: Savannah Ostrowski Date: Mon, 6 Jul 2026 11:59:45 -0700 Subject: [PATCH 3/3] Fix test warning --- src/test/cloud/commands/logs.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/test/cloud/commands/logs.test.ts b/src/test/cloud/commands/logs.test.ts index ce5f879..01d1168 100644 --- a/src/test/cloud/commands/logs.test.ts +++ b/src/test/cloud/commands/logs.test.ts @@ -201,7 +201,10 @@ suite("cloud/commands/logs", () => { }) test("enables manual older log loading when the initial stream stays empty", async () => { - const clock = sinon.useFakeTimers(new Date("2025-01-15T10:40:00Z")) + const clock = sinon.useFakeTimers({ + now: new Date("2025-01-15T10:40:00Z"), + shouldClearNativeTimers: true, + }) const { provider, configService, apiService } = createProvider() const { view, messages } = createWebviewView() provider.resolveWebviewView(view)