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
9 changes: 7 additions & 2 deletions app/packages/web/e2e/pages/DashboardPage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,14 @@ export class DashboardPage {
return this.page.getByText(state, { exact: true });
}

/** Empty-state when the operator hasn't configured any games at all. */
/** Empty-state card heading shown when no games are deployed. */
emptyConfiguredMessage(): Locator {
return this.page.getByText(/no games configured/i);
return this.page.getByRole('heading', { name: /no games deployed/i });
}

/** "Open setup guide" CTA link inside the no-games card. */
setupGuideLink(): Locator {
return this.page.getByRole('link', { name: /open setup guide/i });
}

/** Empty-state when the search input filters out every card. */
Expand Down
12 changes: 12 additions & 0 deletions app/packages/web/e2e/specs/dashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,18 @@ test.describe('dashboard', () => {
await expect(dashboard.emptyConfiguredMessage()).toBeVisible();
});

test('should show setup guide and terraform.tfvars CTAs in the no-games card', async ({
dashboard,
}) => {
await stubApis(dashboard.page, { statuses: [] });
await dashboard.goto();

await expect(dashboard.setupGuideLink()).toBeVisible();
await expect(
dashboard.page.getByRole('link', { name: /terraform\.tfvars/i }),
).toBeVisible();
});

test('should fire POST /api/start/:game when Start is clicked', async ({ dashboard }) => {
await stubApis(dashboard.page, { statuses: [STOPPED_GAME] });

Expand Down
28 changes: 28 additions & 0 deletions app/packages/web/e2e/specs/discord.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,34 @@ test.describe('discord settings', () => {
await expect(page.getByRole('heading', { name: 'Get started' })).not.toBeVisible();
});

test('should show the wizard when allowedGuilds is empty even if credentials are already set', async ({
authedPage: page,
}) => {
await stubApis(page, {
discord: { ...CONFIGURED_DISCORD_CONFIG, allowedGuilds: [] },
});
await page.goto('/discord');

await expect(page.getByRole('heading', { name: 'Get started' })).toBeVisible();
});

test('should render live checkmarks for satisfied wizard steps', async ({
authedPage: page,
}) => {
// clientId set → step 1 done; botTokenSet + publicKeySet → step 2 done;
// interactionsEndpointUrl set → step 3 done; no guilds → step 4 pending.
await stubApis(page, {
discord: { ...CONFIGURED_DISCORD_CONFIG, allowedGuilds: [] },
});
await page.goto('/discord');

await expect(page.getByRole('heading', { name: 'Get started' })).toBeVisible();
// The credentials step should be struck through (done) because both secrets are set
const credentialsStep = page.getByText(/Paste those values into the/i);
await expect(credentialsStep).toBeVisible();
await expect(credentialsStep).toHaveCSS('text-decoration-line', 'line-through');
});

test('should render the Credentials tab by default', async ({ authedPage: page }) => {
await stubApis(page, { discord: CONFIGURED_DISCORD_CONFIG });
await page.goto('/discord');
Expand Down
2 changes: 1 addition & 1 deletion app/packages/web/src/components/app-layout.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ export function AppLayout({ children }: { children: ReactNode }) {
</header>

{/* Page content */}
<main className="flex-1 overflow-auto">
<main className="flex-1 overflow-auto p-8">
{children}
</main>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ describe('WatchdogPanel', () => {
expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
});

it('should render an accessible help button for each watchdog field', () => {
render(<WatchdogPanel />);

expect(screen.getByRole('button', { name: 'Check interval (min) help' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Idle checks before shutdown help' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Min packets (activity threshold) help' })).toBeInTheDocument();
});

it('should show a success toast after saving settings', async () => {
render(<WatchdogPanel />);

Expand Down
99 changes: 75 additions & 24 deletions app/packages/web/src/components/watchdog-panel.component.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useId } from 'react';
import { HelpCircle } from 'lucide-react';
import { toast } from 'sonner';
import { api, type WatchdogConfig } from '../api.service.js';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip.component';

/**
* Bottom-right dashboard panel that reads and writes the three watchdog knobs
Expand Down Expand Up @@ -31,36 +38,80 @@ export function WatchdogPanel() {
}

return (
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: '12px', padding: '1.25rem' }}>
<h2 style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-dim)', marginBottom: '1rem' }}>
Watchdog Settings
</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.6rem' }}>
<Field label="Check interval (min)" value={cfg.watchdog_interval_minutes}
onChange={(v) => setCfg((c) => ({ ...c, watchdog_interval_minutes: v }))} />
<Field label="Idle checks before shutdown" value={cfg.watchdog_idle_checks}
onChange={(v) => setCfg((c) => ({ ...c, watchdog_idle_checks: v }))} />
<Field label="Min packets (activity threshold)" value={cfg.watchdog_min_packets}
onChange={(v) => setCfg((c) => ({ ...c, watchdog_min_packets: v }))} />
<TooltipProvider delayDuration={150}>
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No unit test?

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 7dcad97. watchdog-panel.component.test.tsx now has a test should render an accessible help button for each watchdog field that asserts all three fields produce a <button> with the correct aria-label ("Check interval (min) help", etc.).


Generated by Claude Code

<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: '12px', padding: '1.25rem' }}>
<h2 style={{ fontSize: '0.75rem', textTransform: 'uppercase', letterSpacing: '0.05em', color: 'var(--text-dim)', marginBottom: '1rem' }}>
Watchdog Settings
</h2>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0.6rem' }}>
<Field
label="Check interval (min)"
tooltip="How often the watchdog inspects each running task. Lower = faster shutdown, higher = less CPU."
value={cfg.watchdog_interval_minutes}
onChange={(v) => setCfg((c) => ({ ...c, watchdog_interval_minutes: v }))}
/>
<Field
label="Idle checks before shutdown"
tooltip="Number of consecutive idle checks before the task stops. With 5 min interval × 5 checks = 25 idle minutes."
value={cfg.watchdog_idle_checks}
onChange={(v) => setCfg((c) => ({ ...c, watchdog_idle_checks: v }))}
/>
<Field
label="Min packets (activity threshold)"
tooltip="If a task receives fewer than this many network packets in an interval, it counts as idle."
value={cfg.watchdog_min_packets}
onChange={(v) => setCfg((c) => ({ ...c, watchdog_min_packets: v }))}
/>
</div>
<p style={{ fontSize: '0.72rem', color: 'var(--text-dim)', marginTop: '0.6rem' }}>
Auto-shutdown after {idleMinutes} minutes idle ({cfg.watchdog_interval_minutes} min × {cfg.watchdog_idle_checks} checks).
Update Terraform vars to change the Lambda schedule.
</p>
<div style={{ marginTop: '0.75rem' }}>
<button className="btn-secondary btn-sm" onClick={() => void handleSave()}>
Save
</button>
</div>
</div>
<p style={{ fontSize: '0.72rem', color: 'var(--text-dim)', marginTop: '0.6rem' }}>
Auto-shutdown after {idleMinutes} minutes idle ({cfg.watchdog_interval_minutes} min × {cfg.watchdog_idle_checks} checks).
Update Terraform vars to change the Lambda schedule.
</p>
<div style={{ marginTop: '0.75rem' }}>
<button className="btn-secondary btn-sm" onClick={() => void handleSave()}>
Save
</button>
</div>
</div>
</TooltipProvider>
);
}

function Field({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) {
function Field({
label,
tooltip,
value,
onChange,
}: {
label: string;
tooltip: string;
value: number;
onChange: (v: number) => void;
}) {
const id = useId();
return (
<div>
<label style={{ fontSize: '0.72rem', color: 'var(--text-dim)', display: 'block', marginBottom: '0.2rem' }}>{label}</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', marginBottom: '0.2rem' }}>
<label htmlFor={id} style={{ fontSize: '0.72rem', color: 'var(--text-dim)' }}>
{label}
</label>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`${label} help`}
style={{ display: 'inline-flex', alignItems: 'center', background: 'none', border: 'none', padding: 0, cursor: 'help', color: 'inherit' }}
>
<HelpCircle style={{ width: '0.7rem', height: '0.7rem', flexShrink: 0 }} />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-56">
{tooltip}
</TooltipContent>
</Tooltip>
</div>
<input
id={id}
type="number"
value={value}
onChange={(e) => onChange(parseInt(e.target.value, 10) || 0)}
Expand Down
2 changes: 1 addition & 1 deletion app/packages/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
}

/* ── Base ───────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
*, *::before, *::after { box-sizing: border-box; }

body {
font-family: var(--font-ui);
Expand Down
2 changes: 1 addition & 1 deletion app/packages/web/src/pages/costs.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ export function CostsPage() {

return (
<TooltipProvider delayDuration={150}>
<div className="max-w-6xl mx-auto p-8 space-y-6">
<div className="max-w-6xl mx-auto space-y-6">
<header className="flex flex-wrap items-end justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold text-[var(--color-foreground)]">Cost Analysis</h2>
Expand Down
9 changes: 9 additions & 0 deletions app/packages/web/src/pages/dashboard.page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ describe('DashboardPage', () => {
expect(screen.getByRole('heading', { name: 'valheim' })).toBeInTheDocument();
});

it('should render the no-games card with CTAs when the server returns no statuses', async () => {
apiMock.status.mockResolvedValue([]);
renderPage(<DashboardPage />);

expect(await screen.findByRole('heading', { name: 'No games deployed' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /Open setup guide/i })).toBeInTheDocument();
expect(screen.getByRole('link', { name: /terraform\.tfvars/i })).toBeInTheDocument();
});

it('should narrow the visible cards by the search filter without removing the indicator', async () => {
const user = userEvent.setup();
renderPage(<DashboardPage />);
Expand Down
52 changes: 48 additions & 4 deletions app/packages/web/src/pages/dashboard.page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { Search } from 'lucide-react';
import { Search, Server, ExternalLink } from 'lucide-react';
import { useGameStatus } from '../polling/game-status-provider.component.js';
import { useFileManager } from '../hooks/use-file-manager.hook.js';
import { api, type ActualCosts } from '../api.service.js';
Expand All @@ -8,6 +8,7 @@ import { KpiStrip } from '../components/kpi-strip.component.js';
import { FileManagerModal } from '../components/file-manager-modal.component.js';
import { PollingIndicator } from '../polling/polling-indicator.component.js';
import { Input } from '@/components/ui/input.component';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card.component';

/**
* Dashboard route (`/`) — top KPI strip, then a search-filterable grid of
Expand Down Expand Up @@ -38,7 +39,7 @@ export function DashboardPage() {

return (
<>
<div className="max-w-7xl mx-auto px-6 py-6">
<div className="max-w-7xl mx-auto">
{/* KPI strip */}
<KpiStrip statuses={statuses} estimates={estimates} actualCosts={actualCosts} />

Expand All @@ -65,8 +66,8 @@ export function DashboardPage() {
Loading servers…
</div>
) : statuses.length === 0 ? (
<div className="col-span-full text-sm text-[var(--color-muted-foreground)] py-8 text-center">
No games configured. Run <code>terraform apply</code> first.
<div className="col-span-full py-8 flex justify-center">
<NoGamesCard />
</div>
) : visible.length === 0 ? (
<div className="col-span-full text-sm text-[var(--color-muted-foreground)] py-8 text-center">
Expand Down Expand Up @@ -100,3 +101,46 @@ export function DashboardPage() {
</>
);
}

/** Shown when the API returns no game statuses — guides first-time operators. */
function NoGamesCard() {
return (
<Card className="max-w-lg w-full border-[var(--color-border)]">
<CardHeader className="pb-3">
<div className="flex items-center gap-3 mb-1">
<div className="p-2 rounded-lg bg-[var(--color-primary)]/10">
<Server className="size-5 text-[var(--color-primary-light)]" />
</div>
<CardTitle>No games deployed</CardTitle>
</div>
<CardDescription>
Game servers are provisioned via Terraform. Each entry in{' '}
<code className="font-mono text-xs bg-[var(--color-surface-2)] px-1 py-0.5 rounded">
terraform.tfvars
</code>{' '}
creates an ECS task definition, EFS volume, and CloudWatch log group automatically.
</CardDescription>
</CardHeader>
<CardContent className="flex flex-wrap gap-4">
<a
href="https://codercoco.github.io/game-server-deploy/setup"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-[var(--color-primary-light)] underline-offset-4 hover:underline"
>
Open setup guide
<ExternalLink className="size-3.5" />
</a>
<a
href="https://github.com/CoderCoco/game-server-deploy/blob/main/terraform/terraform.tfvars.example"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1.5 text-sm text-[var(--color-primary-light)] underline-offset-4 hover:underline"
>
Edit <code className="font-mono text-xs">terraform.tfvars</code>
<ExternalLink className="size-3.5" />
</a>
</CardContent>
</Card>
);
}
39 changes: 39 additions & 0 deletions app/packages/web/src/pages/discord.page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,45 @@ describe('DiscordPage', () => {
expect(screen.getByRole('tab', { name: 'Per-Game Permissions' })).toBeInTheDocument();
});

describe('SetupWizard', () => {
it('should render the setup wizard when allowedGuilds is empty', async () => {
apiMock.discordConfig.mockResolvedValue({
...REDACTED_CONFIG,
allowedGuilds: [],
botTokenSet: false,
publicKeySet: false,
clientId: '',
interactionsEndpointUrl: null,
});
renderPage(<DiscordPage />, { initialEntries: ['/discord'] });

expect(await screen.findByRole('heading', { name: 'Get started' })).toBeInTheDocument();
});

it('should not render the setup wizard when allowedGuilds is non-empty', async () => {
renderPage(<DiscordPage />, { initialEntries: ['/discord'] });

await screen.findByText(/Slash-command bot configuration/i);
expect(screen.queryByRole('heading', { name: 'Get started' })).toBeNull();
});

it('should mark credentials step as done when botTokenSet and publicKeySet are true', async () => {
apiMock.discordConfig.mockResolvedValue({
...REDACTED_CONFIG,
allowedGuilds: [],
botTokenSet: true,
publicKeySet: true,
clientId: '123456789012345678',
interactionsEndpointUrl: null,
});
renderPage(<DiscordPage />, { initialEntries: ['/discord'] });

await screen.findByRole('heading', { name: 'Get started' });
// The credentials step text should be present (and rendered as struck-through)
expect(screen.getByText(/Paste those values into the/i)).toBeInTheDocument();
});
});

it('should show the unavailable-state copy when /api/discord/config rejects', async () => {
apiMock.discordConfig.mockRejectedValue(new Error('boom'));
renderPage(<DiscordPage />, { initialEntries: ['/discord'] });
Expand Down
Loading
Loading