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
55 changes: 33 additions & 22 deletions web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ The management console for ThinkWatch, built with React 19, TypeScript, and Vite

- **React 19** with TypeScript
- **TanStack Router** for file-based routing
- **TanStack Query** for server state — see [Data fetching](#data-fetching)
- **shadcn/ui** (Radix UI + Tailwind CSS 4) for components
- **react-i18next** for internationalization (English + Chinese)
- **Vitest** + React Testing Library for testing
Expand All @@ -23,28 +24,36 @@ pnpm exec tsc --noEmit # Type check

## Data fetching

Loads are hand-rolled: a `useCallback` that fetches and sets state, plus a
`useEffect` that calls it. There is no data-fetching layer in this app.

That shape trips `react-hooks/set-state-in-effect`, and the sites that it
flags carry a one-line suppression saying which of two things is going on:

- **The rule is wrong.** `useEffect(() => { load(); }, [load])` where `load`
is async and its first statement is the `await`. Every setState inside
runs in the continuation — never synchronously with the effect, never a
cascading render. The rule's cross-function analysis does not model
`await`.
- **The rule is right and the fix is architectural.** The loader's first
statement flips a spinner. Hoisting that flag out to render-time silences
the rule, but it splits "start a load" across two places and leaves every
other caller of the loader responsible for remembering half of it.

**Both go away with a data-fetching layer** (TanStack Query or equivalent),
which owns the loading flag and the cache and removes the effect entirely.
That is a deliberate piece of work, not something to fold into a lint pass.
Until then, do not add new suppressions of this rule without one of the two
reasons above — every other finding it reports is a real one, and the rest
of the codebase is clean of them.
Server state goes through [TanStack Query](https://tanstack.com/query). A
component asks for what it renders with `useQuery`, and the query layer owns
the rest: the loading flag, the cache, cancelling a request nobody is waiting
for, and keeping a late response from landing under filters that have since
changed. No load runs in an effect.

- **Keys follow the endpoint.** `GET /api/admin/teams/{id}/members` is keyed
`['admin', 'teams', id, 'members']`, with query-string parameters in a
trailing object. A key names everything its `queryFn` reads —
`@tanstack/query/exhaustive-deps` enforces that — and invalidating a
prefix such as `['admin', 'teams']` reaches every query under it. Pass
`exact: true` when a prefix would reach further than the write did.
- **Writes invalidate.** After a mutation, `await
queryClient.invalidateQueries({ queryKey })` for what the screen shows.
Awaiting keeps orderings like "close the dialog once the list is fresh".
Screens that aren't open need nothing: every successful write through
`api()` drops the cached queries no screen is using (`onSuccessfulWrite`,
wired up in `main.tsx`), so none of them reopens onto pre-write data.
- **Pass the signal.** `queryFn: ({ signal }) => api(path, { signal })` lets
the layer cancel a request once no component needs its answer.
- **Derive, don't copy.** Read `query.data` where it is rendered. When a
screen needs local state that follows what was loaded — a form draft, a
selection — adjust it during render, the way `useResetOnChange` does.

`src/lib/query-client.ts` builds the client and says why its defaults differ
from the library's. Tests render through `renderWithQueryClient` from
`src/test/render.tsx`, which gives every call an empty cache.

Do not add new suppressions of `react-hooks/set-state-in-effect` — every
finding it reports is a real one, and the codebase is clean of them.

## Project Structure

Expand All @@ -58,6 +67,7 @@ src/
│ └── use-mobile.ts # Responsive breakpoint detection
├── lib/
│ ├── api.ts # HTTP client with HMAC signing & auto token refresh
│ ├── query-client.ts # TanStack Query client & its defaults
│ └── utils.ts # Utility functions
├── i18n/
│ ├── en.json # English translations
Expand Down Expand Up @@ -88,6 +98,7 @@ src/
│ ├── settings.tsx # Dynamic system settings (7 tabs)
│ └── log-forwarders.tsx # Log forwarding configuration
├── test/
│ ├── render.tsx # render() inside a fresh query cache
│ └── setup.ts # Test setup (jest-dom + i18n)
└── router.tsx # Route definitions & setup redirect logic
```
Expand Down
5 changes: 5 additions & 0 deletions web/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import reactCompiler from 'eslint-plugin-react-compiler'
import pluginQuery from '@tanstack/eslint-plugin-query'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'

Expand All @@ -22,6 +23,10 @@ export default defineConfig([
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
// The query layer's counterpart to exhaustive effect deps: a value a
// `queryFn` reads but its `queryKey` omits makes two different
// requests share one cache entry.
pluginQuery.configs['flat/recommended'],
],
rules: {
// `warn` rather than `error` while we land the underlying
Expand Down
2 changes: 2 additions & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@codemirror/lang-json": "^6.0.2",
"@fontsource-variable/geist": "^5.2.8",
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-query": "^5.102.8",
"@tanstack/react-router": "^1.168.10",
"@uiw/react-codemirror": "^4.25.9",
"class-variance-authority": "^0.7.1",
Expand All @@ -44,6 +45,7 @@
"devDependencies": {
"@eslint/js": "^10.0.1",
"@playwright/test": "^1.59.1",
"@tanstack/eslint-plugin-query": "^5.102.8",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
Expand Down
39 changes: 39 additions & 0 deletions web/pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions web/src/components/limits/user-limits-tab.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import { screen, waitFor } from '@testing-library/react'
import { renderWithQueryClient } from '@/test/render'
import { UserLimitsTab } from './user-limits-tab'

vi.mock('@/lib/api', () => ({
Expand Down Expand Up @@ -55,7 +56,7 @@ describe('UserLimitsTab — remaining / exceeded labels', () => {
dashboard({ ruleMax: 1000, ruleCurrent: 250, capLimit: 1_000_000, capCurrent: 600_000 }),
)

render(<UserLimitsTab userId="user-1" />)
renderWithQueryClient(<UserLimitsTab userId="user-1" />)

await waitFor(() => {
expect(screen.getByText('25%')).toBeInTheDocument()
Expand All @@ -72,7 +73,7 @@ describe('UserLimitsTab — remaining / exceeded labels', () => {
dashboard({ ruleMax: 100, ruleCurrent: 100, capLimit: 500, capCurrent: 600 }),
)

render(<UserLimitsTab userId="user-2" />)
renderWithQueryClient(<UserLimitsTab userId="user-2" />)

await waitFor(() => {
// Rule pct caps at 100 because of Math.min, cap pct also caps at 100
Expand Down Expand Up @@ -103,7 +104,7 @@ describe('UserLimitsTab — remaining / exceeded labels', () => {
recent_events: [],
})

render(<UserLimitsTab userId="user-3" />)
renderWithQueryClient(<UserLimitsTab userId="user-3" />)

await waitFor(() => {
expect(screen.getByText('0%')).toBeInTheDocument()
Expand Down
72 changes: 32 additions & 40 deletions web/src/components/limits/user-limits-tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@
// POST /api/admin/limits/bulk/{rules|budgets}/{disable|delete} — bulk
// ============================================================================

import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useNow } from '@/hooks/use-now';
import { AlertCircle, Plus, Trash2, PowerOff, RotateCw, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
Expand Down Expand Up @@ -174,10 +175,18 @@ interface DrawerInit {

export function UserLimitsTab({ userId }: UserLimitsTabProps) {
const { t } = useTranslation();
const [data, setData] = useState<LimitsDashboard | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [selected, setSelected] = useState<Set<string>>(new Set());
const queryClient = useQueryClient();
const dashboardQuery = useQuery({
queryKey: ['admin', 'users', userId, 'limits-dashboard'],
queryFn: ({ signal }) =>
api<LimitsDashboard>(`/api/admin/users/${userId}/limits-dashboard`, { signal }),
});
const data = dashboardQuery.data ?? null;
const loading = dashboardQuery.isPending;
const error = dashboardQuery.error?.message ?? '';
const reload = () =>
queryClient.invalidateQueries({ queryKey: ['admin', 'users', userId, 'limits-dashboard'] });
const [selection, setSelection] = useState<Set<string>>(new Set());
const [bulkAction, setBulkAction] = useState<'disable' | 'delete' | null>(null);
const [drawer, setDrawer] = useState<DrawerInit | null>(null);
const [resetTarget, setResetTarget] = useState<
Expand All @@ -186,39 +195,6 @@ export function UserLimitsTab({ userId }: UserLimitsTabProps) {
| null
>(null);

const reload = useCallback(async () => {
setError('');
try {
const res = await api<LimitsDashboard>(`/api/admin/users/${userId}/limits-dashboard`);
setData(res);
// Drop stale selections.
setSelected((prev) => {
const live = new Set<string>();
res.rules.forEach((r) => {
const k = rowKey(r);
if (k) live.add(k);
});
res.caps.forEach((c) => {
const k = rowKey(c);
if (k) live.add(k);
});
return new Set(Array.from(prev).filter((k) => live.has(k)));
});
} catch (e) {
setError(e instanceof Error ? e.message : t('common.operationFailed'));
} finally {
setLoading(false);
}
}, [userId, t]);

useEffect(() => {
// Hand-rolled load: the spinner flag is the first half of "start a
// fetch" and belongs with it. See "Data fetching" in web/README.md —
// this goes away with a data-fetching layer, not by moving the flag.
// eslint-disable-next-line react-hooks/set-state-in-effect
reload();
}, [reload]);

const overrideKeys = useMemo(() => {
if (!data) return { rules: new Set<string>(), caps: new Set<string>() };
return {
Expand All @@ -229,10 +205,26 @@ export function UserLimitsTab({ userId }: UserLimitsTabProps) {
};
}, [data]);

// Drop stale selections: an override the latest load no longer lists
// stops counting as selected.
const selected = useMemo(() => {
if (!data) return selection;
const live = new Set<string>();
data.rules.forEach((r) => {
const k = rowKey(r);
if (k) live.add(k);
});
data.caps.forEach((c) => {
const k = rowKey(c);
if (k) live.add(k);
});
return new Set(Array.from(selection).filter((k) => live.has(k)));
}, [data, selection]);

const someSelected = selected.size > 0;

const toggleOne = (key: string) =>
setSelected((prev) => {
setSelection((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
Expand All @@ -259,7 +251,7 @@ export function UserLimitsTab({ userId }: UserLimitsTabProps) {
count: selected.size,
}),
);
setSelected(new Set());
setSelection(new Set());
await reload();
} catch (e) {
toast.error(e instanceof Error ? e.message : t('common.operationFailed'));
Expand Down
Loading
Loading