Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
# AGENTS.md jongio/azd-app
# AGENTS.md: jongio/azd-app

## Overview

**azd-app** is an Azure Developer CLI (azd) extension that orchestrates multi-service application development. It provides service discovery, lifecycle management, health checks, log streaming, and a real-time dashboard all driven from a single `azd app run` command.
**azd-app** is an Azure Developer CLI (azd) extension that orchestrates multi-service application development. It provides service discovery, lifecycle management, health checks, log streaming, and a real-time dashboard, all driven from a single `azd app run` command.

## Architecture

Monorepo with three major components:

- **cli/** Go CLI extension (the core product)
- **web/** Astro 6 documentation site
- **proto/** Protobuf service definitions (Connect-RPC v2)
- **cli/**: Go CLI extension (the core product)
- **web/**: Astro 6 documentation site
- **proto/**: Protobuf service definitions (Connect-RPC v2)

### CLI (Go)

Expand All @@ -20,11 +20,11 @@ Monorepo with three major components:
- **Core dependency**: `github.com/jongio/azd-core` (shared extension SDK)
- **Build tool**: Mage (`magefile.go` at `cli/magefile.go`)
- **Package structure**:
- `cli/src/cmd/app/` Entry point
- `cli/src/cmd/app/commands/` Command implementations (run, logs, health, test, etc.)
- `cli/src/internal/` Domain packages (service, detector, executor, orchestrator, portmanager, etc.)
- `cli/src/gen/proto/` Generated protobuf Go code
- **Dashboard**: `cli/dashboard/` Vite + React 19 SPA, communicates via Connect-RPC
- `cli/src/cmd/app/`: Entry point
- `cli/src/cmd/app/commands/`: Command implementations (run, logs, health, test, etc.)
- `cli/src/internal/`: Domain packages (service, detector, executor, orchestrator, portmanager, etc.)
- `cli/src/gen/proto/`: Generated protobuf Go code
- **Dashboard**: `cli/dashboard/`: Vite + React 19 SPA, communicates via Connect-RPC
Comment thread
jongio marked this conversation as resolved.
Outdated

### Web (Astro)

Expand All @@ -50,7 +50,7 @@ Conventional Commits strictly enforced:

### Go Code Style

- **Error handling**: `fmt.Errorf` with `%w` wrapping always add context
- **Error handling**: `fmt.Errorf` with `%w` wrapping: always add context
- **Logging**: slog-based via logutil, component-scoped: `NewLogger("component-name")`
- **Naming**: PascalCase exports, camelCase unexported, descriptive domain package names
- **Interfaces**: Suffix with role (e.g., `*Credential`, `*Logger`, `*Provider`)
Expand All @@ -66,15 +66,15 @@ Conventional Commits strictly enforced:

### Linting

- **Config**: `.golangci.yml` 24 linters enabled, 5-minute timeout
- **Config**: `.golangci.yml`: 24 linters enabled, 5-minute timeout
- **Key linters**: errcheck, govet, staticcheck, gosec, revive, dupl, exhaustive
- **Security**: gosec enabled (G204/G304 excluded for CLI exec patterns)
- **Test exclusions**: Broad most linters disabled for `_test.go` files
- **Test exclusions**: Broad: most linters disabled for `_test.go` files
- **Run**: `mage preflight` (runs format, imports, security, lint)

## CI/CD

- **Main CI**: `.github/workflows/ci.yml` preflight, lint, test on ubuntu/windows/macos matrix
- **Main CI**: `.github/workflows/ci.yml`: preflight, lint, test on ubuntu/windows/macos matrix
- **Go version**: 1.26.5, Node: 22, pnpm: 9
- **Race detector**: Enabled on Linux/Windows, disabled on macOS
- **Coverage**: codecov integration with threshold enforcement
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Monitor all your services in one place with live status updates and health check
![Dashboard Resources](web/public/screenshots/dashboard-resources-cards.png)

### 📝 Unified Logs
Stream and filter logs from all servicesboth local and Azure. Search, highlight, and export with ease. Switch between local and cloud logs with a single click.
Stream and filter logs from all services, both local and Azure. Search, highlight, and export with ease. Switch between local and cloud logs with a single click.

![Console Logs](web/public/screenshots/dashboard-console.png)

Expand Down Expand Up @@ -252,7 +252,7 @@ retired and being archived; it still resolves for existing installs but is no lo
maintained. **azd exec** is no longer needed at all, because `azd exec` shipped as a
built-in azd command in v1.25.1.

🌐 **Extension Hub**: [jongio.github.io/azd-extensions](https://jongio.github.io/azd-extensions/) Browse all extensions, quick install, and registry info.
🌐 **Extension Hub**: [jongio.github.io/azd-extensions](https://jongio.github.io/azd-extensions/): Browse all extensions, quick install, and registry info.

---

Expand Down
6 changes: 3 additions & 3 deletions cli/dashboard/e2e/helpers/connect-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@
* does.
*
* Wire format:
* - Unary calls ride `application/json` with a raw message body easy.
* - Unary calls ride `application/json` with a raw message body: easy.
* - Server-streaming rides `application/connect+json` with length-prefixed
* envelopes: one 5-byte header (1 flag byte + 4-byte big-endian length)
* per frame, terminated by an end-stream envelope (flag 0x02). Request
* bodies for streams are a single data envelope, which is why we parse
* with `postDataBuffer()` rather than `postData()` the 5-byte prefix
* with `postDataBuffer()` rather than `postData()`: the 5-byte prefix
* is not UTF-8-safe.
*
* Enum encoding:
Expand Down Expand Up @@ -72,7 +72,7 @@ function encodeStreamBody(messages: unknown[], endPayload: unknown = {}): Buffer
* Build a single data-envelope (flag 0x00) for a JSON message. Used by
* callers that construct a never-closing ReadableStream inside the page
* (via addInitScript) and need raw bytes to enqueue. Returns a plain
* Uint8Array Node's Buffer doesn't survive the structured-clone serde
* Uint8Array: Node's Buffer doesn't survive the structured-clone serde
* boundary into the page context.
*/
export function encodeStreamEnvelopeNoEnd(message: unknown): Uint8Array {
Expand Down
2 changes: 1 addition & 1 deletion cli/dashboard/e2e/helpers/test-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1032,7 +1032,7 @@ export async function mockConnectRoutes(page: Page, options: MockConnectOptions
// close the connection, because `useHealthStream` flips `connected` to
// false on stream close and schedules a reconnect. React batches state
// updates, so a setConnected(true) + setConnected(false) within the
// same tick collapses to false tests never observe the "connected"
// same tick collapses to false; tests never observe the "connected"
// state and downstream hooks gated on it (useLogsStream for Azure)
// never fire their first fetch.
//
Expand Down
2 changes: 1 addition & 1 deletion cli/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ function App() {

// Update document title whenever the project name resolves. Keeping
// this side-effect in App.tsx (vs. inside useProject) preserves the
// hook's purity useProject is reused-safe and shouldn't mutate the
// hook's purity: useProject is reused-safe and shouldn't mutate the
// browser document just by being called.
useEffect(() => {
if (projectName) {
Expand Down
6 changes: 3 additions & 3 deletions cli/dashboard/src/components/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export function App({
)
const hasServiceSearch = serviceSearch.trim().length > 0

// Sync selected service with services list (in case it updates) render-time reset
// Sync selected service with services list (in case it updates), render-time reset
const [prevServices, setPrevServices] = React.useState(services)
if (services !== prevServices) {
setPrevServices(services)
Expand Down Expand Up @@ -329,8 +329,8 @@ export function App({
*
* The full-screen blocking overlay only renders when the backend is
* genuinely unreachable (reconnect attempts exhausted). Transient
* reconnect cycles `Connection lost. Reconnecting in Ns...` and
* bare `Backend connection lost` during attempts 4-5 must not
* reconnect cycles: `Connection lost. Reconnecting in Ns...` and
* bare `Backend connection lost` during attempts 4-5: must not
* block the UI; the page stays interactive and recovers silently
* when the stream re-attaches. The exhaustion signal is the single
* `Click to reconnect` substring that useHealthStream sets exactly
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import userEvent from '@testing-library/user-event'
// Replace the Connect-backed hook with a tiny shim that still consumes
// `globalThis.fetch` so the pre-existing fetch-mock staged payloads keep
// driving the UI. The shim maps the legacy `/api/azure/diagnostic-settings/
// check` JSON payload onto the hook's result shape verbatim which
// check` JSON payload onto the hook's result shape verbatim, which
// matches what the real hook surfaces once it's decoded the proto.
vi.mock('@/hooks/useDiagnosticSettings', async () => {
const React = await import('react')
Expand Down
2 changes: 1 addition & 1 deletion cli/dashboard/src/components/DiagnosticsModal.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* stage a proto response (built by `buildHealthResponse`) or a thrown
* error. The assertion surface (rendered status, names, fix-setup
* routing, etc.) is unchanged because the component still renders a
* `HealthCheckResponse` internally only the wire shape differs.
* `HealthCheckResponse` internally: only the wire shape differs.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { render, screen, waitFor, cleanup } from '@testing-library/react'
Expand Down
2 changes: 1 addition & 1 deletion cli/dashboard/src/components/HistoricalLogPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ export function HistoricalLogPanel({
}
}, [isOpen])

// Reset state when panel opens with new service render-time reset
// Reset state when panel opens with new service, render-time reset
const [prevOpenKey, setPrevOpenKey] = React.useState(() => `${isOpen}:${serviceName}:${defaultTimeRange}`)
const openKey = `${isOpen}:${serviceName}:${defaultTimeRange}`
if (openKey !== prevOpenKey) {
Expand Down
4 changes: 2 additions & 2 deletions cli/dashboard/src/components/LogConfigPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export function LogConfigPanel({
// Close on Escape
useEscapeKey(onClose, isOpen)

// Fetch data when panel opens render-time reset
// Fetch data when panel opens, render-time reset
const [prevIsOpen, setPrevIsOpen] = React.useState(isOpen)
if (isOpen !== prevIsOpen) {
setPrevIsOpen(isOpen)
Expand All @@ -87,7 +87,7 @@ export function LogConfigPanel({
}
}

// Sync local state with fetched config render-time reset
// Sync local state with fetched config, render-time reset
const [prevConfig, setPrevConfig] = React.useState(config)
if (config !== prevConfig) {
setPrevConfig(config)
Expand Down
2 changes: 1 addition & 1 deletion cli/dashboard/src/components/LogsPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export function LogsPane({
return `azure:${resolvedTimeRange.preset}:${end}:${azureRealtime ? 'realtime' : 'poll'}`
}, [logMode, resolvedTimeRange.preset, resolvedTimeRange.end, azureRealtime])

// Reset state when fetchKey changes render-time reset
// Reset state when fetchKey changes, render-time reset
const [prevFetchKey, setPrevFetchKey] = useState(fetchKey)
if (fetchKey !== prevFetchKey) {
setPrevFetchKey(fetchKey)
Expand Down
2 changes: 1 addition & 1 deletion cli/dashboard/src/components/ServiceDetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -783,7 +783,7 @@ export function ServiceDetailPanel({

useEscapeKey(onClose, isOpen)

// Reset tab when service changes render-time reset
// Reset tab when service changes, render-time reset
const [prevService, setPrevService] = React.useState(service)
if (service !== prevService) {
setPrevService(service)
Expand Down
2 changes: 1 addition & 1 deletion cli/dashboard/src/components/SettingsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export function SettingsDialog({
}
}, [])

// Reset pending changes when dialog opens render-time reset
// Reset pending changes when dialog opens, render-time reset
const [prevIsOpen, setPrevIsOpen] = React.useState(isOpen)
if (isOpen !== prevIsOpen) {
setPrevIsOpen(isOpen)
Expand Down
2 changes: 1 addition & 1 deletion cli/dashboard/src/components/TableSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ export function TableSelector({
new Set<string>()
)

// Initialize expanded categories once categories are available render-time reset
// Initialize expanded categories once categories are available, render-time reset
const [prevSafeCategories, setPrevSafeCategories] = React.useState(safeCategories)
if (safeCategories !== prevSafeCategories) {
setPrevSafeCategories(safeCategories)
Expand Down
2 changes: 1 addition & 1 deletion cli/dashboard/src/components/TimeRangeSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export function TimeRangeSelector({
formatDateTimeLocal(value.end ?? getDefaultEnd())
)

// Update local state when value changes externally render-time reset
// Update local state when value changes externally, render-time reset
const [prevValue, setPrevValue] = React.useState(value)
if (value !== prevValue) {
setPrevValue(value)
Expand Down
2 changes: 1 addition & 1 deletion cli/dashboard/src/gen/proto/azdapp/v1/common_pb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,7 @@ export const ServiceStatusSchema: GenEnum<ServiceStatus> = /*@__PURE__*/
* HealthState mirrors internal/healthcheck health states.
*
* Wire stability: enum values are append-only. DEGRADED was added after the
* initial draft when wiring HealthService the existing dashboard summary
* initial draft when wiring HealthService: the existing dashboard summary
Comment thread
jongio marked this conversation as resolved.
Outdated
* distinguishes degraded from unhealthy, and dropping that distinction would
* silently lose information. Older clients that don't recognise the value
* will see HEALTH_STATE_UNSPECIFIED (proto3 unknown-enum semantics) rather
Expand Down
12 changes: 6 additions & 6 deletions cli/dashboard/src/hooks/useAzureConnectionStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ interface RouterOverrides {
/**
* Build an in-memory router serving ModeService. Each test passes the
* scenario it cares about; unimplemented methods raise CodeUnimplemented
* automatically exactly what we want when a test should not hit a
* automatically: exactly what we want when a test should not hit a
* given RPC.
*/
function makeTransport(overrides: RouterOverrides = {}) {
Expand Down Expand Up @@ -78,7 +78,7 @@ describe('useAzureConnectionStatus (Connect)', () => {
})

describe('initial render', () => {
it('does not auto-fetch fetchAzureStatus must be called explicitly', async () => {
it('does not auto-fetch, fetchAzureStatus must be called explicitly', async () => {
let calls = 0
const transport = makeTransport({
getMode: () => {
Expand Down Expand Up @@ -186,13 +186,13 @@ describe('useAzureConnectionStatus (Connect)', () => {
})
await waitFor(() => expect(calls).toBe(1))

// Now issue concurrent calls they must all bail out at the
// Now issue concurrent calls; they must all bail out at the
// abortControllerRef guard before reaching the transport.
act(() => {
void result.current.fetchAzureStatus()
void result.current.fetchAzureStatus()
})
// Give microtasks a chance calls must stay at 1.
// Give microtasks a chance; calls must stay at 1.
await Promise.resolve()
expect(calls).toBe(1)

Expand Down Expand Up @@ -248,7 +248,7 @@ describe('useAzureConnectionStatus (Connect)', () => {

const { result } = renderHook(() => useAzureConnectionStatus({ transport }))

// Initial state is 'local' switching to 'local' should be a no-op.
// Initial state is 'local'; switching to 'local' should be a no-op.
await act(async () => {
await result.current.handleLogModeChange('local')
})
Expand Down Expand Up @@ -303,7 +303,7 @@ describe('useAzureConnectionStatus (Connect)', () => {
// Switching flips on synchronously.
expect(result.current.isModeSwitching).toBe(true)

// Resolve the SetMode promise switching stays true until the
// Resolve the SetMode promise; switching stays true until the
// 1500ms cleanup timeout fires.
await act(async () => {
resolveSet(create(SetModeResponseSchema, {
Expand Down
4 changes: 2 additions & 2 deletions cli/dashboard/src/hooks/useAzureConnectionStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ function logModeToProto(m: LogMode): ProtoLogMode {

/**
* Project a Get/SetMode response onto the local state shape. Keeps the
* two response handlers in sync both messages share the same fields,
* two response handlers in sync: both messages share the same fields,
* so a divergence here would silently desync the UI.
*/
interface NormalizedModeSnapshot {
Expand Down Expand Up @@ -132,7 +132,7 @@ export interface UseAzureConnectionStatusResult {

export interface UseAzureConnectionStatusOptions {
onAzureRealtimeConfig?: (azureRealtime: boolean | undefined) => void
/** Test seam inject a Connect transport (e.g. createRouterTransport). */
/** Test seam, inject a Connect transport (e.g. createRouterTransport). */
transport?: Transport
}

Expand Down
2 changes: 1 addition & 1 deletion cli/dashboard/src/hooks/useConsoleFilters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export function useConsoleFilters(services: Service[]): UseConsoleFiltersResult
() => new Set(savedFilters?.healthFilter?.length ? savedFilters.healthFilter : ['healthy', 'degraded', 'unhealthy', 'unknown'])
)

// Sync selected services with available services render-time reset
// Sync selected services with available services, render-time reset
const [prevSyncKey, setPrevSyncKey] = React.useState(() => `${services.map(s => s.name).join(',')}:${serviceSelectionMode}`)
const syncKey = `${services.map(s => s.name).join(',')}:${serviceSelectionMode}`
if (syncKey !== prevSyncKey) {
Expand Down
2 changes: 1 addition & 1 deletion cli/dashboard/src/hooks/useHealthStream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ interface Harness {
/**
* Build a transport whose StreamHealth handler yields events queued via
* the returned controller. The queue uses an inner promise per pending
* event so the handler can suspend until tests push something this is
* event so the handler can suspend until tests push something: this is
* the same shape `cli/src/internal/rpc/health.go` produces (one async
* source -> one yield per source event) but in TS test land.
*/
Expand Down
8 changes: 4 additions & 4 deletions cli/dashboard/src/hooks/useHealthStream.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* useHealthStream subscribes to the HealthService.StreamHealth Connect
* useHealthStream: subscribes to the HealthService.StreamHealth Connect
* server-streaming RPC and exposes the same React hook surface the
* dashboard already consumes.
*
Expand All @@ -9,8 +9,8 @@
* moves: instead of named SSE events (`message`, `health-change`,
* `heartbeat`) we consume a `HealthEvent` oneof and translate each
* variant back into the legacy event shape so downstream UI never sees
* a proto type. Doing that translation in this single place rather
* than threading proto types through to App.tsx keeps the migration
* a proto type. Doing that translation in this single place, rather
* than threading proto types through to App.tsx, keeps the migration
* contained to the transport layer.
*
* The `summary` field on legacy `HealthReportEvent` was computed by the
Expand Down Expand Up @@ -118,7 +118,7 @@ function healthStateToStatus(state: HealthState): HealthStatus {

/**
* Convert google.protobuf.Timestamp (seconds + nanos as bigint) to ISO
* string. Falls back to "now" when the server omitted the field the
* string. Falls back to "now" when the server omitted the field: the
* legacy SSE handler always populated it, so omission is a server bug
* rather than a normal path, but a missing timestamp must never crash
* the UI.
Expand Down
2 changes: 1 addition & 1 deletion cli/dashboard/src/hooks/useProject.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Tests for useProject against an in-memory Connect router transport.
* Mirrors useCodespaceEnv.test.tsx no fetch mocking, no client mocking;
* Mirrors useCodespaceEnv.test.tsx: no fetch mocking, no client mocking;
* the production hook code path runs unchanged with an injected transport.
*/
import { renderHook, waitFor } from '@testing-library/react'
Expand Down
2 changes: 1 addition & 1 deletion cli/dashboard/src/hooks/useProject.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* useProject fetches azure.yaml-derived project metadata via the
* useProject: fetches azure.yaml-derived project metadata via the
* ProjectService Connect handler.
*
* Wire migration note: replaces a one-shot `fetch('/api/project')` that
Expand Down
2 changes: 1 addition & 1 deletion cli/dashboard/src/hooks/useServiceErrors.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* useServiceErrors fan out N parallel Connect log streams (one per
* useServiceErrors: fan out N parallel Connect log streams (one per
* service) and surface a boolean for whether ANY service has produced
* an error-level entry in the last 30 seconds.
*
Expand Down
2 changes: 1 addition & 1 deletion cli/dashboard/src/hooks/useSharedLogStream.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* useSharedLogStream singleton multiplexer over the live local-log
* useSharedLogStream: singleton multiplexer over the live local-log
* stream so a dashboard with N panes opens one upstream connection
* instead of N. Each pane subscribes for one service (or "all"); the
* manager fans incoming entries out to every matching subscriber.
Expand Down
Loading