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: 4 additions & 56 deletions apps/app/src/pages/Licenses.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ import {useSession} from '../session';
import {useClusters} from '../useClusters';
import {DOCS_URL} from '../config';

export const MIN_REFRESH_MS = 400;
const REFRESH_ERROR = 'Could not refresh. Check your connection and try again.';

const INSTALLER = '/bin/bash -c "$(curl -fsSL https://kwatch.dev/kwatch.sh)"';

Expand All @@ -23,8 +21,8 @@ function LastSeen({unix, skewMs}: {unix: number; skewMs: number}) {
return <span title={fmtUtcFull(unix)}>Last seen {fmtLastSeenAgo(unix, now + skewMs)}</span>;
}

function statusOf(l: License): {label: string; tone: 'neutral' | 'warn' | 'bad'} {
if (l.status === 'active') return {label: 'active', tone: 'neutral'};
function statusOf(l: License): {label: string; tone: 'ok' | 'warn' | 'bad'} {
if (l.status === 'active') return {label: 'active', tone: 'ok'};
if (l.status === 'suspended') return {label: l.status, tone: 'warn'};
return {label: l.status, tone: 'bad'};
}
Expand Down Expand Up @@ -313,31 +311,11 @@ export default function Licenses() {
const reloadAll = useCallback(async () => {
await Promise.all([reload(), reloadClusters()]);
}, [reload, reloadClusters]);
const [refreshing, setRefreshing] = useState(false);
const [refreshFailed, setRefreshFailed] = useState(false);
const [announce, setAnnounce] = useState('');
const [tick, setTick] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setTick(Date.now()), 30_000);
return () => clearInterval(id);
}, []);
// Manual refresh: busy state stays up for at least MIN_REFRESH_MS so it registers.
async function refresh() {
setRefreshing(true);
setRefreshFailed(false);
setAnnounce('');
try {
const [results] = await Promise.all([
Promise.all([reload(), reloadClusters()]),
new Promise((r) => setTimeout(r, MIN_REFRESH_MS)),
]);
const failed = results.some((ok) => !ok);
setRefreshFailed(failed);
setAnnounce(failed ? REFRESH_ERROR : 'Refreshed');
} finally {
setRefreshing(false);
}
}
const [busy, setBusy] = useState(false);
const [error, setError] = useState<ReactNode>('');
const [trialKey, setTrialKey] = useState<{lid: string; key: string} | null>(null);
Expand Down Expand Up @@ -400,38 +378,15 @@ export default function Licenses() {
{free && (
<span className="small muted">
<span className="mono count">{free.used} of {free.limit}</span> free Pro licenses used
{where ? ` · ${where}` : ''}
</span>
)}
<span className="small muted" title={fmtUtcFull(updatedAt / 1000)}>
{fmtRefreshed(updatedAt, Math.max(tick, updatedAt))}
</span>
<span className="sr-only" aria-live="polite">{announce}</span>
<button
type="button"
className="icon-btn"
aria-label={refreshing ? 'Refreshing…' : 'Refresh'}
title="Refresh"
disabled={refreshing}
aria-busy={refreshing || undefined}
onClick={() => void refresh()}
>
{refreshing ? (
<span className="spinner" aria-hidden="true" />
) : (
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 12a9 9 0 1 1-2.64-6.36" />
<path d="M21 3v6h-6" />
</svg>
)}
</button>
</div>
</div>
{error && <div className="alert" role="alert">{error}</div>}
{refreshFailed && !refreshing && (
<div className="alert">{REFRESH_ERROR}</div>
)}
<div aria-busy={refreshing}>
<div>
{sorted.map((l) => (
<LicenseCard
key={l.lid}
Expand All @@ -456,14 +411,7 @@ export default function Licenses() {
<div className="get-more">
<button type="button" className="btn-action" disabled={busy} onClick={getFree}>
{busy ? 'Getting your license…' : 'Get another free Pro license'}
</button> </div>
)}
{!empty && (
<div className="small muted">
<p>
Want an alert if kwatch stops?{' '}
<a href={`${DOCS_URL}/heartbeat-monitor-configuration`}>Set up the dead man&rsquo;s switch.</a>
</p>
</button>
</div>
)}
</>
Expand Down
3 changes: 3 additions & 0 deletions apps/app/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ form .muted.small { margin-top: 14px; font-size: 13px; }
.license-name { font-weight: 600; font-size: 18px; font-family: var(--font-body); }
.key { font-size: 13px; overflow-wrap: anywhere; }
.badge { font-size: 12px; font-weight: 500; padding: 2px 8px; border-radius: var(--radius-chip); margin-left: 6px; vertical-align: middle; border: 1px solid transparent; }
.badge-ok { color: var(--success); background: var(--success-bg); }
.badge-neutral { color: var(--text); background: var(--bg); border-color: var(--line); }
.badge-warn { color: var(--degraded); background: var(--degraded-bg); }
.badge-bad { color: var(--failing); background: var(--failing-bg); }
Expand Down Expand Up @@ -224,3 +225,5 @@ form .muted.small { margin-top: 14px; font-size: 13px; }
.icon-btn:disabled { cursor: default; }
.key-row { display: flex; align-items: center; gap: var(--space-1); min-width: 0; }
.key-row .key { margin: 0; overflow-x: auto; }
.license-main p { margin: 0; }
.license-name { margin-bottom: 4px; }
49 changes: 1 addition & 48 deletions apps/app/src/test/licenses.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe('/licenses', () => {
'GET /account/clusters': {body: {clusters: [cl({})]}},
});
renderAt('/licenses');
expect(await screen.findByText(/free Pro licenses used · acme.com/)).toHaveTextContent('2 of 2 free Pro licenses used · acme.com');
expect(await screen.findByText(/free Pro licenses used/)).toHaveTextContent('2 of 2 free Pro licenses used');
const first = await screen.findByRole('region', {name: 'License lic_1'});
expect(within(first).getByText(/KW1-••••••f3a9Qk/)).toBeInTheDocument();
expect(within(first).getByText('active')).toBeInTheDocument();
Expand Down Expand Up @@ -227,7 +227,6 @@ describe('/licenses', () => {
expect(screen.getByText('3 nodes')).toBeInTheDocument();
expect(screen.getAllByText(/^Last seen /).length).toBe(2);
expect(screen.queryByText(/Inactive|Stopped|Waiting|check-in/i)).toBeNull();
expect(screen.getByRole('link', {name: /dead man.s switch/})).toHaveAttribute('href', 'https://docs.kwatch.dev/heartbeat-monitor-configuration');
expect(screen.queryByText(/reach kwatch.dev/)).toBeNull();
});

Expand Down Expand Up @@ -302,52 +301,6 @@ describe('/licenses', () => {
expect(container.querySelector('.btn-signal')).toBeNull();
});

it('refresh reloads clusters', async () => {
const {calls} = withClusters([]);
const user = userEvent.setup();
renderAt('/licenses');
await user.click(await screen.findByRole('button', {name: 'Refresh'}));
await screen.findByRole('region', {name: 'License lic_1'});
expect(calls.filter((c) => c.key === 'GET /account/clusters').length).toBeGreaterThanOrEqual(2);
});

it('refresh shows a busy state, then Last refreshed', async () => {
withClusters([]);
const user = userEvent.setup();
renderAt('/licenses');
const status = await screen.findByText('Last refreshed just now');
expect(status).not.toHaveAttribute('aria-live');
const sr = document.querySelector('.sr-only[aria-live="polite"]') as HTMLElement;
expect(sr).toHaveAttribute('aria-live', 'polite');
expect(sr).toBeEmptyDOMElement();
expect(status).toHaveAttribute('title', expect.stringMatching(/UTC$/));
await user.click(screen.getByRole('button', {name: 'Refresh'}));
const busy = screen.getByRole('button', {name: /Refreshing/});
expect(busy).toBeDisabled();
expect(busy).toHaveAttribute('aria-busy', 'true');
expect(document.querySelector('div[aria-busy="true"] [aria-label="License lic_1"]')).not.toBeNull();
expect(await screen.findByRole('button', {name: 'Refresh'})).toBeEnabled();
expect(document.querySelector('[aria-busy="true"]')).toBeNull();
expect(document.querySelector('.sr-only[aria-live]')).toHaveTextContent('Refreshed');
});

it('refresh error shows an alert and keeps the old timestamp', async () => {
let n = 0;
mockApi({
'GET /account': () => (++n === 1 ? {body: account({licenses: [license()]})} : {status: 500, body: {}}),
'GET /account/clusters': {body: {clusters: []}},
});
const user = userEvent.setup();
renderAt('/licenses');
await screen.findByText('Last refreshed just now');
await user.click(screen.getByRole('button', {name: 'Refresh'}));
expect(await screen.findAllByText('Could not refresh. Check your connection and try again.')).toHaveLength(2);
expect(document.querySelector('.sr-only[aria-live]')).toHaveTextContent('Could not refresh');
expect(screen.getAllByText(/Could not refresh/)).toHaveLength(2);
expect(screen.getByText('Last refreshed just now')).toBeInTheDocument();
expect(screen.getByRole('region', {name: 'License lic_1'})).toBeInTheDocument();
});

it('/clusters redirects to /licenses and the nav has one item', async () => {
withClusters([cl()]);
renderAt('/clusters');
Expand Down
Loading