diff --git a/.yarn/changelogs/frontend.f07443ec.md b/.yarn/changelogs/frontend.f07443ec.md new file mode 100644 index 0000000..cc0c393 --- /dev/null +++ b/.yarn/changelogs/frontend.f07443ec.md @@ -0,0 +1,60 @@ + +# 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` diff --git a/.yarn/changelogs/stack-craft.f07443ec.md b/.yarn/changelogs/stack-craft.f07443ec.md new file mode 100644 index 0000000..8ef9468 --- /dev/null +++ b/.yarn/changelogs/stack-craft.f07443ec.md @@ -0,0 +1,17 @@ + +# 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 diff --git a/.yarn/versions/f07443ec.yml b/.yarn/versions/f07443ec.yml new file mode 100644 index 0000000..e231c93 --- /dev/null +++ b/.yarn/versions/f07443ec.yml @@ -0,0 +1,3 @@ +releases: + frontend: patch + stack-craft: patch diff --git a/e2e/helpers/index.ts b/e2e/helpers/index.ts index 0ab391a..16b1448 100644 --- a/e2e/helpers/index.ts +++ b/e2e/helpers/index.ts @@ -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' diff --git a/e2e/helpers/sidebar.ts b/e2e/helpers/sidebar.ts index 83ce5b3..b8dfd0b 100644 --- a/e2e/helpers/sidebar.ts +++ b/e2e/helpers/sidebar.ts @@ -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() diff --git a/e2e/helpers/stack.ts b/e2e/helpers/stack.ts index 6ac4a11..933f996 100644 --- a/e2e/helpers/stack.ts +++ b/e2e/helpers/stack.ts @@ -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 }) } diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts new file mode 100644 index 0000000..2248559 --- /dev/null +++ b/e2e/navigation.spec.ts @@ -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) + }) + } +}) diff --git a/frontend/src/components/app-routes.tsx b/frontend/src/components/app-routes.tsx index 9e7ad0c..ae04dbe 100644 --- a/frontend/src/components/app-routes.tsx +++ b/frontend/src/components/app-routes.tsx @@ -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 = { '/': { @@ -36,7 +36,7 @@ export const appRoutes = { }, '/stacks/:stackName': { component: ({ match }: { match: MatchResult<{ stackName: string }> }) => ( - + ), }, '/stacks/:stackName/edit': { @@ -51,7 +51,7 @@ export const appRoutes = { }, '/stacks/:stackName/setup': { component: ({ match }: { match: MatchResult<{ stackName: string }> }) => ( - + ), }, '/stacks/:stackName/services': { diff --git a/frontend/src/components/branch-selector.tsx b/frontend/src/components/branch-selector.tsx index 60ce33a..81ed79b 100644 --- a/frontend/src/components/branch-selector.tsx +++ b/frontend/src/components/branch-selector.tsx @@ -10,6 +10,7 @@ type BranchSelectorProps = { cloneStatus: CloneStatus upstreamStatus?: UpstreamStatus lastPullError?: string + commitsBehind?: number } /** @@ -205,6 +206,16 @@ export const BranchSelector = Shade({ 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', @@ -218,7 +229,7 @@ export const BranchSelector = Shade({ }, render: ({ props, injector, useState, useDisposable, useRef }) => { const triggerRef = useRef('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) @@ -332,20 +343,27 @@ export const BranchSelector = Shade({ return ( <> - + + + {commitsBehind ? ( + + {commitsBehind} behind + + ) : null} +