diff --git a/extension/loc/xlf/aspire-vscode.xlf b/extension/loc/xlf/aspire-vscode.xlf index 4c427c51070..5352a86b3d5 100644 --- a/extension/loc/xlf/aspire-vscode.xlf +++ b/extension/loc/xlf/aspire-vscode.xlf @@ -181,6 +181,9 @@ Execute resource command + + Expand all resources + Explore the dashboard diff --git a/extension/package.json b/extension/package.json index 7db5f4ed6ef..abd0aa3ea43 100644 --- a/extension/package.json +++ b/extension/package.json @@ -360,6 +360,12 @@ "command": "aspire-vscode.codeLensRevealResource", "title": "%command.codeLensRevealResource%", "category": "Aspire" + }, + { + "command": "aspire-vscode.expandAll", + "title": "%command.expandAll%", + "category": "Aspire", + "icon": "$(expand-all)" } ], "jsonValidation": [ @@ -493,6 +499,10 @@ { "command": "aspire-vscode.copyPid", "when": "false" + }, + { + "command": "aspire-vscode.expandAll", + "when": "false" } ], "view/title": [ @@ -518,6 +528,11 @@ "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^(appHost|workspaceResources)$/", "group": "inline" }, + { + "command": "aspire-vscode.expandAll", + "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^(appHost|workspaceResources)$/", + "group": "inline" + }, { "command": "aspire-vscode.openAppHostSource", "when": "view == aspire-vscode.runningAppHosts && viewItem =~ /^(appHost|workspaceResources)$/", diff --git a/extension/package.nls.json b/extension/package.nls.json index a32ac426daf..4b5660448ba 100644 --- a/extension/package.nls.json +++ b/extension/package.nls.json @@ -149,6 +149,7 @@ "command.codeLensResourceAction": "Aspire resource action", "command.codeLensRevealResource": "Reveal resource in Aspire panel", "command.codeLensViewLogs": "View Aspire resource logs", + "command.expandAll": "Expand all resources", "walkthrough.getStarted.title": "Get started with Aspire", "walkthrough.getStarted.description": "Learn how to create, run, and monitor distributed applications with Aspire.", "walkthrough.getStarted.welcome.title": "Welcome to Aspire", diff --git a/extension/resources/editor-indicators-dark.png b/extension/resources/editor-indicators-dark.png new file mode 100644 index 00000000000..083599f1196 Binary files /dev/null and b/extension/resources/editor-indicators-dark.png differ diff --git a/extension/src/editor/AspireCodeLensProvider.ts b/extension/src/editor/AspireCodeLensProvider.ts index c84179b1cf7..2d6ce7d139d 100644 --- a/extension/src/editor/AspireCodeLensProvider.ts +++ b/extension/src/editor/AspireCodeLensProvider.ts @@ -13,8 +13,12 @@ import { codeLensResourceRunningWarning, codeLensResourceRunningError, codeLensResourceStarting, + codeLensResourceNotStarted, + codeLensResourceWaiting, codeLensResourceStopped, + codeLensResourceStoppedWithExitCode, codeLensResourceStoppedError, + codeLensResourceStoppedErrorWithExitCode, codeLensResourceError, codeLensRestart, codeLensStop, @@ -105,14 +109,31 @@ export class AspireCodeLensProvider implements vscode.CodeLensProvider { const commands = resource.commands ? Object.keys(resource.commands) : []; // State indicator lens (clickable — reveals resource in tree view) - let stateLabel = getCodeLensStateLabel(state, stateStyle); + let stateLabel = getCodeLensStateLabel(state, stateStyle, resource.exitCode); if (healthStatus && healthStatus !== HealthStatus.Healthy) { - stateLabel += ` - (${healthStatus})`; + const reports = resource.healthReports; + if (reports) { + const entries = Object.values(reports); + const healthy = entries.filter(r => r.status === HealthStatus.Healthy).length; + stateLabel += ` - (${healthStatus} ${healthy}/${entries.length})`; + } else { + stateLabel += ` - (${healthStatus})`; + } } + + let tooltipText = `${resource.displayName ?? resource.name}: ${state}${healthStatus ? ` (${healthStatus})` : ''}`; + const reports = resource.healthReports; + if (reports && healthStatus && healthStatus !== HealthStatus.Healthy) { + const failing = Object.entries(reports).filter(([, r]) => r.status !== HealthStatus.Healthy); + if (failing.length > 0) { + tooltipText += '\n' + failing.map(([name, r]) => ` ${name}: ${r.status}${r.description ? ` - ${r.description}` : ''}`).join('\n'); + } + } + lenses.push(new vscode.CodeLens(range, { title: stateLabel, command: 'aspire-vscode.codeLensRevealResource', - tooltip: `${resource.displayName ?? resource.name}: ${state}${healthStatus ? ` (${healthStatus})` : ''}`, + tooltip: tooltipText, arguments: [resource.displayName ?? resource.name], })); @@ -177,7 +198,7 @@ export class AspireCodeLensProvider implements vscode.CodeLensProvider { } } -export function getCodeLensStateLabel(state: string, stateStyle: string): string { +export function getCodeLensStateLabel(state: string, stateStyle: string, exitCode?: number | null): string { switch (state) { case ResourceState.Running: case ResourceState.Active: @@ -190,19 +211,23 @@ export function getCodeLensStateLabel(state: string, stateStyle: string): string return codeLensResourceRunning; case ResourceState.Starting: case ResourceState.Building: + return codeLensResourceStarting; case ResourceState.Waiting: + return codeLensResourceWaiting; case ResourceState.NotStarted: - return codeLensResourceStarting; + return codeLensResourceNotStarted; case ResourceState.FailedToStart: case ResourceState.RuntimeUnhealthy: return codeLensResourceError; + case ResourceState.Stopping: + return codeLensResourceStarting; case ResourceState.Finished: case ResourceState.Exited: - case ResourceState.Stopping: + case ResourceState.Stopped: if (stateStyle === StateStyle.Error) { - return codeLensResourceStoppedError; + return exitCode != null ? codeLensResourceStoppedErrorWithExitCode(exitCode) : codeLensResourceStoppedError; } - return codeLensResourceStopped; + return exitCode != null ? codeLensResourceStoppedWithExitCode(exitCode) : codeLensResourceStopped; default: return state || codeLensResourceStopped; } diff --git a/extension/src/editor/AspireGutterDecorationProvider.ts b/extension/src/editor/AspireGutterDecorationProvider.ts index d1eaca4d198..15461949d04 100644 --- a/extension/src/editor/AspireGutterDecorationProvider.ts +++ b/extension/src/editor/AspireGutterDecorationProvider.ts @@ -7,39 +7,73 @@ import { AspireAppHostTreeProvider } from '../views/AspireAppHostTreeProvider'; import { findResourceState, findWorkspaceResourceState } from './resourceStateUtils'; import { ResourceState, StateStyle, HealthStatus } from './resourceConstants'; -type GutterCategory = 'running' | 'warning' | 'error' | 'starting' | 'stopped'; - -const gutterCategories: GutterCategory[] = ['running', 'warning', 'error', 'starting', 'stopped']; - -const gutterColors: Record = { - running: '#28a745', // green - warning: '#e0a30b', // yellow/amber - error: '#d73a49', // red - starting: '#2188ff', // blue - stopped: '#6a737d', // gray -}; - -/** Creates a data-URI SVG of a filled circle with the given color. */ -function makeGutterSvgUri(color: string): vscode.Uri { - const svg = ``; +type GutterCategory = 'running' | 'warning' | 'error' | 'starting' | 'stopped' | 'completed'; + +const gutterCategories: GutterCategory[] = ['running', 'warning', 'error', 'starting', 'stopped', 'completed']; + +/** + * Creates a data-URI SVG gutter icon for each category. + * Uses distinct shapes (not just colored dots) so they aren't confused with breakpoints. + */ +function makeGutterSvgUri(category: GutterCategory): vscode.Uri { + let svg: string; + switch (category) { + case 'running': + // Green checkmark ✅ + svg = ` + + `; + break; + case 'warning': + // Yellow warning triangle ⚠️ + svg = ` + + ! + `; + break; + case 'error': + // Red X ❌ + svg = ` + + `; + break; + case 'starting': + // Blue hourglass ⌛ + svg = ` + + `; + break; + case 'stopped': + // Grey hollow circle (clearly distinct from solid breakpoint dot) + svg = ` + + `; + break; + case 'completed': + // Pale green checkmark (lighter than running) + svg = ` + + `; + break; + } return vscode.Uri.parse(`data:image/svg+xml;utf8,${encodeURIComponent(svg)}`); } const decorationTypes = Object.fromEntries( gutterCategories.map(c => [c, vscode.window.createTextEditorDecorationType({ - gutterIconPath: makeGutterSvgUri(gutterColors[c]), + gutterIconPath: makeGutterSvgUri(c), gutterIconSize: '70%', })]) ) as Record; -function classifyState(state: string, stateStyle: string, healthStatus: string): GutterCategory { +function classifyState(state: string, stateStyle: string, healthStatus: string, exitCode?: number | null): GutterCategory { switch (state) { case ResourceState.Running: case ResourceState.Active: - if (stateStyle === StateStyle.Error || healthStatus === HealthStatus.Unhealthy) { + if (stateStyle === StateStyle.Error) { return 'error'; } - if (stateStyle === StateStyle.Warning || healthStatus === HealthStatus.Degraded) { + if (healthStatus === HealthStatus.Unhealthy || healthStatus === HealthStatus.Degraded || stateStyle === StateStyle.Warning) { return 'warning'; } return 'running'; @@ -50,11 +84,16 @@ function classifyState(state: string, stateStyle: string, healthStatus: string): case ResourceState.Stopping: case ResourceState.Building: case ResourceState.Waiting: - case ResourceState.NotStarted: return 'starting'; + case ResourceState.NotStarted: + return 'stopped'; case ResourceState.Finished: case ResourceState.Exited: - return stateStyle === StateStyle.Error ? 'error' : 'stopped'; + case ResourceState.Stopped: + if (stateStyle === StateStyle.Error || (exitCode != null && exitCode !== 0)) { + return 'error'; + } + return 'completed'; default: return 'stopped'; } @@ -138,7 +177,7 @@ export class AspireGutterDecorationProvider implements vscode.Disposable { } const { resource } = match; - const category = classifyState(resource.state ?? '', resource.stateStyle ?? '', resource.healthStatus ?? ''); + const category = classifyState(resource.state ?? '', resource.stateStyle ?? '', resource.healthStatus ?? '', resource.exitCode); buckets.get(category)!.push({ range: editor.document.lineAt(parsed.range.start.line).range }); } diff --git a/extension/src/extension.ts b/extension/src/extension.ts index d05edf4dbe3..23944a1bb60 100644 --- a/extension/src/extension.ts +++ b/extension/src/extension.ts @@ -82,7 +82,9 @@ export async function activate(context: vscode.ExtensionContext) { const appHostTreeProvider = new AspireAppHostTreeProvider(dataRepository, terminalProvider); const appHostTreeView = vscode.window.createTreeView('aspire-vscode.runningAppHosts', { treeDataProvider: appHostTreeProvider, + showCollapseAll: true, }); + appHostTreeProvider.setTreeView(appHostTreeView); // Global-mode polling is tied to panel visibility dataRepository.setPanelVisible(appHostTreeView.visible); @@ -107,6 +109,7 @@ export async function activate(context: vscode.ExtensionContext) { const copyResourceNameRegistration = vscode.commands.registerCommand('aspire-vscode.copyResourceName', (element) => appHostTreeProvider.copyResourceName(element)); const copyPidRegistration = vscode.commands.registerCommand('aspire-vscode.copyPid', (element) => appHostTreeProvider.copyPid(element)); const copyAppHostPathRegistration = vscode.commands.registerCommand('aspire-vscode.copyAppHostPath', (element) => appHostTreeProvider.copyAppHostPath(element)); + const expandAllRegistration = vscode.commands.registerCommand('aspire-vscode.expandAll', (element) => appHostTreeProvider.expandAll(element)); // Set initial context for welcome view vscode.commands.executeCommand('setContext', 'aspire.noRunningAppHosts', true); @@ -115,7 +118,7 @@ export async function activate(context: vscode.ExtensionContext) { // Activate the data repository (starts workspace describe --follow; global polling begins when the panel is visible) dataRepository.activate(); - context.subscriptions.push(appHostTreeView, refreshRunningAppHostsRegistration, switchToGlobalViewRegistration, switchToWorkspaceViewRegistration, openDashboardRegistration, openAppHostSourceRegistration, stopAppHostRegistration, stopResourceRegistration, startResourceRegistration, restartResourceRegistration, viewResourceLogsRegistration, executeResourceCommandRegistration, copyEndpointUrlRegistration, openInExternalBrowserRegistration, openInSimpleBrowserRegistration, copyResourceNameRegistration, copyPidRegistration, copyAppHostPathRegistration, { dispose: () => { appHostTreeProvider.dispose(); dataRepository.dispose(); } }); + context.subscriptions.push(appHostTreeView, refreshRunningAppHostsRegistration, switchToGlobalViewRegistration, switchToWorkspaceViewRegistration, openDashboardRegistration, openAppHostSourceRegistration, stopAppHostRegistration, stopResourceRegistration, startResourceRegistration, restartResourceRegistration, viewResourceLogsRegistration, executeResourceCommandRegistration, copyEndpointUrlRegistration, openInExternalBrowserRegistration, openInSimpleBrowserRegistration, copyResourceNameRegistration, copyPidRegistration, copyAppHostPathRegistration, expandAllRegistration, { dispose: () => { appHostTreeProvider.dispose(); dataRepository.dispose(); } }); // CodeLens provider — shows Debug on pipeline steps, resource state on resources const codeLensProvider = new AspireCodeLensProvider(appHostTreeProvider); diff --git a/extension/src/loc/strings.ts b/extension/src/loc/strings.ts index db6afd8ef38..c4c7166e083 100644 --- a/extension/src/loc/strings.ts +++ b/extension/src/loc/strings.ts @@ -59,7 +59,6 @@ export const cliPidLabel = (pid: number) => vscode.l10n.t('CLI PID: {0}', pid); export const appHostPidLabel = (pid: number) => vscode.l10n.t('Apphost PID: {0}', pid); export const errorFetchingAppHosts = (error: string) => vscode.l10n.t('Error fetching running apphosts: {0}', error); export const resourcesGroupLabel = vscode.l10n.t('Resources'); -export const resourceStateLabel = (name: string, state: string) => vscode.l10n.t('{0} — {1}', name, state); export const noCommandsAvailable = vscode.l10n.t('No commands available for this resource.'); export const selectCommandPlaceholder = vscode.l10n.t('Select a command to execute'); export const selectDashboardPlaceholder = vscode.l10n.t('Select a dashboard to open'); @@ -69,6 +68,10 @@ export const tooltipType = (type: string) => vscode.l10n.t('Type: {0}', type); export const tooltipState = (state: string) => vscode.l10n.t('State: {0}', state); export const tooltipHealth = (health: string) => vscode.l10n.t('Health: {0}', health); export const tooltipEndpoints = vscode.l10n.t('Endpoints:'); +export const healthChecksLabel = vscode.l10n.t('Health Checks'); +export const healthCheckDescription = (status: string) => vscode.l10n.t('Status: {0}', status); +export const resourceDescriptionHealth = (passed: number, total: number) => vscode.l10n.t('Health: {0}/{1}', passed, total); +export const resourceDescriptionExitCode = (exitCode: number) => vscode.l10n.t('Exit Code: {0}', exitCode); export const failedToStartDebugSession = vscode.l10n.t('Failed to start debug session.'); export const failedToGetConfigInfo = (exitCode: number) => vscode.l10n.t('Failed to get Aspire config info (exit code: {0}). Try updating the Aspire CLI with: aspire update', exitCode); export const failedToParseConfigInfo = (error: any) => vscode.l10n.t('Failed to parse Aspire config info: {0}. Try updating the Aspire CLI with: aspire update', error); @@ -107,8 +110,12 @@ export const codeLensResourceRunning = vscode.l10n.t('$(pass) Running'); export const codeLensResourceRunningWarning = vscode.l10n.t('$(warning) Running'); export const codeLensResourceRunningError = vscode.l10n.t('$(error) Running'); export const codeLensResourceStarting = vscode.l10n.t('$(loading~spin) Starting'); +export const codeLensResourceNotStarted = vscode.l10n.t('$(circle-outline) Not Started'); +export const codeLensResourceWaiting = vscode.l10n.t('$(loading~spin) Waiting'); export const codeLensResourceStopped = vscode.l10n.t('$(circle-outline) Stopped'); +export const codeLensResourceStoppedWithExitCode = (exitCode: number) => vscode.l10n.t('$(circle-outline) Stopped (Exit Code: {0})', exitCode); export const codeLensResourceStoppedError = vscode.l10n.t('$(error) Stopped'); +export const codeLensResourceStoppedErrorWithExitCode = (exitCode: number) => vscode.l10n.t('$(error) Stopped (Exit Code: {0})', exitCode); export const codeLensResourceError = vscode.l10n.t('$(error) Error'); export const codeLensRestart = vscode.l10n.t('$(debug-restart) Restart'); export const codeLensStop = vscode.l10n.t('$(debug-stop) Stop'); diff --git a/extension/src/test/appHostTreeView.test.ts b/extension/src/test/appHostTreeView.test.ts index 6a3e6d99c88..519f24b5532 100644 --- a/extension/src/test/appHostTreeView.test.ts +++ b/extension/src/test/appHostTreeView.test.ts @@ -1,7 +1,7 @@ import * as assert from 'assert'; import * as path from 'path'; import { shortenPath } from '../views/AppHostDataRepository'; -import { getResourceContextValue, getResourceIcon, resolveAppHostSourcePath } from '../views/AspireAppHostTreeProvider'; +import { getResourceContextValue, getResourceIcon, resolveAppHostSourcePath, buildResourceDescription } from '../views/AspireAppHostTreeProvider'; import type { ResourceJson } from '../views/AppHostDataRepository'; import { ResourceState, HealthStatus, StateStyle } from '../editor/resourceConstants'; @@ -13,6 +13,8 @@ function makeResource(overrides: Partial = {}): ResourceJson { state: null, stateStyle: null, healthStatus: null, + healthReports: null, + exitCode: null, dashboardUrl: null, urls: null, commands: null, @@ -154,9 +156,9 @@ suite('getResourceIcon', () => { assert.strictEqual(icon.id, 'pass'); }); - test('Running + Unhealthy shows error icon', () => { + test('Running + Unhealthy shows warning icon', () => { const icon = getResourceIcon(makeResource({ state: ResourceState.Running, healthStatus: HealthStatus.Unhealthy })); - assert.strictEqual(icon.id, 'error'); + assert.strictEqual(icon.id, 'warning'); }); test('Running + Degraded shows warning icon', () => { @@ -179,16 +181,21 @@ suite('getResourceIcon', () => { assert.strictEqual(icon.id, 'pass'); }); - test('Finished shows circle-outline', () => { - const icon = getResourceIcon(makeResource({ state: ResourceState.Finished })); - assert.strictEqual(icon.id, 'circle-outline'); - }); - test('Exited with error stateStyle shows error', () => { const icon = getResourceIcon(makeResource({ state: ResourceState.Exited, stateStyle: StateStyle.Error })); assert.strictEqual(icon.id, 'error'); }); + test('Exited with non-zero exit code shows error', () => { + const icon = getResourceIcon(makeResource({ state: ResourceState.Exited, exitCode: 137 })); + assert.strictEqual(icon.id, 'error'); + }); + + test('Finished with exit code 0 shows green pass', () => { + const icon = getResourceIcon(makeResource({ state: ResourceState.Finished, exitCode: 0 })); + assert.strictEqual(icon.id, 'pass'); + }); + test('FailedToStart shows error icon', () => { const icon = getResourceIcon(makeResource({ state: ResourceState.FailedToStart })); assert.strictEqual(icon.id, 'error'); @@ -209,9 +216,24 @@ suite('getResourceIcon', () => { assert.strictEqual(icon.id, 'loading~spin'); }); - test('null state shows circle-outline', () => { + test('Waiting shows loading spinner', () => { + const icon = getResourceIcon(makeResource({ state: ResourceState.Waiting })); + assert.strictEqual(icon.id, 'loading~spin'); + }); + + test('NotStarted shows record (no spinner)', () => { + const icon = getResourceIcon(makeResource({ state: ResourceState.NotStarted })); + assert.strictEqual(icon.id, 'record'); + }); + + test('Finished shows green pass', () => { + const icon = getResourceIcon(makeResource({ state: ResourceState.Finished })); + assert.strictEqual(icon.id, 'pass'); + }); + + test('null state shows record', () => { const icon = getResourceIcon(makeResource({ state: null })); - assert.strictEqual(icon.id, 'circle-outline'); + assert.strictEqual(icon.id, 'record'); }); test('unknown state shows circle-filled', () => { @@ -219,3 +241,43 @@ suite('getResourceIcon', () => { assert.strictEqual(icon.id, 'circle-filled'); }); }); + +suite('buildResourceDescription', () => { + test('no state, health, or exit code returns resource type', () => { + assert.strictEqual(buildResourceDescription(makeResource()), 'Project'); + }); + + test('with state shows type and state', () => { + assert.strictEqual(buildResourceDescription(makeResource({ state: 'Running' })), 'Project · Running'); + }); + + test('with health reports shows count', () => { + const desc = buildResourceDescription(makeResource({ + healthReports: { + 'check1': { status: 'Healthy', description: null, exceptionMessage: null }, + 'check2': { status: 'Unhealthy', description: null, exceptionMessage: null }, + }, + })); + assert.ok(desc.includes('1/2')); + }); + + test('with exit code shows exit code', () => { + const desc = buildResourceDescription(makeResource({ exitCode: 137 })); + assert.ok(desc.includes('137')); + }); + + test('with both health and exit code shows both', () => { + const desc = buildResourceDescription(makeResource({ + exitCode: 1, + healthReports: { + 'check1': { status: 'Healthy', description: null, exceptionMessage: null }, + }, + })); + assert.ok(desc.includes('1/1')); + assert.ok(desc.includes('Exit Code: 1')); + }); + + test('empty health reports returns resource type', () => { + assert.strictEqual(buildResourceDescription(makeResource({ healthReports: {} })), 'Project'); + }); +}); diff --git a/extension/src/test/codeLens.test.ts b/extension/src/test/codeLens.test.ts index 0f6a48ed242..d83a7b6d633 100644 --- a/extension/src/test/codeLens.test.ts +++ b/extension/src/test/codeLens.test.ts @@ -5,8 +5,12 @@ import { codeLensResourceRunningWarning, codeLensResourceRunningError, codeLensResourceStarting, + codeLensResourceNotStarted, + codeLensResourceWaiting, codeLensResourceStopped, + codeLensResourceStoppedWithExitCode, codeLensResourceStoppedError, + codeLensResourceStoppedErrorWithExitCode, codeLensResourceError, } from '../loc/strings'; import { ResourceState, StateStyle } from '../editor/resourceConstants'; @@ -48,12 +52,12 @@ suite('getCodeLensStateLabel', () => { assert.strictEqual(getCodeLensStateLabel(ResourceState.Building, ''), codeLensResourceStarting); }); - test('Waiting returns starting label', () => { - assert.strictEqual(getCodeLensStateLabel(ResourceState.Waiting, ''), codeLensResourceStarting); + test('Waiting returns waiting label', () => { + assert.strictEqual(getCodeLensStateLabel(ResourceState.Waiting, ''), codeLensResourceWaiting); }); - test('NotStarted returns starting label', () => { - assert.strictEqual(getCodeLensStateLabel(ResourceState.NotStarted, ''), codeLensResourceStarting); + test('NotStarted returns not-started label', () => { + assert.strictEqual(getCodeLensStateLabel(ResourceState.NotStarted, ''), codeLensResourceNotStarted); }); // --- Error states --- @@ -76,8 +80,8 @@ suite('getCodeLensStateLabel', () => { assert.strictEqual(getCodeLensStateLabel(ResourceState.Exited, ''), codeLensResourceStopped); }); - test('Stopping with no stateStyle returns stopped label', () => { - assert.strictEqual(getCodeLensStateLabel(ResourceState.Stopping, ''), codeLensResourceStopped); + test('Stopped with no stateStyle returns stopped label', () => { + assert.strictEqual(getCodeLensStateLabel(ResourceState.Stopped, ''), codeLensResourceStopped); }); test('Finished with error stateStyle returns stopped-error label', () => { @@ -88,8 +92,30 @@ suite('getCodeLensStateLabel', () => { assert.strictEqual(getCodeLensStateLabel(ResourceState.Exited, StateStyle.Error), codeLensResourceStoppedError); }); - test('Stopping with error stateStyle returns stopped-error label', () => { - assert.strictEqual(getCodeLensStateLabel(ResourceState.Stopping, StateStyle.Error), codeLensResourceStoppedError); + test('Stopping returns starting label', () => { + assert.strictEqual(getCodeLensStateLabel(ResourceState.Stopping, ''), codeLensResourceStarting); + }); + + test('Stopping with error stateStyle still returns starting label', () => { + assert.strictEqual(getCodeLensStateLabel(ResourceState.Stopping, StateStyle.Error), codeLensResourceStarting); + }); + + // --- Exit code tests --- + + test('Finished with exitCode returns stopped-with-exit-code label', () => { + assert.strictEqual(getCodeLensStateLabel(ResourceState.Finished, '', 0), codeLensResourceStoppedWithExitCode(0)); + }); + + test('Exited with exitCode and error stateStyle returns stopped-error-with-exit-code label', () => { + assert.strictEqual(getCodeLensStateLabel(ResourceState.Exited, StateStyle.Error, 1), codeLensResourceStoppedErrorWithExitCode(1)); + }); + + test('Finished with null exitCode returns stopped label', () => { + assert.strictEqual(getCodeLensStateLabel(ResourceState.Finished, '', null), codeLensResourceStopped); + }); + + test('Finished with undefined exitCode returns stopped label', () => { + assert.strictEqual(getCodeLensStateLabel(ResourceState.Finished, ''), codeLensResourceStopped); }); // --- Default / unknown states --- diff --git a/extension/src/views/AppHostDataRepository.ts b/extension/src/views/AppHostDataRepository.ts index a37df1b21dd..243803c5f81 100644 --- a/extension/src/views/AppHostDataRepository.ts +++ b/extension/src/views/AppHostDataRepository.ts @@ -17,6 +17,12 @@ export interface ResourceCommandJson { description: string | null; } +export interface ResourceHealthReportJson { + status: string | null; + description: string | null; + exceptionMessage: string | null; +} + export interface ResourceJson { name: string; displayName: string | null; @@ -24,6 +30,8 @@ export interface ResourceJson { state: string | null; stateStyle: string | null; healthStatus: string | null; + healthReports: Record | null; + exitCode: number | null; dashboardUrl: string | null; urls: ResourceUrlJson[] | null; commands: Record | null; diff --git a/extension/src/views/AspireAppHostTreeProvider.ts b/extension/src/views/AspireAppHostTreeProvider.ts index 5bb4e290f18..97e484c1fb9 100644 --- a/extension/src/views/AspireAppHostTreeProvider.ts +++ b/extension/src/views/AspireAppHostTreeProvider.ts @@ -9,7 +9,6 @@ import { cliPidLabel, appHostPidLabel, resourcesGroupLabel, - resourceStateLabel, noCommandsAvailable, selectCommandPlaceholder, selectDashboardPlaceholder, @@ -21,6 +20,10 @@ import { tooltipEndpoints, appHostSourceNotFound, appHostSourceOpenFailed, + healthChecksLabel, + healthCheckDescription, + resourceDescriptionHealth, + resourceDescriptionExitCode, } from '../loc/strings'; import { AppHostDataRepository, @@ -30,7 +33,7 @@ import { shortenPath, } from './AppHostDataRepository'; -type TreeElement = AppHostItem | PidItem | EndpointUrlItem | ResourcesGroupItem | ResourceItem | WorkspaceResourcesItem; +type TreeElement = AppHostItem | PidItem | EndpointUrlItem | ResourcesGroupItem | ResourceItem | WorkspaceResourcesItem | HealthChecksGroupItem | HealthCheckItem; function sortResources(resources: ResourceJson[]): ResourceJson[] { return [...resources].sort((a, b) => { @@ -117,22 +120,57 @@ class ResourcesGroupItem extends vscode.TreeItem { } } +class HealthChecksGroupItem extends vscode.TreeItem { + constructor(public readonly resource: ResourceJson, parentId: string) { + super(healthChecksLabel, vscode.TreeItemCollapsibleState.Expanded); + this.id = `${parentId}:health-checks`; + this.iconPath = new vscode.ThemeIcon('heart'); + this.contextValue = 'healthChecksGroup'; + const reports = resource.healthReports; + if (reports) { + const total = Object.keys(reports).length; + const passed = Object.values(reports).filter(r => r.status === 'Healthy').length; + this.description = `${passed}/${total}`; + } + } +} + +class HealthCheckItem extends vscode.TreeItem { + constructor(name: string, status: string | null, description: string | null, parentId: string) { + super(name, vscode.TreeItemCollapsibleState.None); + this.id = `${parentId}:health:${name}`; + const isHealthy = status === 'Healthy'; + const isDegraded = status === 'Degraded'; + this.iconPath = isHealthy + ? new vscode.ThemeIcon('pass', new vscode.ThemeColor('testing.iconPassed')) + : isDegraded + ? new vscode.ThemeIcon('warning', new vscode.ThemeColor('list.warningForeground')) + : new vscode.ThemeIcon('error', new vscode.ThemeColor('list.errorForeground')); + this.description = healthCheckDescription(status ?? 'Unknown'); + if (description) { + this.tooltip = description; + } + this.contextValue = 'healthCheck'; + } +} + function getParentResourceName(resource: ResourceJson): string | null { return resource.properties?.['resource.parentName'] ?? null; } class ResourceItem extends vscode.TreeItem { constructor(public readonly resource: ResourceJson, public readonly appHostPid: number | null, hasChildren: boolean) { - const state = resource.state ?? ''; - const label = state ? resourceStateLabel(resource.displayName ?? resource.name, state) : (resource.displayName ?? resource.name); + const label = resource.displayName ?? resource.name; const hasUrls = resource.urls && resource.urls.filter(u => !u.isInternal).length > 0; + const hasHealthReports = resource.healthReports && Object.keys(resource.healthReports).length > 0; + const hasExpandableContent = hasChildren || hasUrls || hasHealthReports; const collapsible = hasChildren ? vscode.TreeItemCollapsibleState.Expanded - : hasUrls ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None; + : hasExpandableContent ? vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None; super(label, collapsible); this.id = appHostPid !== null ? `resource:${appHostPid}:${resource.name}` : `resource:workspace:${resource.name}`; this.iconPath = getResourceIcon(resource); - this.description = resource.resourceType; + this.description = buildResourceDescription(resource); this.tooltip = buildResourceTooltip(resource); this.contextValue = getResourceContextValue(resource); } @@ -159,19 +197,23 @@ export function getResourceIcon(resource: ResourceJson): vscode.ThemeIcon { switch (state) { case ResourceState.Running: case ResourceState.Active: - if (health === HealthStatus.Unhealthy || resource.stateStyle === StateStyle.Error) { + if (resource.stateStyle === StateStyle.Error) { return new vscode.ThemeIcon('error', new vscode.ThemeColor('list.errorForeground')); } + if (health === HealthStatus.Unhealthy) { + return new vscode.ThemeIcon('warning', new vscode.ThemeColor('list.warningForeground')); + } if (health === HealthStatus.Degraded || resource.stateStyle === StateStyle.Warning) { return new vscode.ThemeIcon('warning', new vscode.ThemeColor('list.warningForeground')); } return new vscode.ThemeIcon('pass', new vscode.ThemeColor('testing.iconPassed')); case ResourceState.Finished: case ResourceState.Exited: - if (resource.stateStyle === StateStyle.Error) { + case ResourceState.Stopped: + if (resource.stateStyle === StateStyle.Error || (resource.exitCode != null && resource.exitCode !== 0)) { return new vscode.ThemeIcon('error', new vscode.ThemeColor('list.errorForeground')); } - return new vscode.ThemeIcon('circle-outline'); + return new vscode.ThemeIcon('pass', new vscode.ThemeColor('charts.green')); case ResourceState.FailedToStart: case ResourceState.RuntimeUnhealthy: return new vscode.ThemeIcon('error', new vscode.ThemeColor('list.errorForeground')); @@ -179,11 +221,12 @@ export function getResourceIcon(resource: ResourceJson): vscode.ThemeIcon { case ResourceState.Stopping: case ResourceState.Building: case ResourceState.Waiting: - case ResourceState.NotStarted: return new vscode.ThemeIcon('loading~spin'); + case ResourceState.NotStarted: + return new vscode.ThemeIcon('record', new vscode.ThemeColor('descriptionForeground')); default: if (state === null || state === undefined) { - return new vscode.ThemeIcon('circle-outline'); + return new vscode.ThemeIcon('record', new vscode.ThemeColor('descriptionForeground')); } return new vscode.ThemeIcon('circle-filled', new vscode.ThemeColor('aspire.brandPurple')); } @@ -217,6 +260,25 @@ export function resolveAppHostSourcePath(appHostPath: string, fileExists: (candi return appHostPath; } +export function buildResourceDescription(resource: ResourceJson): string { + const parts: string[] = [resource.resourceType]; + const state = resource.state; + if (state) { + parts.push(state); + } + const reports = resource.healthReports; + const exitCode = resource.exitCode; + if (reports && Object.keys(reports).length > 0) { + const total = Object.keys(reports).length; + const passed = Object.values(reports).filter(r => r.status === 'Healthy').length; + parts.push(resourceDescriptionHealth(passed, total)); + } + if (exitCode != null) { + parts.push(resourceDescriptionExitCode(exitCode)); + } + return parts.join(' · '); +} + function buildResourceTooltip(resource: ResourceJson): vscode.MarkdownString { const md = new vscode.MarkdownString(); md.appendMarkdown(`**${resource.displayName ?? resource.name}**\n\n`); @@ -226,6 +288,14 @@ function buildResourceTooltip(resource: ResourceJson): vscode.MarkdownString { } if (resource.healthStatus) { md.appendMarkdown(`${tooltipHealth(resource.healthStatus)}\n\n`); + const reports = resource.healthReports; + if (reports) { + const entries = Object.entries(reports).sort(([a], [b]) => a.localeCompare(b)); + for (const [name, report] of entries) { + const icon = report.status === HealthStatus.Healthy ? '$(pass)' : report.status === HealthStatus.Degraded ? '$(warning)' : '$(error)'; + md.appendMarkdown(`${icon} ${name}: ${report.status ?? 'Unknown'}${report.description ? ` - ${report.description}` : ''}\n\n`); + } + } } const urls = resource.urls?.filter(u => !u.isInternal && typeof u.url === 'string' && (u.url.startsWith('http://') || u.url.startsWith('https://'))) ?? []; if (urls.length > 0) { @@ -247,6 +317,7 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider | undefined; constructor( private readonly _repository: AppHostDataRepository, @@ -278,6 +349,10 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider): void { + this._treeView = treeView; + } + findResourceElement(resourceName: string): TreeElement | undefined { const allChildren = this.getChildren(); return this._findResourceInTree(allChildren, resourceName); @@ -367,6 +442,10 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider a.appHostPid === element.appHostPid)?.resources ?? []; return this._getResourceChildren(element, allResources); } + if (element instanceof HealthChecksGroupItem) { + return this._getHealthCheckChildren(element); + } return []; } @@ -436,11 +518,38 @@ export class AspireAppHostTreeProvider implements vscode.TreeDataProvider !u.isInternal) ?? []; items.push(...urls.map(url => new EndpointUrlItem(url.url, url.displayName ?? url.url))); + const reports = element.resource.healthReports; + if (reports && Object.keys(reports).length > 0) { + items.push(new HealthChecksGroupItem(element.resource, element.id!)); + } + return items; } + private _getHealthCheckChildren(element: HealthChecksGroupItem): TreeElement[] { + const reports = element.resource.healthReports; + if (!reports) { + return []; + } + return Object.entries(reports) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, report]) => new HealthCheckItem(name, report.status, report.description, element.id!)); + } + // ── Commands ── + async expandAll(element?: TreeElement): Promise { + if (!this._treeView || !element) { + return; + } + const children = this.getChildren(element); + for (const child of children) { + if (child.collapsibleState !== vscode.TreeItemCollapsibleState.None) { + await this._treeView.reveal(child, { expand: 3 }); + } + } + } + async openDashboard(element?: TreeElement): Promise { let url: string | null = null; diff --git a/extension/walkthrough/runApp.md b/extension/walkthrough/runApp.md index ce1a8bdecfa..6fb1112e24f 100644 --- a/extension/walkthrough/runApp.md +++ b/extension/walkthrough/runApp.md @@ -11,6 +11,14 @@ When you run, the extension: ### Debugging When you **debug** instead of run, the extension attaches debuggers to your services automatically — set breakpoints in any project and they'll be hit as requests flow through your app. +### Editor indicators +While your app is running, the extension shows live resource status directly in your apphost source file: + +- **Gutter icons** — distinct shapes in the editor gutter next to each resource definition show state at a glance: a green **✓** checkmark for running healthy, a yellow **⚠** triangle for unhealthy or degraded, a red **✕** for errors, a blue **⌛** hourglass for starting or waiting, and a grey **○** circle for not yet started. +- **Code lens** — inline labels above each resource show the current state and health (e.g. "Running - (Unhealthy 0/1)", "Not Started", "Waiting") along with quick actions like Restart, Stop, Start, and Logs. + +![Editor showing gutter icons and code lens labels for resources in different states](../resources/editor-indicators-dark.png) + ### The dashboard Once running, the dashboard shows all your resources, endpoints, logs, traces, and metrics in one place: