Skip to content

Commit d76acba

Browse files
CoderCococlaude
andauthored
feat(web): Dashboard KPI strip + status-forward GameCards (#77)
Closes #60 ## Summary - New `KpiStrip` at the top of `/` — four ops tiles (Servers running, Spend today, Forecast MTD, Active alerts) each with a top color accent rule and a 7-bar sparkline driven by `/api/status` + `/api/costs/estimate` + `/api/costs/actual`. - `GameCard` redesigned: gradient top-accent rule keyed to state, Outfit-bold game name above DM Mono hostname (with copy button), status badge with icon + pulsing/animated dot covering all 5 states (RUNNING / STARTING / STOPPED / NOT_DEPLOYED / ERROR), 2×2 stats grid (Last run / Players / $/hr / Task short-id), Start-or-Stop gradient primary + Files/Logs secondary actions. - `DashboardPage` adds a client-side search input (filters by game name or hostname) and switches to a responsive 3 → 2 → 1 column grid. - e2e specs from #76 realigned with the new design — STOPPED/RUNNING badges, single-CTA pattern (`Start.toHaveCount(0)` while running) — plus new specs covering search filter, empty-state on no matches, and KPI tile rendering. - `CLAUDE.md` documents the `issue-flow` plugin as the canonical issue → PR driver, complementing the existing `/pr` command. ## Verification — acceptance criteria mapped to landing | AC | Where it lives | |---|---| | KPI strip renders 4 tiles with live data + sparkline | `app/packages/web/src/components/KpiStrip.tsx` | | GameCard uses shadcn Card / Button / Badge from #58 | `GameCard.tsx` (Card root, Button variants `start`/`stop`/`secondary`/`ghost`, state Badge) | | Status badge shows icon + text on all 5 states | `STATE_LABELS` + `StateIcon` in `GameCard.tsx` | | Search filter narrows grid in real time | `DashboardPage.tsx` `useMemo` filter on game name + hostname | | Start, Stop, Files, Logs reachable from new card | `GameCard.tsx` action row; Logs links to `/logs` (the route placeholder lands fully in #63) | | `npm run app:test` passes; existing tests updated, not deleted | 258 tests pass; no prior GameCard tests existed | ## Implementation notes - The redesigned card swaps the primary CTA based on state instead of disabling the inactive one — cleaner UX, but it required updating the e2e spec from `Start.toBeDisabled()` to `Start.toHaveCount(0)` for running games. - KPI sparkline data is currently a single shared `/api/costs/actual` series across all four tiles (cost itself for the cost tiles; same series as a coarse activity proxy for Servers running; flat zeros for Active alerts when count is 0). Per-tile historical series would need new endpoints. - Forecast MTD uses average-daily-spend × calendar-days-in-month — simple extrapolation, no Cost Explorer Forecast API. - The `Logs` per-card button is `<Link to="/logs">` (asChild). The `/logs` page is a placeholder until #63 lands — the link exists so the action is "reachable" per the AC. ## Test plan - [ ] Local: `npm run app:dev`, browse to `/`, verify the four KPI tiles render with live values and a 7-bar sparkline beneath each. - [ ] Type a partial game name or hostname into the search input — grid narrows in real time; clear it — grid restores. - [ ] Start a stopped game from a card — primary CTA swaps to Stop after the 3s refresh; click Files — modal opens; click Logs — navigates to `/logs`. - [ ] CI: `npm run app:test` (vitest, 258 tests) and `npm run app:test:e2e` (Playwright via `.github/workflows/e2e.yml`) both green. --- _Generated by [Claude Code](https://claude.ai/code/session_01TiPfpb2vvkRMMXqc9oX5d2)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bd89b17 commit d76acba

13 files changed

Lines changed: 762 additions & 159 deletions

File tree

CLAUDE.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,22 @@ Use `.worktrees/<branch-name>` for feature work (the directory is gitignored). C
187187
git worktree add .worktrees/<branch> -b <branch>
188188
```
189189

190+
## Claude Code Plugins
191+
192+
This repo expects the **`issue-flow`** plugin (from `CoderCoco/claude-plugin-marketplace`) to drive the issue → PR loop. Two skills, used in order:
193+
194+
- **`work-on`** — start work on a GitHub issue. Creates the `claude/issue-<N>-<slug>` branch, scaffolds a worktree, and pulls the issue checklist into the session as the source of truth for what "done" means.
195+
- **`open-pr`** — finish the loop. Verifies the issue checklist is actually complete, picks up the repo's PR conventions (the rules in the next section), opens the PR with the right `Closes #N` keyword, and moves the project card to "In Review".
196+
197+
Install once:
198+
199+
```
200+
/plugin marketplace add CoderCoco/claude-plugin-marketplace
201+
/plugin install issue-flow@claude-plugin-marketplace
202+
```
203+
204+
If the plugin isn't loaded in the current environment, fetch the skill body from the marketplace repo and follow it manually — don't fall back to ad-hoc PR creation, because the skills enforce checklist-completeness and the closing keyword that this repo's `/pr` command takes for granted.
205+
190206
## PR Conventions
191207

192208
- **Always use `/pr` to create pull requests.** The `.claude/commands/pr.md` skill validates the title format before calling the API. Never call `mcp__github__create_pull_request` directly without running this check first.

app/packages/web/e2e/fixtures/game-data.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { GameStatus, CostEstimates, EnvInfo, WatchdogConfig } from '../../src/api.js';
1+
import type { GameStatus, CostEstimates, EnvInfo, WatchdogConfig, ActualCosts } from '@/api.js';
22

33
/** Stub response for `GET /api/env`. */
44
export const ENV_DATA: EnvInfo = {
@@ -48,3 +48,19 @@ export const COST_DATA: CostEstimates = {
4848
},
4949
totalPerHourIfAllOn: 0.08,
5050
};
51+
52+
/** Stub response for `GET /api/costs/actual` — 7 days of synthetic spend used by the KPI sparklines. */
53+
export const ACTUAL_COSTS: ActualCosts = {
54+
daily: [
55+
{ date: '2026-04-26', cost: 0.42 },
56+
{ date: '2026-04-27', cost: 0.31 },
57+
{ date: '2026-04-28', cost: 0.55 },
58+
{ date: '2026-04-29', cost: 0.18 },
59+
{ date: '2026-04-30', cost: 0.27 },
60+
{ date: '2026-05-01', cost: 0.40 },
61+
{ date: '2026-05-02', cost: 0.35 },
62+
],
63+
total: 2.48,
64+
currency: 'USD',
65+
days: 7,
66+
};

app/packages/web/e2e/fixtures/index.ts

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,20 @@
11
import { test as base, type Page } from '@playwright/test';
2-
import type { GameStatus, CostEstimates, EnvInfo, ActionResult, WatchdogConfig } from '../../src/api.js';
3-
import { ENV_DATA, STOPPED_GAME, COST_DATA, WATCHDOG_CONFIG } from './game-data.js';
2+
import type { GameStatus, CostEstimates, EnvInfo, ActionResult, WatchdogConfig, ActualCosts } from '@/api.js';
3+
import { ENV_DATA, STOPPED_GAME, COST_DATA, WATCHDOG_CONFIG, ACTUAL_COSTS } from './game-data.js';
4+
import { AppLayout, AuthGatePage, DashboardPage } from '../pages/index.js';
45

5-
export type { GameStatus, CostEstimates, EnvInfo, WatchdogConfig };
6-
export { ENV_DATA, STOPPED_GAME, RUNNING_GAME, MULTI_GAME_STATUSES, COST_DATA, WATCHDOG_CONFIG } from './game-data.js';
6+
export type { GameStatus, CostEstimates, EnvInfo, WatchdogConfig, ActualCosts };
7+
export { ENV_DATA, STOPPED_GAME, RUNNING_GAME, MULTI_GAME_STATUSES, COST_DATA, WATCHDOG_CONFIG, ACTUAL_COSTS } from './game-data.js';
8+
export { AppLayout, AuthGatePage, DashboardPage } from '../pages/index.js';
79

810
/** Per-spec overrides for the default `/api/*` stubs registered by `stubApis`. */
911
export interface StubOptions {
1012
/** Game statuses returned by `GET /api/status`. Defaults to `[STOPPED_GAME]`. */
1113
statuses?: GameStatus[];
1214
/** Cost estimates returned by `GET /api/costs/estimate`. */
1315
costs?: CostEstimates;
16+
/** Daily actual spend returned by `GET /api/costs/actual` (drives KPI sparklines). */
17+
actualCosts?: ActualCosts;
1418
/** Env info returned by `GET /api/env`. */
1519
env?: EnvInfo;
1620
/** Watchdog config returned by `GET /api/config`. */
@@ -32,6 +36,7 @@ export interface StubOptions {
3236
export async function stubApis(page: Page, opts: StubOptions = {}): Promise<void> {
3337
const statuses = opts.statuses ?? [STOPPED_GAME];
3438
const costs = opts.costs ?? COST_DATA;
39+
const actualCosts = opts.actualCosts ?? ACTUAL_COSTS;
3540
const env = opts.env ?? ENV_DATA;
3641
const config = opts.config ?? WATCHDOG_CONFIG;
3742
const startResult: ActionResult = opts.startResult ?? { success: true, message: 'Started' };
@@ -52,6 +57,8 @@ export async function stubApis(page: Page, opts: StubOptions = {}): Promise<void
5257

5358
await page.route('**/api/costs/estimate', (route) => route.fulfill({ json: costs }));
5459

60+
await page.route('**/api/costs/actual*', (route) => route.fulfill({ json: actualCosts }));
61+
5562
await page.route('**/api/config', (route) => {
5663
if (route.request().method() === 'POST') {
5764
return route.fulfill({ json: { success: true, config } });
@@ -69,9 +76,17 @@ export async function stubApis(page: Page, opts: StubOptions = {}): Promise<void
6976
type E2EFixtures = {
7077
/**
7178
* A page with `apiToken` pre-seeded in localStorage so every navigation
72-
* starts authenticated. Use this in all specs except auth-gate tests.
79+
* starts authenticated. Use this in specs that need raw page access (e.g.
80+
* to call `stubApis` or `addInitScript`); prefer `dashboard` / `layout`
81+
* for higher-level interactions.
7382
*/
7483
authedPage: Page;
84+
/** Page object for the dashboard route — use in any authed-dashboard spec. */
85+
dashboard: DashboardPage;
86+
/** Page object for the persistent nav shell (sidebar + top bar). */
87+
layout: AppLayout;
88+
/** Page object for the API-token modal — use in auth-gate specs. */
89+
authGate: AuthGatePage;
7590
};
7691

7792
export const test = base.extend<E2EFixtures>({
@@ -81,6 +96,19 @@ export const test = base.extend<E2EFixtures>({
8196
});
8297
await use(page);
8398
},
99+
// `dashboard` depends on `authedPage` because every authed-dashboard spec
100+
// wants the token pre-seeded. `layout` and `authGate` depend on the raw
101+
// `page` so auth-gate specs (which exercise the unauthenticated state) can
102+
// use them without dragging the init script along.
103+
dashboard: async ({ authedPage }, use) => {
104+
await use(new DashboardPage(authedPage));
105+
},
106+
layout: async ({ page }, use) => {
107+
await use(new AppLayout(page));
108+
},
109+
authGate: async ({ page }, use) => {
110+
await use(new AuthGatePage(page));
111+
},
84112
});
85113

86114
export { expect } from '@playwright/test';
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { Page, Locator } from '@playwright/test';
2+
3+
/**
4+
* Page object for the persistent navigation shell rendered by `AppLayout.tsx`
5+
* (sidebar + top bar). Encapsulates locators that are shared across every
6+
* authenticated route so individual specs don't reach into the layout chrome.
7+
*/
8+
export class AppLayout {
9+
constructor(public readonly page: Page) {}
10+
11+
/** Top-bar product heading — used as a "the dashboard mounted" smoke check. */
12+
brandHeading(): Locator {
13+
return this.page.getByRole('heading', { name: 'Game Server Manager' });
14+
}
15+
16+
/** Sidebar nav link by visible label (e.g. "Logs", "Discord", "Settings"). */
17+
sidebarLink(label: string): Locator {
18+
return this.page.getByRole('link', { name: label });
19+
}
20+
21+
/** Click a sidebar nav link and wait for the URL to change to `expectedPath`. */
22+
async navigateTo(label: string, expectedPath: string): Promise<void> {
23+
await this.sidebarLink(label).click();
24+
await this.page.waitForURL(expectedPath);
25+
}
26+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { Page, Locator } from '@playwright/test';
2+
3+
/**
4+
* Page object for the API-token modal rendered by `ApiTokenModal.tsx` when an
5+
* `/api/*` request returns 401. Used by auth-gate specs to assert the modal
6+
* appears and to drive the token-save → reload flow.
7+
*/
8+
export class AuthGatePage {
9+
constructor(public readonly page: Page) {}
10+
11+
/** Modal heading — visible whenever the auth gate is active. */
12+
modalHeading(): Locator {
13+
return this.page.getByRole('heading', { name: 'API token required' });
14+
}
15+
16+
/** API-token text input inside the modal. */
17+
tokenInput(): Locator {
18+
return this.page.getByPlaceholder('API token');
19+
}
20+
21+
/** "Save & reload" submit button — persists the token to localStorage and reloads. */
22+
submitButton(): Locator {
23+
return this.page.getByRole('button', { name: 'Save & reload' });
24+
}
25+
26+
/** Fill the token field and click submit in one call. */
27+
async submit(token: string): Promise<void> {
28+
await this.tokenInput().fill(token);
29+
await this.submitButton().click();
30+
}
31+
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import type { Page, Locator } from '@playwright/test';
2+
3+
/** Status-badge labels rendered by the redesigned `GameCard` (issue #60). */
4+
export type ServerStateLabel =
5+
| 'RUNNING'
6+
| 'STARTING'
7+
| 'STOPPED'
8+
| 'NOT DEPLOYED'
9+
| 'ERROR';
10+
11+
/**
12+
* Page object for the dashboard route (`/`). Wraps the KPI strip, the search
13+
* filter, the GameCard grid, and the per-card action buttons so spec files
14+
* read as test logic rather than locator soup.
15+
*/
16+
export class DashboardPage {
17+
constructor(public readonly page: Page) {}
18+
19+
/** Navigate to the dashboard root. */
20+
async goto(): Promise<void> {
21+
await this.page.goto('/');
22+
}
23+
24+
// ── GameCard grid ────────────────────────────────────────────────────
25+
26+
/** `<h3>` element inside a card whose game name matches `name`. */
27+
gameCardHeading(name: string): Locator {
28+
return this.page.getByRole('heading', { name });
29+
}
30+
31+
/** Status badge by its rendered text label (RUNNING / STOPPED / etc.). */
32+
statusBadge(state: ServerStateLabel): Locator {
33+
return this.page.getByText(state);
34+
}
35+
36+
/** Empty-state when the operator hasn't configured any games at all. */
37+
emptyConfiguredMessage(): Locator {
38+
return this.page.getByText(/no games configured/i);
39+
}
40+
41+
/** Empty-state when the search input filters out every card. */
42+
emptySearchMessage(): Locator {
43+
return this.page.getByText(/no games match/i);
44+
}
45+
46+
// ── Card action buttons ──────────────────────────────────────────────
47+
48+
/** Primary CTA shown on a stopped/not-deployed/error card. */
49+
startButton(): Locator {
50+
return this.page.getByRole('button', { name: 'Start' });
51+
}
52+
53+
/** Primary CTA shown on a running/starting card. */
54+
stopButton(): Locator {
55+
return this.page.getByRole('button', { name: 'Stop' });
56+
}
57+
58+
// ── Search filter ────────────────────────────────────────────────────
59+
60+
/** Search input above the grid that filters by game name or hostname. */
61+
searchInput(): Locator {
62+
return this.page.getByLabel('Filter games');
63+
}
64+
65+
/** Type into the search input and let React rerender the filtered grid. */
66+
async filter(query: string): Promise<void> {
67+
await this.searchInput().fill(query);
68+
}
69+
70+
// ── KPI strip ────────────────────────────────────────────────────────
71+
72+
/** A KPI tile by its label ('Servers running', 'Spend today', etc.). */
73+
kpiTileLabel(label: string): Locator {
74+
return this.page.getByText(label);
75+
}
76+
77+
/** The "Servers running" KPI value (e.g. "1/2"). */
78+
serversRunningValue(value: string): Locator {
79+
return this.page.getByText(value, { exact: true });
80+
}
81+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { AppLayout } from './AppLayout.js';
2+
export { AuthGatePage } from './AuthGatePage.js';
3+
export { DashboardPage, type ServerStateLabel } from './DashboardPage.js';

app/packages/web/e2e/specs/auth-gate.spec.ts

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,33 @@
1-
import { test, expect } from '@playwright/test';
2-
import { stubApis, ENV_DATA, COST_DATA } from '../fixtures/index.js';
1+
import { test, expect, stubApis, ENV_DATA, COST_DATA } from '../fixtures/index.js';
32

43
/**
5-
* Auth-gate specs use the base Playwright `test` (no pre-seeded token) so
6-
* they can verify the 401 → modal and token-save → reload flows in isolation.
4+
* Auth-gate specs use the base Playwright `page` (no pre-seeded token) so they
5+
* can verify the 401 → modal and token-save → reload flows in isolation. The
6+
* `authGate` page object encapsulates the modal locators; raw `page` is still
7+
* needed for direct route stubbing and `addInitScript`.
78
*/
89

910
test.describe('auth gate', () => {
10-
test('should show token modal when API returns 401', async ({ page }) => {
11+
test('should show token modal when API returns 401', async ({ page, authGate }) => {
1112
await page.route('**/api/**', (route) =>
1213
route.fulfill({ status: 401, body: 'Unauthorized' })
1314
);
1415
await page.goto('/');
15-
await expect(page.getByRole('heading', { name: 'API token required' })).toBeVisible();
16+
await expect(authGate.modalHeading()).toBeVisible();
1617
});
1718

18-
test('should load dashboard when valid token is already stored', async ({ page }) => {
19+
test('should load dashboard when valid token is already stored', async ({ page, authGate, layout }) => {
1920
await page.addInitScript(() => {
2021
localStorage.setItem('apiToken', 'test-token');
2122
});
2223
await stubApis(page, { statuses: [] });
2324
await page.goto('/');
24-
// Top-bar heading is the dashboard shell; modal should not appear.
25-
await expect(page.getByRole('heading', { name: 'Game Server Manager' })).toBeVisible();
26-
await expect(page.getByRole('heading', { name: 'API token required' })).not.toBeVisible();
25+
// Dashboard shell mounts, modal does not.
26+
await expect(layout.brandHeading()).toBeVisible();
27+
await expect(authGate.modalHeading()).not.toBeVisible();
2728
});
2829

29-
test('should save token and show dashboard after reload', async ({ page }) => {
30+
test('should save token and show dashboard after reload', async ({ page, authGate, layout }) => {
3031
// Playwright matches routes in REVERSE registration order, so register the
3132
// catch-all 404 FIRST and the specific 401/200 handlers after — otherwise
3233
// the catch-all takes precedence and the modal never triggers.
@@ -60,13 +61,12 @@ test.describe('auth gate', () => {
6061
});
6162

6263
await page.goto('/');
63-
await expect(page.getByRole('heading', { name: 'API token required' })).toBeVisible();
64+
await expect(authGate.modalHeading()).toBeVisible();
6465

65-
await page.getByPlaceholder('API token').fill('my-test-token');
66-
await page.getByRole('button', { name: 'Save & reload' }).click();
66+
await authGate.submit('my-test-token');
6767

6868
// After reload the stored token is sent; the modal must be gone.
69-
await expect(page.getByRole('heading', { name: 'Game Server Manager' })).toBeVisible();
70-
await expect(page.getByRole('heading', { name: 'API token required' })).not.toBeVisible();
69+
await expect(layout.brandHeading()).toBeVisible();
70+
await expect(authGate.modalHeading()).not.toBeVisible();
7171
});
7272
});

0 commit comments

Comments
 (0)