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..b0e11e4 100644 --- a/src/cloud/commands/logs.ts +++ b/src/cloud/commands/logs.ts @@ -3,18 +3,67 @@ 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" }, ] +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 retentionDays = Math.floor(logRetentionDays) + const extraOptions = EXTRA_SINCE_OPTION_DAYS.filter( + (days) => days <= retentionDays, + ).map(dayOption) + return [...BASE_SINCE_OPTIONS, ...extraOptions] +} + +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 +} + +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]. @@ -64,10 +113,31 @@ export function formatLogEntry(entry: AppLogEntry): AppLogEntry { return { level, timestamp: formatTimestamp(entry.timestamp), + timestamp_ns: entry.timestamp_ns, message: entry.message, } } +function getTimestampEpochMs(entry: AppLogEntry): number { + const timestampMs = new Date(entry.timestamp).getTime() + return Number.isNaN(timestampMs) ? 0 : timestampMs +} + +function getPaginationCursorNs(entry: AppLogEntry): string | undefined { + if (entry.timestamp_ns) return entry.timestamp_ns + const timestampMs = getTimestampEpochMs(entry) + return timestampMs > 0 ? `${timestampMs}000000` : undefined +} + +interface HistoryState { + appId: string + beforeNs: string + windowStartEpochMs: number + rangeLabel: string + hasOlder: boolean + hasLogs: boolean +} + // --- Webview HTML --- function getLevelChipsHtml(): string { @@ -77,11 +147,15 @@ function getLevelChipsHtml(): string { ).join("\n") } +function getSinceOptionHtml(option: SinceOption, selected: boolean): string { + const selectedAttr = selected ? " selected" : "" + return `` +} + function getSinceOptionsHtml(): string { - return SINCE_OPTIONS.map( - (o, i) => - ``, - ).join("") + return getSinceOptions() + .map((option, index) => getSinceOptionHtml(option, index === 0)) + .join("") } export function getWebviewHtml( @@ -102,11 +176,11 @@ export function getWebviewHtml( - -
- -
- + +
+ +
+
@@ -123,10 +197,14 @@ export function getWebviewHtml(
-
- -
-
Click "Start" to stream logs.
+
+ +
+ +
Click "Start" to stream logs.
` @@ -135,6 +213,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 +235,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 +243,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 +286,116 @@ 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(options?: { + hasOlder?: boolean + loading?: boolean + }): void { + const state = this.historyState + this.view?.webview.postMessage({ + type: "historyState", + hasOlder: options?.hasOlder ?? state?.hasOlder ?? false, + loading: options?.loading ?? false, + }) + } + + private updateHistoryCursor(entry: AppLogEntry): void { + const cursor = getPaginationCursorNs(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 windowStartEpochMs = this.historyState?.windowStartEpochMs ?? 0 + let reachedWindowStart = false + const filteredLogs = logs.filter((entry) => { + 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({ loading: 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.hasLogs = true + state.beforeNs = getPaginationCursorNs(logs[0]) ?? state.beforeNs + this.view?.webview.postMessage({ + type: "olderLogs", + entries: logs.map(formatLogEntry), + }) + } + + state.hasOlder = response.has_more && !reachedWindowStart + this.postHistoryState() + if (logs.length === 0 && !state.hasOlder) { + this.view?.webview.postMessage({ + type: "historyNotice", + text: this.getNoOlderLogsText(state), + }) + } + } 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() + 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 +413,8 @@ export class LogsViewProvider implements vscode.WebviewViewProvider { return } + await this.updateSinceOptionsForConfig(config) + const since = options?.since ?? "5m" const tail = options?.tail ?? 100 @@ -237,9 +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: `${nowEpochMs}000000`, + windowStartEpochMs: nowEpochMs - parseSinceMs(since), + rangeLabel: formatSinceLabel(since), + hasOlder: true, + hasLogs: false, + } if (this.view) { this.view.webview.postMessage({ type: "clear" }) + this.postHistoryState({ hasOlder: false }) this.view.webview.postMessage({ type: "status", text: "Connecting to log stream...", @@ -261,20 +465,25 @@ 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() } - }, 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.view.webview.postMessage({ type: "log", entry: formatLogEntry(entry), @@ -292,6 +501,7 @@ export class LogsViewProvider implements vscode.WebviewViewProvider { text: "Stream ended.", }) } + this.postHistoryState() } 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..615d2c9 100644 --- a/src/cloud/ui/panel/webview.ts +++ b/src/cloud/ui/panel/webview.ts @@ -5,11 +5,26 @@ import type { AppLogEntry } from "../../api" declare function acquireVsCodeApi(): { postMessage(msg: unknown): void } +interface SinceOption { + label: string + 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 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")! @@ -18,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"), @@ -54,10 +65,16 @@ filterBtn.addEventListener("click", (e) => { }) clearBtn.addEventListener("click", () => { - logs.innerHTML = "" + logs.replaceChildren() firstEntry = true }) +loadOlderBtn.addEventListener("click", () => { + loadOlderBtn.disabled = true + loadOlderBtn.textContent = "Loading logs..." + vscode.postMessage({ type: "loadOlder" }) +}) + function isNearBottom(): boolean { return document.body.scrollHeight - window.innerHeight - window.scrollY < 8 } @@ -131,6 +148,54 @@ function setStreamingState(streaming: boolean, appLabel?: string): void { streaming && appLabel ? `Streaming logs for ${appLabel}...` : "" } +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 + } + + 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 { + renderHistoryRow({ kind: "notice", text }) +} + +function updateSinceOptions(options: SinceOption[]): void { + const previousValue = sinceFilter.value + sinceFilter.replaceChildren() + 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,33 +212,66 @@ 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") + } +} + window.addEventListener("message", (event) => { const msg = event.data if (msg.type === "log") { if (firstEntry) { - logs.innerHTML = "" + logs.replaceChildren() firstEntry = false } 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.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) + applyCurrentFilters(line) + 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) + } 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)) } }) 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..01d1168 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" @@ -13,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)[] = [] @@ -67,9 +75,37 @@ 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()) + 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() @@ -164,6 +200,53 @@ 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({ + now: new Date("2025-01-15T10:40:00Z"), + shouldClearNativeTimers: true, + }) + 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() @@ -268,6 +351,315 @@ 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" }) + + apiService.streamAppLogs.returns( + streamEntries([ + createLogEntry( + "2025-01-15T10:30:00Z", + "1736937000000000000", + "line 1", + ), + ]), + ) + 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, + ), + ) + }) + + 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" }) + + apiService.streamAppLogs.returns( + streamEntries([ + createLogEntry( + "2025-01-15T10:30:00Z", + "1736937000000000000", + "newer", + ), + ]), + ) + apiService.getAppLogs.resolves({ + logs: [ + createLogEntry( + "2025-01-15T10:20:00Z", + "1736936400000000000", + "older", + ), + ], + 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, + ), + ) + }) + + 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" }) + + apiService.streamAppLogs.returns( + streamEntries([ + createLogEntry( + "2025-01-15T10:30:00Z", + "1736937000000000000", + "newer", + ), + ]), + ) + apiService.getAppLogs.resolves({ + logs: [ + createLogEntry( + "2025-01-15T10:20:00Z", + "1736936400000000000", + "older", + ), + ], + 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, + ), + ) + }) + + 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" }) + + apiService.streamAppLogs.returns( + streamEntries([ + createLogEntry( + "2025-01-15T10:30:00Z", + "1736937000000000000", + "newer", + ), + ]), + ) + apiService.getAppLogs.resolves({ + logs: [ + createLogEntry( + "2025-01-15T10:00:00Z", + "1736935200000000000", + "too old", + ), + ], + 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 the last 30 minutes.", + ), + ) + 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("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* 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() + + 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: TestLogEntry[]; has_more: boolean }) => void) + | undefined + const olderLogsPromise = new Promise<{ + logs: TestLogEntry[] + has_more: boolean + }>((resolve) => { + resolveOlderLogs = resolve + }) + + 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() + + await provider.streamLogs({ since: "1h" }) + const loadPromise = provider.loadOlderLogs() + await provider.streamLogs({ since: "5m" }) + + resolveOlderLogs?.({ + logs: [ + createLogEntry( + "2025-01-15T10:20:00Z", + "1736936400000000000", + "stale older", + ), + ], + 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" }) + + 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() + + 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 +940,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("Load 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