Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
60 changes: 60 additions & 0 deletions .yarn/changelogs/frontend.f07443ec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<!-- version-type: patch -->
# frontend

## ✨ Features

### Services is now the stack default page

Opening a stack (`/stacks/:stackName`) or the legacy Setup route (`/stacks/:stackName/setup`) redirects to **Services**. Create, import, and service-wizard flows land on Services directly so day-to-day work starts on the page you actually use.

### Stack actions menu on Services

A **Stack actions** menu (`⋯`) on the Services header groups stack-level operations that previously lived only on Overview:

- **Edit Stack** — open stack settings
- **Export Stack** — export stack configuration
- **Set Up All** — run the stack batch setup endpoint (`POST /stacks/:id/setup`) for every service

### Sidebar stack status at a glance

Each stack in the sidebar accordion shows a **traffic-light dot** derived from live service run state:

- Green — at least one service running
- Amber — services starting or stopping
- Red — at least one service in error
- Grey — all stopped or no services

The **Services** sub-link also shows a count so you can spot empty stacks without opening them.

### Dashboard cards with inline status and per-stack actions

Global dashboard stack cards now show **status chips inline with the stack name** (running, stopped, error, etc.) and expose **Start All**, **Stop All**, and **Update All** in the card footer. Actions use `stopPropagation` so clicking them does not navigate away from the dashboard.

### Slimmer Services table actions

Service rows now expose three actions instead of six:

- **Primary** — context-aware play/stop/retry for the current pipeline stage
- **Logs** — jump to the service logs tab
- **Chevron** — open service details

The service name is a link; double-clicking a row also opens details. Bulk selection still works on single-click.

**Commits behind upstream** moved from the removed Update button to a badge on the **Branch** cell (`N behind`).

### Secret warnings and guided empty state on Services

- **Potential secrets** warnings (previously on stack Overview) now appear above the Services table when detected in service definitions.
- Stacks with **no services** show a **3-step getting-started guide** (Repositories → Prerequisites → Create Services). The old “Run Setup” step was removed — setup is available via bulk actions and the stack menu.

## ♻️ Refactoring

- Removed stack **Overview** (`stack-dashboard.tsx`) and **Setup** (`stack-setup.tsx`) pages; unique concerns relocated to Services, sidebar, and the stack actions menu
- Extracted `StackRedirect`, `StackCard`, `SidebarStackItem`, `ServicesEmptyState`, and `StackActionsMenu` components
- Added `getStackRunSummary()` helper in `stack-status.ts` for shared stack run-state derivation (sidebar dot and future consumers)
- Simplified global `Dashboard` to list-only mode

## 🧪 Tests

- Added unit tests for `getStackRunSummary` and `getStackStatusPaletteKey`
- Added component tests for `StackRedirect`, `StackCard`, `ServiceTable`, `SidebarStackItem`, `ServicesEmptyState`, and `StackActionsMenu`
17 changes: 17 additions & 0 deletions .yarn/changelogs/stack-craft.f07443ec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<!-- version-type: patch -->
# stack-craft

## ✨ Features

### Navigation restructure — Services-first stack workflow

StackCraft now treats **Services** as the default landing page for every stack. Legacy URLs (`/stacks/:name`, `/stacks/:name/setup`) redirect automatically, and stack-level actions (Edit, Export, Set Up All) are reachable from the Services header menu instead of a separate Overview page.

The sidebar shows a **per-stack status dot** and service count so you can tell at a glance whether a cloned stack still has processes running — useful when the same stack exists in multiple feature branches and port conflicts are a risk.

The global dashboard adds **per-card Start/Stop/Update All** controls alongside inline status chips, without leaving the dashboard.

## 🧪 Tests

- Added `e2e/navigation.spec.ts` covering legacy-route redirects, stack actions menu (Edit/Export), dashboard card actions that do not navigate away, and sidebar status dots
- Updated E2E helpers: `createStack` asserts landing on Services; `deleteStack` reaches Edit Stack via the stack actions menu; sidebar helper drops the removed Overview link
3 changes: 3 additions & 0 deletions .yarn/versions/f07443ec.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
releases:
frontend: patch
stack-craft: patch
2 changes: 1 addition & 1 deletion e2e/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,4 @@ export { addPrerequisite, type PrerequisiteParams } from './prerequisite.js'
export { addRepository, type AddRepositoryParams } from './repository.js'
export { fillServiceForm, navigateToCreateService, submitServiceForm, type CreateServiceParams } from './service.js'
export { navigateViaSidebar } from './sidebar.js'
export { createStack, deleteStack, type CreateStackParams } from './stack.js'
export { createStack, deleteStack, openStackActionsMenu, type CreateStackParams } from './stack.js'
9 changes: 3 additions & 6 deletions e2e/helpers/sidebar.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import type { Page } from '@playwright/test'

