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.
+
+
+
### The dashboard
Once running, the dashboard shows all your resources, endpoints, logs, traces, and metrics in one place: