From e3ecad18cad90e779ef2cb604e238cb767c2d382 Mon Sep 17 00:00:00 2001 From: Abdelrahman Ahmed Date: Thu, 22 Oct 2026 16:33:59 +0300 Subject: [PATCH] app: tidy Licenses page Remove the refresh button, the domain after the license counter and the dead man's switch note; align the license name and early access line; show active licenses with a green badge. --- apps/app/src/pages/Licenses.tsx | 60 ++--------------------------- apps/app/src/styles.css | 3 ++ apps/app/src/test/licenses.test.tsx | 49 +---------------------- 3 files changed, 8 insertions(+), 104 deletions(-) diff --git a/apps/app/src/pages/Licenses.tsx b/apps/app/src/pages/Licenses.tsx index 6aac898..3b36fba 100644 --- a/apps/app/src/pages/Licenses.tsx +++ b/apps/app/src/pages/Licenses.tsx @@ -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)"'; @@ -23,8 +21,8 @@ function LastSeen({unix, skewMs}: {unix: number; skewMs: number}) { return Last seen {fmtLastSeenAgo(unix, now + skewMs)}; } -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'}; } @@ -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(''); const [trialKey, setTrialKey] = useState<{lid: string; key: string} | null>(null); @@ -400,38 +378,15 @@ export default function Licenses() { {free && ( {free.used} of {free.limit} free Pro licenses used - {where ? ` · ${where}` : ''} )} {fmtRefreshed(updatedAt, Math.max(tick, updatedAt))} - {announce} - {error &&
{error}
} - {refreshFailed && !refreshing && ( -
{REFRESH_ERROR}
- )} -
+
{sorted.map((l) => (
- )} - {!empty && ( -
-

- Want an alert if kwatch stops?{' '} - Set up the dead man’s switch. -

+
)} diff --git a/apps/app/src/styles.css b/apps/app/src/styles.css index 719ac0f..b129e68 100644 --- a/apps/app/src/styles.css +++ b/apps/app/src/styles.css @@ -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); } @@ -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; } diff --git a/apps/app/src/test/licenses.test.tsx b/apps/app/src/test/licenses.test.tsx index 9ea6d62..b9dd93b 100644 --- a/apps/app/src/test/licenses.test.tsx +++ b/apps/app/src/test/licenses.test.tsx @@ -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(); @@ -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(); }); @@ -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');