export const navigateViaSidebar = async (
page: Page,
stackDisplayName: string,
link: 'Overview' | 'Services' | 'Repositories' | 'Prerequisites',
) => {
export type StackSidebarLink = 'Services' | 'Repositories' | 'Prerequisites'

export const navigateViaSidebar = async (page: Page, stackDisplayName: string, link: StackSidebarLink) => {
const stackSidebar = page.locator('shade-accordion-item').filter({ hasText: stackDisplayName })

// Expand the accordion if it's collapsed
const isExpanded = await stackSidebar.getAttribute('data-expanded')
if (isExpanded === null) {
await stackSidebar.locator('.accordion-header').click()
Expand Down
20 changes: 11 additions & 9 deletions e2e/helpers/stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,26 +21,28 @@ export const createStack = async (page: Page, params: CreateStackParams) => {
await page.locator('button', { hasText: 'Create' }).click()

await expectNotification(page, `Stack "${params.displayName}" was created successfully.`)
await expect(page.locator('shade-dashboard')).toBeVisible()
await expect(page.getByTestId('page-header-title')).toContainText(params.displayName)
await expect(page.locator('shade-services-list')).toBeVisible()
await expect(page.getByTestId('page-header-title')).toContainText('Services (0)')
}

export const openStackActionsMenu = async (page: Page) => {
await page.getByRole('button', { name: 'Stack actions' }).click()
}

export const deleteStack = async (page: Page, displayName: string) => {
// Navigate to the main dashboard via the sidebar "Dashboard" link (always visible, no accordion)
await page.locator('shade-sidebar-item a', { hasText: 'Dashboard' }).click()
await expect(page.locator('stack-list-dashboard')).toBeVisible()

// Click on the stack card to open its overview
await page.locator('stack-list-dashboard shade-card', { hasText: displayName }).click()
await expect(page.getByTestId('page-header-title')).toContainText(displayName)
await page.locator('stack-list-dashboard stack-card', { hasText: displayName }).click()
await expect(page.locator('shade-services-list')).toBeVisible()

// Navigate to Edit Stack, then delete
await page.locator('a', { hasText: 'Edit Stack' }).click()
await openStackActionsMenu(page)
await page.getByRole('menuitem', { name: 'Edit Stack' }).click()
await expect(page.locator('shade-edit-stack')).toBeVisible()

await page.locator('button', { hasText: 'Delete Stack' }).click()
await page.locator('shade-dialog .dialog-confirm-btn').click()

await expectNotification(page, `"${displayName}" was deleted.`)
await expect(page.locator('shade-dashboard')).toBeVisible({ timeout: 10_000 })
await expect(page.locator('stack-list-dashboard')).toBeVisible({ timeout: 10_000 })
}
73 changes: 73 additions & 0 deletions e2e/navigation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { tmpdir } from 'os'
import { join } from 'path'

import { expect, test } from '@playwright/test'

import { createStack, deleteStack, login, openStackActionsMenu } from './helpers/index.js'

test('Navigation restructure', async ({ page, browserName }) => {
const uuid = crypto.randomUUID()
const stackName = `e2e-nav-${uuid}`
const displayName = `Nav Stack - ${browserName} - ${uuid}`

await page.goto('/')
await login(page)

try {
await test.step('Create stack lands on Services', async () => {
await createStack(page, {
name: stackName,
displayName,
description: 'Navigation restructure E2E',
mainDirectory: join(tmpdir(), `e2e-nav-${uuid}`),
})
await expect(page).toHaveURL(new RegExp(`/stacks/${stackName}/services$`))
})

await test.step('Legacy routes redirect to Services', async () => {
await page.goto(`/stacks/${stackName}`)
await expect(page.locator('shade-services-list')).toBeVisible()
await expect(page).toHaveURL(new RegExp(`/stacks/${stackName}/services$`))

await page.goto(`/stacks/${stackName}/setup`)
await expect(page.locator('shade-services-list')).toBeVisible()
await expect(page).toHaveURL(new RegExp(`/stacks/${stackName}/services$`))
})

await test.step('Stack actions menu reaches Edit and Export', async () => {
await openStackActionsMenu(page)
await page.getByRole('menuitem', { name: 'Edit Stack' }).click()
await expect(page.locator('shade-edit-stack')).toBeVisible()

await page.goto(`/stacks/${stackName}/services`)
await openStackActionsMenu(page)
await page.getByRole('menuitem', { name: 'Export Stack' }).click()
await expect(page.locator('shade-export-stack')).toBeVisible()
})

await test.step('Stack actions menu runs Set Up All', async () => {
await page.goto(`/stacks/${stackName}/services`)
await openStackActionsMenu(page)
await page.getByRole('menuitem', { name: 'Set Up All' }).click()
await expect(page.locator('shade-services-list')).toBeVisible()
})

await test.step('Dashboard card actions do not navigate away', async () => {
await page.locator('shade-sidebar-item a', { hasText: 'Dashboard' }).click()
await expect(page.locator('stack-list-dashboard')).toBeVisible()

const card = page.locator('stack-card', { hasText: displayName })
await card.getByRole('button', { name: 'Start All' }).click()
await expect(page.locator('stack-list-dashboard')).toBeVisible()
await expect(page).toHaveURL('/')
})

await test.step('Sidebar shows stack status dot', async () => {
await expect(page.locator('shade-sidebar-stack-item [data-testid="stack-status-dot"]').first()).toBeVisible()
})
} finally {
await test.step('Clean up stack', async () => {
await deleteStack(page, displayName)
})
}
})
6 changes: 3 additions & 3 deletions frontend/src/components/app-routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ import { ServicesList } from '../pages/services/services-list.js'
import { UserSettings } from '../pages/settings/user-settings.js'
import { CreateStack } from '../pages/stacks/create-stack.js'
import { EditStack } from '../pages/stacks/edit-stack.js'
import { StackSetup } from '../pages/stacks/stack-setup.js'
import { CreateServiceWizard } from '../pages/wizards/create-service-wizard/index.js'
import { StackRedirect } from './stack-redirect.js'

export const appRoutes = {
'/': {
Expand All @@ -36,7 +36,7 @@ export const appRoutes = {
},
'/stacks/:stackName': {
component: ({ match }: { match: MatchResult<{ stackName: string }> }) => (
<Dashboard stackName={match.params.stackName} />
<StackRedirect stackName={match.params.stackName} />
),
},
'/stacks/:stackName/edit': {
Expand All @@ -51,7 +51,7 @@ export const appRoutes = {
},
'/stacks/:stackName/setup': {
component: ({ match }: { match: MatchResult<{ stackName: string }> }) => (
<StackSetup stackName={match.params.stackName} />
<StackRedirect stackName={match.params.stackName} />
),
},
'/stacks/:stackName/services': {
Expand Down
48 changes: 33 additions & 15 deletions frontend/src/components/branch-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ type BranchSelectorProps = {
cloneStatus: CloneStatus
upstreamStatus?: UpstreamStatus
lastPullError?: string
commitsBehind?: number
}

/**
Expand Down Expand Up @@ -205,6 +206,16 @@ export const BranchSelector = Shade<BranchSelectorProps>({
opacity: '0.5',
flexShrink: '0',
},
'& .commits-behind-badge': {
fontSize: '10px',
fontWeight: '600',
padding: '1px 5px',
borderRadius: '8px',
backgroundColor: 'var(--shades-theme-palette-primary-main)',
color: 'var(--shades-theme-palette-primary-mainContrast)',
flexShrink: '0',
lineHeight: '1.2',
},
'& .branch-spinner': {
display: 'inline-block',
width: '10px',
Expand All @@ -218,7 +229,7 @@ export const BranchSelector = Shade<BranchSelectorProps>({
},
render: ({ props, injector, useState, useDisposable, useRef }) => {
const triggerRef = useRef<HTMLButtonElement>('triggerRef')
const { serviceId, currentBranch, cloneStatus, upstreamStatus, lastPullError } = props
const { serviceId, currentBranch, cloneStatus, upstreamStatus, lastPullError, commitsBehind } = props

const [branches, setBranches] = useState<{ local: string[]; remote: string[] } | null>('branches', null)
const [isLoading, setIsLoading] = useState('isLoading', false)
Expand Down Expand Up @@ -332,20 +343,27 @@ export const BranchSelector = Shade<BranchSelectorProps>({

return (
<>
<button
ref={triggerRef}
type="button"
className="branch-trigger"
onclick={handleOpen}
disabled={disabled}
title={title}
{...(disabled ? { 'data-disabled': '' } : {})}
{...(isUpstreamGone ? { 'data-upstream-gone': '' } : {})}
>
{isPulling || isCheckingOut ? <span className="branch-spinner" aria-hidden="true" /> : null}
{label}
<span className="branch-arrow">&#9660;</span>
</button>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
<button
ref={triggerRef}
type="button"
className="branch-trigger"
onclick={handleOpen}
disabled={disabled}
title={title}
{...(disabled ? { 'data-disabled': '' } : {})}
{...(isUpstreamGone ? { 'data-upstream-gone': '' } : {})}
>
{isPulling || isCheckingOut ? <span className="branch-spinner" aria-hidden="true" /> : null}
{label}
<span className="branch-arrow">&#9660;</span>
</button>
{commitsBehind ? (
<span className="commits-behind-badge" data-testid="commits-behind-badge" title="Commits behind upstream">
{commitsBehind} behind
</span>
) : null}
</span>
<style>{`
@keyframes shade-branch-spin {
from { transform: rotate(0deg); }
Expand Down
4 changes: 1 addition & 3 deletions frontend/src/components/layout/breadcrumbs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ const parseBreadcrumbs = (

segments.push({
label: stackName,
href: '/stacks/:stackName',
href: '/stacks/:stackName/services',
params: { stackName },
})

Expand Down Expand Up @@ -71,8 +71,6 @@ const parseBreadcrumbs = (
segments.push({ label: 'Edit' })
} else if (subSection === 'export') {
segments.push({ label: 'Export' })
} else if (subSection === 'setup') {
segments.push({ label: 'Setup' })
}

return { segments, stackName, serviceId, repositoryId }
Expand Down
Loading
Loading