diff --git a/.github/workflows/react-doctor.yml b/.github/workflows/react-doctor.yml new file mode 100644 index 00000000..98086273 --- /dev/null +++ b/.github/workflows/react-doctor.yml @@ -0,0 +1,54 @@ +# React Doctor — finds security, performance, correctness, accessibility, +# bundle-size, and architecture issues in React codebases. +# +# Docs: https://www.react.doctor/ci +# Source: https://github.com/millionco/react-doctor + +name: React Doctor + +on: + # Scans the PR's changed files and posts a sticky summary comment listing only the new issues introduced relative to the merge base of the target branch. + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + # Scans `main` on every push to track the health-score trend and catch regressions that slipped past PR review. + push: + branches: ["main"] + +permissions: + contents: read + pull-requests: write + issues: write + statuses: write + +# Cancels any in-flight scan for the same PR (or branch, on push) the moment a new commit arrives, so reviewers only ever see the latest run. +concurrency: + group: react-doctor-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + react-doctor: + runs-on: ubuntu-latest + steps: + # fetch-depth: 0 gives React Doctor the full git history it needs to find the merge base with the target branch. Without it a shallow checkout has no merge base, so PR runs can't compare against the base and fall back to reporting every issue in the changed files (pre-existing ones included) instead of only the ones the PR introduced. + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + + - uses: millionco/react-doctor@v2 + with: + directory: gui + # Advisory by default: React Doctor reports findings on every PR — a + # sticky summary comment, inline review comments, and a commit status + # with the health score — but never fails the check, so it won't red-X + # a teammate's PR on day one. When your team trusts the signal, graduate + # the gate: uncomment the block below and set blocking to "error" (fail + # on new error-severity findings) or "warning" (fail on any finding). + # Full reference: https://www.react.doctor/ci + # with: + # blocking: error # Gate level: "none" (advisory, the default) | "warning" | "error" + # scope: full # On PRs, scan the whole project instead of just changed files + # comment: false # Disable the sticky PR summary comment + # review-comments: false # Disable inline review comments on changed lines + # commit-status: false # Disable the commit status (score + counts, links to the run) + # version: "0.4.0" # Pin to a specific react-doctor version instead of "latest" + # project: "web,admin" # In a monorepo, scan specific workspace project(s) diff --git a/gui/README.md b/gui/README.md index 3e2b6ced..667c170a 100644 --- a/gui/README.md +++ b/gui/README.md @@ -28,3 +28,27 @@ bun run build:gui That command installs/builds this dashboard and copies the production assets into the package layout used by `ocx gui`. + +## Lint and React Doctor + +```bash +cd gui +bun run lint # ESLint — hard local/CI gate (`GUI lint` in CI) +bun run doctor # React Doctor vs origin/main (changed-scope regressions) +bun run doctor:full # Full-project React Doctor scan +``` + +From the repo root: + +```bash +bun run doctor:gui # same as gui doctor +bun run doctor:gui:full +bun run setup:hooks # pre-push runs doctor when gui/ changed +``` + +| Tool | Role | +|------|------| +| **ESLint** (`bun run lint`) | Hard gate in CI and expected before merge | +| **React Doctor** (`bun run doctor`) | Extra React health check. Pre-push runs it only if `gui/` changed. CI workflow is advisory (does not fail the PR) | + +Fix ESLint errors first. Use `doctor` / `doctor:full` for deeper React triage. diff --git a/gui/bun.lock b/gui/bun.lock index 11c7b846..abd679cf 100644 --- a/gui/bun.lock +++ b/gui/bun.lock @@ -5,6 +5,7 @@ "": { "name": "gui", "dependencies": { + "@tanstack/react-query": "^5.101.2", "@tanstack/react-virtual": "^3.14.5", "react": "^19.2.7", "react-dom": "^19.2.7", @@ -136,6 +137,10 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@tanstack/query-core": ["@tanstack/query-core@5.101.2", "", {}, "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.101.2", "", { "dependencies": { "@tanstack/query-core": "5.101.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg=="], + "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.5", "", { "dependencies": { "@tanstack/virtual-core": "3.17.3" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A=="], "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.3", "", {}, "sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw=="], diff --git a/gui/doctor.config.json b/gui/doctor.config.json new file mode 100644 index 00000000..974a0078 --- /dev/null +++ b/gui/doctor.config.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://react.doctor/schema/config.json", + "scope": "changed", + "base": "origin/main", + "blocking": "error", + "ignore": { + "files": ["dist/**", "node_modules/**"] + } +} diff --git a/gui/eslint.config.js b/gui/eslint.config.js index 4d405488..a8d0cf95 100644 --- a/gui/eslint.config.js +++ b/gui/eslint.config.js @@ -5,8 +5,7 @@ import reactRefresh from 'eslint-plugin-react-refresh' import tseslint from 'typescript-eslint' import { defineConfig, globalIgnores } from 'eslint/config' import { I18N_DATA_FILES, I18N_UI_FILES } from './.eslint/i18n-file-groups.ts' - -const localI18nPlugin = (await import(new URL('./.eslint/local-i18n-plugin.ts', import.meta.url).href)).default +import localI18nPlugin from './.eslint/local-i18n-plugin.ts' export default defineConfig([ globalIgnores([ diff --git a/gui/package.json b/gui/package.json index f3df2de4..9f2b474c 100644 --- a/gui/package.json +++ b/gui/package.json @@ -8,9 +8,12 @@ "build": "tsc -b && vite build", "lint": "eslint .", "lint:i18n": "eslint src/pages src/components src/App.tsx src/ui.tsx", + "doctor": "npx --yes react-doctor@latest --verbose --scope changed --base origin/main --no-telemetry", + "doctor:full": "npx --yes react-doctor@latest --verbose --scope full --no-telemetry", "preview": "vite preview" }, "dependencies": { + "@tanstack/react-query": "^5.101.2", "@tanstack/react-virtual": "^3.14.5", "react": "^19.2.7", "react-dom": "^19.2.7" diff --git a/gui/src/App.tsx b/gui/src/App.tsx index 5562fc93..d25f46e6 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -1,3 +1,4 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; import Dashboard from "./pages/Dashboard"; import Providers from "./pages/Providers"; @@ -10,7 +11,7 @@ import CodexAuth from "./pages/CodexAuth"; import ApiKeys from "./pages/ApiKeys"; import ClaudeCode from "./pages/ClaudeCode"; import { IconGrid, IconServer, IconBoxes, IconBot, IconList, IconTerminal, IconActivity, IconKey, IconGithub, IconMenu, IconSun, IconMoon, IconMonitor, IconGlobe, IconPower, IconSparkle, IconX } from "./icons"; -import { useI18n, useT, LOCALES, type Locale, type TKey } from "./i18n"; +import { useI18n, useT, LOCALES, type Locale, type TKey } from "./i18n/shared"; import { Select } from "./ui"; import { installApiAuthFetch } from "./api"; @@ -59,7 +60,7 @@ function readStoredTheme(): Theme { export default function App() { const [page, setPageState] = useState(readPageFromHash); const [theme, setTheme] = useState(readStoredTheme); - const [runtimeVersion, setRuntimeVersion] = useState(null); + const queryClient = useQueryClient(); const { locale, setLocale } = useI18n(); const t = useT(); @@ -89,30 +90,23 @@ export default function App() { else { el.setAttribute("data-theme", theme); localStorage.setItem(THEME_KEY, theme); } }, [theme]); - useEffect(() => { - let cancelled = false; - const fetchRuntimeVersion = async () => { - try { - const res = await fetch(`${API_BASE}/healthz`); - if (!res.ok) return; - const version = readRuntimeVersion(await res.json()); - if (!cancelled && version) setRuntimeVersion(version); - } catch { - // Keep the build-time fallback when the proxy is unavailable. - } - }; - fetchRuntimeVersion(); - const interval = setInterval(fetchRuntimeVersion, 30000); - return () => { cancelled = true; clearInterval(interval); }; - }, []); + const { data: healthData } = useQuery({ + queryKey: ["healthz", API_BASE], + queryFn: async () => { + const res = await fetch(`${API_BASE}/healthz`); + if (!res.ok) throw new Error("health check failed"); + return res.json(); + }, + refetchInterval: 30_000, + retry: false, + }); const cycleTheme = () => setTheme(t => (t === "light" ? "dark" : t === "dark" ? "system" : "light")); const ThemeIcon = THEME_ICON[theme]; - const displayedVersion = runtimeVersion ?? __APP_VERSION__; + const displayedVersion = readRuntimeVersion(healthData) ?? __APP_VERSION__; const [stopping, setStopping] = useState(false); // Sidebar "Claude ON" toggle — literal label in every locale (product name). - const [claudeEnabled, setClaudeEnabled] = useState(null); useEffect(() => { if (!navOpen) return; @@ -142,28 +136,31 @@ export default function App() { return () => mq.removeEventListener("change", onChange); }, []); - useEffect(() => { - let cancelled = false; - fetch(`${API_BASE}/api/claude-code`) - .then(res => res.json()) - .then(d => { if (!cancelled && typeof d.enabled === "boolean") setClaudeEnabled(d.enabled); }) - .catch(() => { /* toggle stays hidden until the API answers */ }); - return () => { cancelled = true; }; - }, []); + const { data: claudeData } = useQuery({ + queryKey: ["claude-code", API_BASE], + queryFn: async () => { + const res = await fetch(`${API_BASE}/api/claude-code`); + if (!res.ok) throw new Error("claude-code fetch failed"); + return res.json() as Promise<{ enabled?: boolean }>; + }, + retry: false, + }); + const claudeEnabled = + claudeData !== undefined && typeof claudeData.enabled === "boolean" ? claudeData.enabled : null; const toggleClaude = async () => { if (claudeEnabled === null) return; const next = !claudeEnabled; - setClaudeEnabled(next); // optimistic + queryClient.setQueryData(["claude-code", API_BASE], { enabled: next }); try { const res = await fetch(`${API_BASE}/api/claude-code`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ enabled: next }), }); - if (!res.ok) setClaudeEnabled(!next); + if (!res.ok) queryClient.setQueryData(["claude-code", API_BASE], { enabled: !next }); } catch { - setClaudeEnabled(!next); + queryClient.setQueryData(["claude-code", API_BASE], { enabled: !next }); } }; const handleStop = async () => { @@ -206,7 +203,7 @@ export default function App() {