Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
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
93 changes: 70 additions & 23 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 { 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,35 +38,75 @@ 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;
}) {
return (
<div>
<label style={{ fontSize: '0.72rem', color: 'var(--text-dim)', display: 'block', marginBottom: '0.2rem' }}>{label}</label>
<label style={{ fontSize: '0.72rem', color: 'var(--text-dim)', display: 'flex', alignItems: 'center', gap: '0.25rem', marginBottom: '0.2rem' }}>
{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>
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.

Fixed in 37b0d1e. The Field component now uses useId() to link <label htmlFor={id}> to <input id={id}>, and the help <button> is moved out of the <label> as a sibling inside a wrapper <div>. The <label> no longer contains any interactive element.


Generated by Claude Code

</TooltipTrigger>
<TooltipContent side="top" className="max-w-56">
{tooltip}
</TooltipContent>
</Tooltip>
</label>
<input
type="number"
value={value}
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
50 changes: 47 additions & 3 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 @@ -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