Skip to content

feat: built-in web dashboard#1342

Merged
ibigbug merged 25 commits into
masterfrom
feat/builtin-dashboard
Apr 27, 2026
Merged

feat: built-in web dashboard#1342
ibigbug merged 25 commits into
masterfrom
feat/builtin-dashboard

Conversation

@ibigbug
Copy link
Copy Markdown
Member

@ibigbug ibigbug commented Apr 27, 2026

What does this PR do?

Adds a built-in web dashboard to clash-rs, compiled into the binary via rust-embed and served at /ui. No external dashboard binary needed.

The dashboard uses an Apple/SwiftUI-inspired design with liquid-glass cards and a clean minimal layout.

Features

  • Overview — traffic chart (upload/download), mode switcher, proxy group quick-select
  • Proxies — full proxy/group hierarchy, latency testing, selector switching
  • Rules — rule list with per-type color badges, live search/filter
  • Providers — proxy providers (expand to see proxies + latency) and rule providers (expand to see Classical rules), update + healthcheck
  • Connections — active connection table with filter and bulk/individual close
  • DNS — query tool with A/AAAA/CNAME answer records
  • Logs — real-time log stream via WebSocket with severity filter, pause/resume, history replay on connect
  • Config — live config editor (port, DNS, mode, log level) with reload
  • Settings — API URL + secret (session-scoped), connection status

Technical

  • Feature flag: dashboard (enabled by default in all builds)
  • Build: build.rs runs npm run build when CARGO_FEATURE_DASHBOARD is set; dashboard dist/ embedded via rust-embed
  • Backend additions: /providers/rules (list + update), /providers/rules/:name/rules (Classical rule enumeration), WebSocket log history ring buffer (200 entries, replayed on connect)

Type

  • New feature

Checklist

  • Tests added or not needed
  • Docs updated or not needed

Summary by CodeRabbit

  • New Features

    • Built-in web dashboard for managing proxies, rules, connections, logs, DNS, traffic, and settings; available at /ui/ when enabled.
    • Real-time traffic charts, live connection list with controls, log tailing with filters/pausing, DNS lookup tool, proxy group management, and rule browsing/testing.
    • Persistent API URL/secret settings via the UI.
  • Documentation

    • Added a dashboard README with setup and usage instructions.
  • Chores

    • Added dashboard build/config and packaging so the app can be embedded and served.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 27, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds a full Clash RS Dashboard SPA (Vite + React + TypeScript) and embeds it into the server binary. Frontend includes UI components, pages, WebSocket hooks, and API client; backend adds build integration, embedded static serving, WebSocket log replay, rule-provider routes, and partial listener restart support.

Changes

Cohort / File(s) Summary
Project config & build
clash-dashboard/{.gitignore,package.json,README.md,components.json,tsconfig.json,tsconfig.app.json,tsconfig.node.json,eslint.config.js,vite.config.ts,index.html}
New dashboard project files: gitignore, npm manifest, README, shadcn components.json, TypeScript and ESLint configs, Vite config and HTML entry.
Frontend core
clash-dashboard/src/{main.tsx,App.tsx,index.css}
App bootstrap, root App with router + QueryClient, global Tailwind/CSS including liquid-glass styles.
UI primitives
clash-dashboard/src/components/ui/*.{ts,tsx}
shadcn-style UI components: badge, button, card (including VividCard), scroll-area, separator, table, tabs, tooltip.
Layout, charts, groups
clash-dashboard/src/components/{Layout.tsx,TrafficChart.tsx,ProxyGroups.tsx}
App shell with sticky nav/status, uPlot traffic chart, and ProxyGroups with optimistic selection and latency testing.
Pages
clash-dashboard/src/pages/{Overview,Proxies,Connections,Rules,DNS,Logs,Settings,Providers}.tsx
Feature pages for overview, proxies/providers, connections, rules, DNS lookup, logs, and settings.
Hooks & libs
clash-dashboard/src/hooks/{useWebSocket,useTraffic}.ts, clash-dashboard/src/lib/{api.ts,settings.ts,utils.ts}
WebSocket and traffic hooks, typed REST API client, settings persistence, and cn util.
Tooling files
clash-dashboard/components.json, clash-dashboard/eslint.config.js
UI/tooling configuration and flat ESLint config.
Backend build & embedding
clash-lib/build.rs, clash-lib/Cargo.toml, clash-bin/Cargo.toml
New dashboard Cargo feature, build.rs step to run npm build when enabled, rust-embed optional dependency and default feature inclusion.
Embedded asset server
clash-lib/src/app/api/embedded_dashboard.rs, clash-lib/src/app/api/mod.rs, clash-lib/src/app/api/runner.rs
RustEmbed-based embedded assets, handlers to serve SPA (/ui/*), AppState now contains recent_logs ring buffer, ApiRunner threaded with recent_logs.
WebSocket & logging
clash-lib/src/app/api/websocket.rs, clash-lib/src/app/api/handlers/log.rs
Log websocket now replays buffered recent logs on connect, receiver loop refactored to handle lag/closed cases and stop on send failures.
Provider & rule APIs
clash-lib/src/app/api/handlers/provider.rs, clash-lib/src/app/remote_content_manager/providers/rule_provider/{provider.rs,mrs.rs}
New rule provider routes (list, get, update, list rules, match); RuleProvider trait adds list_rules; rule content tracks rule counts for some provider types.
Config & restart logic
clash-lib/src/app/api/handlers/config.rs, clash-lib/src/app/inbound/manager.rs, clash-lib/src/lib.rs
Configs endpoint returns config_path; PATCH refined to distinguish port-only changes vs others and use restart_idle() for port-only restarts; inbound manager adds restart_idle to restart only idle/missing listeners; GlobalState now tracks config_path and recent_logs.
Router API
clash-lib/src/app/router/mod.rs
Router stores rule_providers map and exposes get_rule_providers.

Sequence Diagram(s)

sequenceDiagram
  participant Dev as Developer (build)
  participant NPM as npm (ci/build)
  participant RustBuild as build.rs
  participant Embed as rust-embed
  participant Server as clash-rs (Axum)
  participant Client as Browser SPA

  Dev->>NPM: run npm ci && npm run build (clash-dashboard/dist)
  NPM-->>RustBuild: output dist/
  RustBuild->>Embed: embed dist/ into binary (rust-embed)
  RustBuild->>Server: include embedded assets at compile-time
  Client->>Server: GET /ui/ (or /ui/<asset>)
  Server->>Embed: resolve asset
  Embed-->>Server: return file bytes + content-type
  Server-->>Client: serve asset (index.html or static file)
  Client->>Server: REST /api/* and WebSocket /ws/*
  Server->>Client: JSON responses and WS messages (including replayed recent_logs)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • Itsusinn

Poem

🐰 I stitched a dashboard, bright and neat,
With charts that dance and tabs that greet,
Logs replayed from memory's keep,
Proxies folding, traffic in a sweep,
Now served inside the binary's beat. 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.91% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: built-in web dashboard' clearly describes the main feature added: a new built-in web dashboard. It follows the conventional commit format and accurately summarizes the primary change.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/builtin-dashboard

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

🧹 Nitpick comments (8)
clash-dashboard/src/components/ui/badge.tsx (1)

29-36: Reuse badgeVariants inside Badge to avoid style drift.
Lines 32-35 duplicate the same composition logic already encoded in badgeVariants.

♻️ Suggested refactor
 function Badge({ className, variant = 'default', ...props }: BadgeProps) {
   return (
     <span
-      className={cn(
-        'inline-flex items-center text-[11px] font-semibold px-2 py-0.5 rounded-full',
-        variantStyles[variant] ?? variantStyles.default,
-        className
-      )}
+      className={cn(badgeVariants({ variant }), className)}
       {...props}
     />
   )
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/components/ui/badge.tsx` around lines 29 - 36, The Badge
component duplicates style composition; update Badge to call the existing
badgeVariants helper instead of manually combining variantStyles and className:
in the Badge function (keeping the signature and default variant), compute the
span's className by invoking badgeVariants({ variant, className }) (or the
correct badgeVariants API used in the repo) and remove the explicit
variantStyles lookup, then keep spreading ...props onto the span; ensure
badgeVariants is imported where Badge is defined.
clash-dashboard/src/components/ui/button.tsx (1)

4-38: Tighten variant/size typing to preserve literal unions.
On lines 4 and 14, using Record<string, string> widens keys to string, so variant/size lose strict key safety.

♻️ Suggested refactor
-const variantStyles: Record<string, string> = {
+const variantStyles = {
   default: 'bg-[`#0071e3`] hover:bg-[`#0077ed`] text-white',
   primary: 'bg-[`#0071e3`] hover:bg-[`#0077ed`] text-white',
   outline: 'border border-black/[0.08] bg-white hover:bg-[`#f2f2f7`] text-[`#1d1d1f`]',
   secondary: 'bg-black/[0.06] hover:bg-black/[0.1] text-[`#1d1d1f`]',
   ghost: 'text-[`#6e6e73`] hover:text-[`#1d1d1f`] hover:bg-black/[0.05]',
   destructive: 'bg-red-500 hover:bg-red-600 text-white',
   link: 'text-[`#0071e3`] underline-offset-4 hover:underline',
-}
+} as const

-const sizeStyles: Record<string, string> = {
+const sizeStyles = {
   default: 'h-8 px-3 text-[15px]',
   xs: 'h-6 px-2 text-xs rounded-md',
   sm: 'h-7 px-2.5 text-[0.8rem]',
   lg: 'h-10 px-4 text-[15px]',
   icon: 'size-8',
   'icon-xs': 'size-6',
   'icon-sm': 'size-7',
   'icon-lg': 'size-9',
-}
+} as const
+
+type ButtonVariant = keyof typeof variantStyles
+type ButtonSize = keyof typeof sizeStyles

 export const buttonVariants = ({
   variant = 'default',
   size = 'default',
-}: { variant?: string; size?: string } = {}) =>
+}: { variant?: ButtonVariant; size?: ButtonSize } = {}) =>
   cn(
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/components/ui/button.tsx` around lines 4 - 38, The
variantStyles and sizeStyles objects are typed as Record<string,string>, which
widens their keys and loses literal union types; make them const-typed so their
keys are preserved (e.g., declare variantStyles and sizeStyles as plain object
literals with "as const" or inferred const types), then use keyof typeof
variantStyles and keyof typeof sizeStyles for the buttonVariants parameters and
ButtonProps variant/size types; update the buttonVariants signature (and its
default param types) to reference those keyof types so TypeScript enforces the
exact allowed keys while leaving the runtime values unchanged (refer to
variantStyles, sizeStyles, buttonVariants, and ButtonProps).
clash-lib/src/app/api/embedded_dashboard.rs (2)

60-76: MIME type coverage is solid; consider adding a few more common types.

The extension-to-MIME mapping covers the essentials. You might want to add .webpimage/webp, .gifimage/gif, and .mapapplication/json (for source maps during development), though these may not be needed for the current dashboard build output.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-lib/src/app/api/embedded_dashboard.rs` around lines 60 - 76, The
content_type function's MIME mapping is missing a few common extensions; update
the match in content_type(path: &str) to handle "webp" -> "image/webp", "gif" ->
"image/gif", and "map" -> "application/json" (or "application/json;
charset=utf-8" if you prefer consistency), keeping the existing default branch
intact and using the same pattern of matching path.rsplit('.').next() as in the
current function.

39-43: Consider propagating errors instead of unwrap.

The Response::builder().unwrap() calls are practically safe since only valid headers are set, but for defensive coding they could return a Result or use unwrap_or_else with a fallback 500 response. This is a minor consideration given the controlled input.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-lib/src/app/api/embedded_dashboard.rs` around lines 39 - 43, Replace
the final .unwrap() on the Response builder with proper error
propagation/handling: change the enclosing function to return a Result (e.g.
Result<Response<Body>, E>) or map the builder error into an HTTP 500 response
instead of panicking. Specifically, handle failures from
Response::builder().header(header::CONTENT_TYPE,
mime).header(header::CACHE_CONTROL,
cache).body(Body::from(asset.data.into_owned())) by using ? (to propagate) or
.map_err(|e| /* convert to your error type or build a 500 Response */) so the
code no longer calls .unwrap() and either returns the error or a fallback
Response.
clash-dashboard/src/pages/Connections.tsx (2)

51-52: Mutations lack error handling and user feedback.

Both closeAllMutation and closeOneMutation don't have onError or onSuccess callbacks. Users won't see feedback if a close operation fails. Consider adding toast notifications or error states:

♻️ Add error/success callbacks
-const closeAllMutation = useMutation({ mutationFn: closeAllConnections });
-const closeOneMutation = useMutation({ mutationFn: closeConnection });
+const closeAllMutation = useMutation({
+  mutationFn: closeAllConnections,
+  onError: (err) => {
+    // Consider using a toast library or error state
+    console.error('Failed to close all connections:', err);
+  },
+});
+const closeOneMutation = useMutation({
+  mutationFn: closeConnection,
+  onError: (err) => {
+    console.error('Failed to close connection:', err);
+  },
+});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/pages/Connections.tsx` around lines 51 - 52, The
mutations closeAllMutation and closeOneMutation (wrapping closeAllConnections
and closeConnection) lack onError/onSuccess handlers, so add callbacks to each
useMutation call to surface user feedback and refresh state: implement onSuccess
to show a success toast (or set a success state) and invalidate or refetch the
connections query via queryClient.invalidateQueries(['connections']) or similar,
and implement onError to show an error toast with the error message and
optionally set an error state; update the useMutation calls for closeAllMutation
and closeOneMutation to include these onSuccess/onError callbacks.

59-66: "Close All" is a destructive action without confirmation.

Clicking "Close All" immediately terminates all connections without a confirmation dialog. For a destructive bulk action, consider adding a confirmation step to prevent accidental clicks.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/pages/Connections.tsx` around lines 59 - 66, The "Close
All" button calls closeAllMutation.mutate() directly and needs a confirmation
step; replace the inline onClick with a handler (e.g., confirmCloseAll or
handleCloseAll) in the Connections component that shows a confirmation dialog
(native window.confirm or your app modal component), and only calls
closeAllMutation.mutate() if the user confirms; ensure the handler is used by
the button and that the modal flow handles cancelation and prevents accidental
double submissions.
clash-dashboard/src/pages/Proxies.tsx (1)

119-146: Provider operations lack error feedback.

Similar to the Connections page, runHealthcheck and runUpdate don't provide user feedback on failure. Consider adding try/catch with error notification:

♻️ Add error handling
 async function runHealthcheck(provider: ProxyProvider) {
   setTestingProviders((s) => new Set(s).add(provider.name));
   setExpandedProviders((prev) => new Set(prev).add(provider.name));
   try {
     await healthcheckProvider(provider.name);
     await queryClient.invalidateQueries({ queryKey: ['providers'] });
+  } catch (err) {
+    console.error('Healthcheck failed:', err);
+    // Consider adding toast/notification
   } finally {
     setTestingProviders((s) => {
       const next = new Set(s);
       next.delete(provider.name);
       return next;
     });
   }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/pages/Proxies.tsx` around lines 119 - 146, Both
runHealthcheck and runUpdate lack error feedback to the user; wrap the async
work in try/catch so failures display a user-facing error notification.
Specifically, in runHealthcheck and runUpdate catch the thrown error from
healthcheckProvider/updateProxyProvider, call the app's existing
notification/error handler (e.g., toast/snackbar/notify) with a clear message
that includes provider.name and the error.message, then re-run
queryClient.invalidateQueries on success or keep it where it is; keep the
existing finally blocks that clear testing/updating state so cleanup still
happens. Locate the functions runHealthcheck and runUpdate to implement the
try/catch + notify behavior.
clash-dashboard/src/components/TrafficChart.tsx (1)

75-91: Consider debouncing ResizeObserver to avoid rapid chart rebuilds.

The ResizeObserver callback fires on every size change during window resize, triggering full chart destruction and recreation. For smoother performance, consider debouncing:

♻️ Example debounce implementation
+import { useEffect, useRef, useCallback } from 'react';
+
+// Add a simple debounce helper or use lodash.debounce
+function debounce<T extends (...args: unknown[]) => void>(fn: T, ms: number): T {
+  let timer: ReturnType<typeof setTimeout>;
+  return ((...args: unknown[]) => {
+    clearTimeout(timer);
+    timer = setTimeout(() => fn(...args), ms);
+  }) as T;
+}

 useEffect(() => {
   if (!containerRef.current) return;
   buildChart(containerRef.current.clientWidth);

+  const debouncedBuild = debounce((width: number) => buildChart(width), 100);
   const observer = new ResizeObserver((entries) => {
     const width = entries[0]?.contentRect.width;
-    if (width) buildChart(width);
+    if (width) debouncedBuild(width);
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/components/TrafficChart.tsx` around lines 75 - 91, The
ResizeObserver in the useEffect (observing containerRef) currently calls
buildChart on every size change which causes expensive destroy/recreate cycles;
debounce the observer callback by introducing a ref-based timer (e.g.,
resizeTimerRef) and only call buildChart(width) after a short delay (100–200ms)
with clearTimeout before scheduling to coalesce rapid events; ensure the initial
buildChart(containerRef.current.clientWidth) stays immediate and that you clear
the timer in the cleanup along with observer.disconnect() and
chartRef.current?.destroy().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@clash-dashboard/src/components/Layout.tsx`:
- Around line 44-45: The header text in Layout.tsx (the <span
className="font-semibold text-[15px]" ...> and the similar spans at lines noted)
hardcodes style={{ color: '#1d1d1f' }}, which prevents dark-mode theming; remove
the inline color and replace it with theme-aware classes (e.g., Tailwind text
utility plus dark: variant such as text-neutral-900 and dark:text-neutral-100 or
a semantic class like .brand-text with dark-mode rules) so the brand/nav/status
text responds to prefers-color-scheme and the rules in src/index.css.

In `@clash-dashboard/src/components/ui/button.tsx`:
- Around line 40-45: The Button component currently renders a plain <button>
which will default to type="submit" inside forms; update the Button function to
ensure a safe default by using the provided props.type when present and
otherwise setting type="button" (e.g., ensure Button uses props.type ?? 'button'
or extracts type from props before spreading). Modify the JSX in Button (the
button element inside function Button) to explicitly set that computed type
while still allowing callers to override via props.

In `@clash-dashboard/src/lib/api.ts`:
- Around line 222-223: The closeConnection function inserts the raw id into the
URL path which can break routes for IDs with reserved characters; update the
call that constructs the path in closeConnection to URL-encode the id (use
encodeURIComponent on the id) before passing it to request so the DELETE
endpoint becomes `/connections/${encodeURIComponent(id)}` and behaves
consistently with other encoded path params.
- Around line 170-174: The code currently only guards 204 and explicit
content-length: 0 but still calls response.json() which throws on empty 200
bodies; update the response handling (the block using response.json()) to first
read the raw body with await response.text(), return undefined if the text is
empty or only whitespace, otherwise parse and return JSON (e.g.,
JSON.parse(text) or JSON.parse after trimming) so empty successful responses
(including 200) don't cause an exception; keep the existing 204/content-length
checks or replace them with this text-based check in the same function where
response and response.json() are used.
- Around line 236-241: The getWsUrl function always appends the token with
'?token=...', which breaks if the provided path already contains query
parameters; update the token concatenation logic in getWsUrl (which calls
getApiUrl and getSecret and uses baseUrl, wsUrl, path, secret) to detect whether
path already contains a '?' and use '&token=...' in that case (otherwise use
'?token=...'), keeping encodeURIComponent(secret) and preserving existing
behavior when secret is empty.

In `@clash-dashboard/src/pages/Config.tsx`:
- Around line 154-162: Add onError handlers to the existing useMutation calls
for patchMutation and reloadMutation so failures surface to the user: update the
useMutation options for the mutationFn entries that call patchConfigs and
reloadConfigs to include an onError callback that logs the error and shows
user-visible feedback (e.g., call your toast/error UI or set an error state to
display inline). Keep the existing onSuccess behavior
(queryClient.invalidateQueries({ queryKey: ['configs'] })) and ensure the
onError receives the error and context so you can include actionable text (e.g.,
"Failed to save config" or "Failed to reload config") when invoking your toast
or inline error component.
- Around line 152-153: The component currently only reads const { data,
isLoading } = useQuery({ queryKey: ['configs'], queryFn: getConfigs }) and ends
up returning null when the fetch fails (cfg undefined); update the hook usage to
also destructure error and isError (e.g., const { data, isLoading, error,
isError } = useQuery(...)) and add an explicit early return rendering a clear
error state (a message or ErrorComponent) when isError is true (or error is set)
so the component (referencing cfg later) shows a failure UI instead of blank;
apply this same pattern wherever the component queries /configs (the other
useQuery occurrences around the cfg usage at the referenced blocks).
- Line 178: The reload button is passing an empty string into
reloadMutation.mutate(''), which becomes Some("") in reloadConfigs() and
triggers the backend path-handling branch that errors; fix by changing the
onClick so it does not pass an empty string — call reloadMutation.mutate() with
no argument (or supply a valid config path or send the config payload) so the
backend receives None instead of Some(""), or alternatively remove/disable the
button if reload is not intended; update the code referencing
reloadMutation.mutate and the reloadConfigs handler usage accordingly.

In `@clash-dashboard/src/pages/Logs.tsx`:
- Around line 98-105: The hover handlers are mutating the same paused state used
by the user's Pause/Resume toggle; introduce a separate hover state (e.g.,
hoverPaused or isHovering) and stop writing directly to paused in
onMouseEnter/onMouseLeave—have those setHoverPaused(true/false) instead, keep
the button toggling only the user-controlled state (e.g., userPaused via
setUserPaused), and derive the runtime behavior/UI from a computed
effectivePaused = userPaused || hoverPaused (use effectivePaused where paused
was read). Update references to paused in the component (Logs.tsx) to use
userPaused for the button label and effectivePaused for auto-scroll logic and
styling; change onMouseEnter/onMouseLeave to setHoverPaused and leave setPaused
only in the button onClick.

In `@clash-dashboard/src/pages/Overview.tsx`:
- Around line 76-79: The mutation allows overlapping writes causing race
conditions; fix by adding an optimistic update flow: in the useMutation for
modeMutation (and the similar mutation at lines 183-190) add onMutate that calls
queryClient.cancelQueries(['configs']), snapshots the previous configs via
queryClient.getQueryData(['configs']), and calls
queryClient.setQueryData(['configs'], draft => ({...draft, mode})); return the
snapshot as context; implement onError to rollback with
queryClient.setQueryData(['configs'], context.previous) and onSettled to
invalidate queries with queryClient.invalidateQueries({ queryKey: ['configs']
}); keep mutationFn as updateConfigs({ mode }). This ensures serial/optimistic
updates and prevents applied mode from reflecting out-of-order network
responses.

In `@clash-dashboard/src/pages/Rules.tsx`:
- Around line 17-18: The UI is treating query failures as an empty rules list;
update the component to surface query errors instead of mapping failures to "no
matches": use the useQuery return values (data, isLoading, isError, error) from
the call that uses getRules and change the logic that computes rules (the
current mapping around lines 51-61) so it only derives an empty array when data
is defined and filtering yields no results; when isError is true show an error
state/message (or propagate error) and when isLoading show a loader—do not
coerce undefined data into [].
- Around line 20-25: The filter callback used to compute filtered (rules.filter)
calls r.payload.toLowerCase() unconditionally and will throw if payload is
undefined; update the predicate to guard payload (e.g., use optional chaining or
a default empty string for r.payload) before calling toLowerCase() — similarly
consider normalizing r.type and r.proxy with toLowerCase() only after ensuring
they exist (or defaulting to '') so the filter never invokes toLowerCase on
undefined.

In `@clash-dashboard/src/pages/Settings.tsx`:
- Around line 33-35: handleSave currently calls setSecret which persists the
admin token via the settings module (setSecret/getSecret) into localStorage;
remove that persistence by stopping the call to setSecret from handleSave and
instead keep the secret only in component state (or introduce an explicit
"persist token" opt-in flag and only call setSecret when that flag is true).
Locate handleSave and the usages of setSecret/getSecret and refactor so the
token is session-scoped (in-memory React state) by default; if persistence is
required, add an explicit checkbox/setting that must be true before calling
setSecret to write to storage.

In `@clash-dashboard/vite.config.ts`:
- Around line 12-15: The alias currently uses __dirname (resolve.alias ->
path.resolve(__dirname, './src')), which fails in ESM; replace it by deriving a
dirname from import.meta.url (e.g., import { fileURLToPath } from 'url' and
const __dirname = path.dirname(fileURLToPath(import.meta.url'))) and then use
path.resolve(__dirname, './src') (or use path.resolve(fileURLToPath(new
URL('./src', import.meta.url)))) so the resolve.alias entry no longer references
the undefined __dirname in ESM.

In `@clash-lib/build.rs`:
- Around line 66-71: The build script's input-watch array (the for loop
iterating over the string array containing "index.html", "vite.config.ts",
"package.json", "tsconfig.json") is missing "tsconfig.app.json", so the
dashboard UI build isn't retriggered when that file changes; add
"tsconfig.app.json" to that same array literal (the one being iterated by file)
so the script emits a rerun trigger for it alongside the other UI inputs.

---

Nitpick comments:
In `@clash-dashboard/src/components/TrafficChart.tsx`:
- Around line 75-91: The ResizeObserver in the useEffect (observing
containerRef) currently calls buildChart on every size change which causes
expensive destroy/recreate cycles; debounce the observer callback by introducing
a ref-based timer (e.g., resizeTimerRef) and only call buildChart(width) after a
short delay (100–200ms) with clearTimeout before scheduling to coalesce rapid
events; ensure the initial buildChart(containerRef.current.clientWidth) stays
immediate and that you clear the timer in the cleanup along with
observer.disconnect() and chartRef.current?.destroy().

In `@clash-dashboard/src/components/ui/badge.tsx`:
- Around line 29-36: The Badge component duplicates style composition; update
Badge to call the existing badgeVariants helper instead of manually combining
variantStyles and className: in the Badge function (keeping the signature and
default variant), compute the span's className by invoking badgeVariants({
variant, className }) (or the correct badgeVariants API used in the repo) and
remove the explicit variantStyles lookup, then keep spreading ...props onto the
span; ensure badgeVariants is imported where Badge is defined.

In `@clash-dashboard/src/components/ui/button.tsx`:
- Around line 4-38: The variantStyles and sizeStyles objects are typed as
Record<string,string>, which widens their keys and loses literal union types;
make them const-typed so their keys are preserved (e.g., declare variantStyles
and sizeStyles as plain object literals with "as const" or inferred const
types), then use keyof typeof variantStyles and keyof typeof sizeStyles for the
buttonVariants parameters and ButtonProps variant/size types; update the
buttonVariants signature (and its default param types) to reference those keyof
types so TypeScript enforces the exact allowed keys while leaving the runtime
values unchanged (refer to variantStyles, sizeStyles, buttonVariants, and
ButtonProps).

In `@clash-dashboard/src/pages/Connections.tsx`:
- Around line 51-52: The mutations closeAllMutation and closeOneMutation
(wrapping closeAllConnections and closeConnection) lack onError/onSuccess
handlers, so add callbacks to each useMutation call to surface user feedback and
refresh state: implement onSuccess to show a success toast (or set a success
state) and invalidate or refetch the connections query via
queryClient.invalidateQueries(['connections']) or similar, and implement onError
to show an error toast with the error message and optionally set an error state;
update the useMutation calls for closeAllMutation and closeOneMutation to
include these onSuccess/onError callbacks.
- Around line 59-66: The "Close All" button calls closeAllMutation.mutate()
directly and needs a confirmation step; replace the inline onClick with a
handler (e.g., confirmCloseAll or handleCloseAll) in the Connections component
that shows a confirmation dialog (native window.confirm or your app modal
component), and only calls closeAllMutation.mutate() if the user confirms;
ensure the handler is used by the button and that the modal flow handles
cancelation and prevents accidental double submissions.

In `@clash-dashboard/src/pages/Proxies.tsx`:
- Around line 119-146: Both runHealthcheck and runUpdate lack error feedback to
the user; wrap the async work in try/catch so failures display a user-facing
error notification. Specifically, in runHealthcheck and runUpdate catch the
thrown error from healthcheckProvider/updateProxyProvider, call the app's
existing notification/error handler (e.g., toast/snackbar/notify) with a clear
message that includes provider.name and the error.message, then re-run
queryClient.invalidateQueries on success or keep it where it is; keep the
existing finally blocks that clear testing/updating state so cleanup still
happens. Locate the functions runHealthcheck and runUpdate to implement the
try/catch + notify behavior.

In `@clash-lib/src/app/api/embedded_dashboard.rs`:
- Around line 60-76: The content_type function's MIME mapping is missing a few
common extensions; update the match in content_type(path: &str) to handle "webp"
-> "image/webp", "gif" -> "image/gif", and "map" -> "application/json" (or
"application/json; charset=utf-8" if you prefer consistency), keeping the
existing default branch intact and using the same pattern of matching
path.rsplit('.').next() as in the current function.
- Around line 39-43: Replace the final .unwrap() on the Response builder with
proper error propagation/handling: change the enclosing function to return a
Result (e.g. Result<Response<Body>, E>) or map the builder error into an HTTP
500 response instead of panicking. Specifically, handle failures from
Response::builder().header(header::CONTENT_TYPE,
mime).header(header::CACHE_CONTROL,
cache).body(Body::from(asset.data.into_owned())) by using ? (to propagate) or
.map_err(|e| /* convert to your error type or build a 500 Response */) so the
code no longer calls .unwrap() and either returns the error or a fallback
Response.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8eacdd42-45a2-4adf-bee5-23a2f77ba32e

📥 Commits

Reviewing files that changed from the base of the PR and between a3d9b8b and 9b8e582.

⛔ Files ignored due to path filters (7)
  • Cargo.lock is excluded by !**/*.lock
  • clash-dashboard/package-lock.json is excluded by !**/package-lock.json
  • clash-dashboard/public/favicon.svg is excluded by !**/*.svg
  • clash-dashboard/public/icons.svg is excluded by !**/*.svg
  • clash-dashboard/src/assets/hero.png is excluded by !**/*.png
  • clash-dashboard/src/assets/react.svg is excluded by !**/*.svg
  • clash-dashboard/src/assets/vite.svg is excluded by !**/*.svg
📒 Files selected for processing (41)
  • clash-dashboard/.gitignore
  • clash-dashboard/README.md
  • clash-dashboard/components.json
  • clash-dashboard/eslint.config.js
  • clash-dashboard/index.html
  • clash-dashboard/package.json
  • clash-dashboard/src/App.tsx
  • clash-dashboard/src/components/Layout.tsx
  • clash-dashboard/src/components/TrafficChart.tsx
  • clash-dashboard/src/components/ui/badge.tsx
  • clash-dashboard/src/components/ui/button.tsx
  • clash-dashboard/src/components/ui/card.tsx
  • clash-dashboard/src/components/ui/scroll-area.tsx
  • clash-dashboard/src/components/ui/separator.tsx
  • clash-dashboard/src/components/ui/table.tsx
  • clash-dashboard/src/components/ui/tabs.tsx
  • clash-dashboard/src/components/ui/tooltip.tsx
  • clash-dashboard/src/hooks/useTraffic.ts
  • clash-dashboard/src/hooks/useWebSocket.ts
  • clash-dashboard/src/index.css
  • clash-dashboard/src/lib/api.ts
  • clash-dashboard/src/lib/settings.ts
  • clash-dashboard/src/lib/utils.ts
  • clash-dashboard/src/main.tsx
  • clash-dashboard/src/pages/Config.tsx
  • clash-dashboard/src/pages/Connections.tsx
  • clash-dashboard/src/pages/DNS.tsx
  • clash-dashboard/src/pages/Logs.tsx
  • clash-dashboard/src/pages/Overview.tsx
  • clash-dashboard/src/pages/Proxies.tsx
  • clash-dashboard/src/pages/Rules.tsx
  • clash-dashboard/src/pages/Settings.tsx
  • clash-dashboard/tsconfig.app.json
  • clash-dashboard/tsconfig.json
  • clash-dashboard/tsconfig.node.json
  • clash-dashboard/vite.config.ts
  • clash-lib/Cargo.toml
  • clash-lib/build.rs
  • clash-lib/src/app/api/embedded_dashboard.rs
  • clash-lib/src/app/api/mod.rs
  • clash-lib/src/app/api/runner.rs

Comment on lines +44 to +45
<span className="font-semibold text-[15px]" style={{ color: '#1d1d1f' }}>
clash-rs
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

These inline text colors defeat dark mode in the shell.

The header/nav/status text is pinned to the light palette here, so the new prefers-color-scheme: dark styling in src/index.css doesn't actually theme the shell. In dark mode the brand and nav labels end up low-contrast or effectively invisible.

Also applies to: 56-60, 83-85

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/components/Layout.tsx` around lines 44 - 45, The header
text in Layout.tsx (the <span className="font-semibold text-[15px]" ...> and the
similar spans at lines noted) hardcodes style={{ color: '#1d1d1f' }}, which
prevents dark-mode theming; remove the inline color and replace it with
theme-aware classes (e.g., Tailwind text utility plus dark: variant such as
text-neutral-900 and dark:text-neutral-100 or a semantic class like .brand-text
with dark-mode rules) so the brand/nav/status text responds to
prefers-color-scheme and the rules in src/index.css.

Comment thread clash-dashboard/src/components/ui/button.tsx Outdated
Comment thread clash-dashboard/src/lib/api.ts Outdated
Comment thread clash-dashboard/src/lib/api.ts Outdated
Comment thread clash-dashboard/src/lib/api.ts Outdated
Comment thread clash-dashboard/src/pages/Rules.tsx Outdated
Comment thread clash-dashboard/src/pages/Rules.tsx
Comment on lines +33 to +35
function handleSave() {
setApiUrl(apiUrl);
setSecret(secret);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't persist the API secret in localStorage.

handleSave() calls setSecret(), and src/lib/settings.ts:1-4 backs that with localStorage. That makes the Clash admin token long-lived and readable by any script running on this origin. Please keep it session-scoped/in-memory, or at least gate persistence behind an explicit opt-in.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/pages/Settings.tsx` around lines 33 - 35, handleSave
currently calls setSecret which persists the admin token via the settings module
(setSecret/getSecret) into localStorage; remove that persistence by stopping the
call to setSecret from handleSave and instead keep the secret only in component
state (or introduce an explicit "persist token" opt-in flag and only call
setSecret when that flag is true). Locate handleSave and the usages of
setSecret/getSecret and refactor so the token is session-scoped (in-memory React
state) by default; if persistence is required, add an explicit checkbox/setting
that must be true before calling setSecret to write to storage.

Comment thread clash-dashboard/vite.config.ts
Comment thread clash-lib/build.rs
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 27, 2026

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 27, 2026

📊 Proxy Throughput Results

Transport Payload Upload (Mbps) Download (Mbps)
ss-obfs-http 32 MB 11635.8 10315.3
ss-obfs-tls 32 MB 16365.2 13510.8
ss-plain 32 MB 14094.4 11966.4
ss-shadow-tls-v3 32 MB 12739.6 8278.8
ss-v2ray-plugin-ws-tls 32 MB 14443.7 13811.9

Tests ran 5 variant(s) in parallel; each direction transfers the full payload.

Full test log

Download the throughput-results artifact for the full log.

@ibigbug ibigbug changed the title Feat/builtin dashboard feat: built-in Apple-style web dashboard Apr 27, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
clash-lib/src/app/api/handlers/config.rs (1)

306-358: ⚠️ Potential issue | 🟠 Major

Bug: bind_address change combined with port change may use incorrect restart method.

When both bind_address and a port field are changed in the same PATCH request, port_changed will be true (from change_ports), causing restart_idle() to be called instead of the required full restart(). However, bind_address changes require a full restart since the new address must apply to all listeners, not just port-changed ones.

Suggested fix
     if let Some(bind_address) = payload.bind_address.clone() {
         match bind_address.parse::<BindAddress>() {
             Ok(bind_address) => {
                 inbound_manager.set_bind_address(bind_address).await;
                 need_restart = true;
+                // bind_address affects all listeners — force full restart
+                // even if ports also changed.
             }
             Err(_) => {
                 return (
                     StatusCode::BAD_REQUEST,
                     format!("invalid bind address: {bind_address}"),
                 )
                     .into_response();
             }
         }
     }

     let mut port_changed = false;
-    if payload.rebuild_listeners() {
+    // Only track port_changed when bind_address wasn't also changed,
+    // since bind_address requires a full restart regardless.
+    if payload.rebuild_listeners() && payload.bind_address.is_none() {
         let ports = Ports {

Alternatively, reset port_changed after the bind_address block similar to the allow_lan handling.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-lib/src/app/api/handlers/config.rs` around lines 306 - 358, When
handling payload.bind_address in the PATCH handler, setting the new bind address
via inbound_manager.set_bind_address(...) requires a full restart, but the
current flow can leave port_changed true (set by
inbound_manager.change_ports(...)) which causes restart_idle() to be used; after
applying the bind address (the block that calls
inbound_manager.set_bind_address), clear or set port_changed = false so the
later restart branch will call inbound_manager.restart() instead of
inbound_manager.restart_idle(); mirror the allow_lan handling where port_changed
is reset to force a full restart.
♻️ Duplicate comments (1)
clash-dashboard/src/components/Layout.tsx (1)

38-55: ⚠️ Potential issue | 🟠 Major

The shell still hardcodes light-theme text colors.

These inline/light-palette styles override the dark-mode rules from src/index.css, so the brand, nav, and status text stay low-contrast in dark mode.

Also applies to: 75-77

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/components/Layout.tsx` around lines 38 - 55, The span in
Layout.tsx and the NavLink class strings are hardcoding light-mode hex colors
(e.g. style={{ color: '#1d1d1f' }}, 'text-[`#0071e3`]', 'text-[`#6e6e73`]',
'hover:text-[`#1d1d1f`]') which override dark-mode rules; remove the inline style
and replace those hardcoded hex classes with theme-aware CSS (use existing
index.css dark-mode variables/classes or Tailwind dark: variants such as
neutral/text color classes or CSS custom properties) so the brand, nav and
status text inherit proper light/dark colors; update the NavLink mapping and the
span in Layout.tsx (and the similar occurrences around the status block) to use
the theme-aware classes instead of hex values.
🧹 Nitpick comments (7)
clash-dashboard/src/pages/Proxies.tsx (1)

358-476: Significant code duplication with Providers.tsx.

The provider cards section (lines 358-476) largely duplicates the rendering logic from Providers.tsx. Consider extracting a shared ProviderCard component to reduce duplication and ensure consistent behavior.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/pages/Proxies.tsx` around lines 358 - 476, The provider
card rendering in Proxies.tsx is duplicated from Providers.tsx; extract a
reusable ProviderCard component that accepts props like provider, isExpanded,
isTesting, isUpdating, latencyMap, toggleProviderExpanded, runHealthcheck,
runUpdate, getLastDelay and getLatencyColor, move the JSX block that starts with
the Providers map (the card container, header buttons, expansion logic and proxy
grid) into that component, and replace the inline map in Proxies.tsx with a call
to <ProviderCard ...> passing the needed handlers/state (use existing
identifiers toggleProviderExpanded, runHealthcheck, runUpdate, latencyMap,
getLastDelay, getLatencyColor and
expandedProviders/testingProviders/updatingProviders to compute booleans).
Ensure ProviderCard emits the same events and preserves keys
(proxy.name/provider.name) and styles so behavior remains identical.
clash-dashboard/src/components/ProxyGroups.tsx (1)

7-16: Consider extracting shared utility functions.

getLatencyColor, getLastDelay, and similar helpers are duplicated across ProxyGroups.tsx, Providers.tsx, and Proxies.tsx. Consider extracting them to a shared utilities file.

Also applies to: 34-37

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/components/ProxyGroups.tsx` around lines 7 - 16,
Duplicate helper functions like getLatencyColor and getLastDelay are present in
ProxyGroups.tsx, Providers.tsx and Proxies.tsx; extract these into a single
shared utility module (e.g., export functions getLatencyColor, getLastDelay, and
any other repeated helpers) and replace the local definitions with imports from
that module in ProxyGroups.tsx, Providers.tsx and Proxies.tsx so the components
use the centralized implementations; ensure exported function names match
(getLatencyColor, getLastDelay) and update all import sites accordingly,
removing the duplicated code blocks from each file.
clash-dashboard/src/pages/Providers.tsx (1)

117-172: Move RuleProviderRulesPanel outside the parent component.

Defining RuleProviderRulesPanel inside Providers recreates the component function on every render of Providers, causing React Query to re-mount and re-fetch unnecessarily when parent state changes (e.g., when expanding/collapsing other providers).

♻️ Proposed fix

Move the component definition outside Providers:

+function RuleProviderRulesPanel({ name, behavior }: { name: string; behavior?: string }) {
+  const { data, isLoading, isError } = useQuery({
+    queryKey: ['rule-provider-rules', name],
+    queryFn: () => getRuleProviderRules(name),
+  });
+  // ... rest of component
+}

 export function Providers() {
   // ...
-  function RuleProviderRulesPanel({ name, behavior }: { name: string; behavior?: string }) {
-    // ...
-  }
   // ...
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/pages/Providers.tsx` around lines 117 - 172, The
RuleProviderRulesPanel function is currently declared inside the Providers
component causing it to be recreated on every render and triggering unnecessary
React Query remounts; move the entire RuleProviderRulesPanel definition to
module scope (outside the Providers component) so it's defined once, keep its
signature RuleProviderRulesPanel({ name, behavior }: { name: string; behavior?:
string }) unchanged, ensure any values it used from Providers are passed in as
props (e.g., name and behavior) and that useQuery remains using
['rule-provider-rules', name] as its queryKey; after moving, verify
imports/types still resolve and that Providers simply renders
<RuleProviderRulesPanel name={...} behavior={...} />.
clash-dashboard/src/pages/Overview.tsx (2)

87-100: Add type="button" to prevent accidental form submission.

The ToggleSwitch button lacks an explicit type attribute. If this component is ever used inside a <form>, it will default to type="submit" and could trigger unintended form submissions.

Suggested fix
   return (
     <button
+      type="button"
       onClick={() => onChange(!value)}
       className="w-10 h-6 rounded-full transition-colors flex-shrink-0 relative"
       style={{ background: value ? '#34c759' : 'rgba(0,0,0,0.15)' }}
     >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/pages/Overview.tsx` around lines 87 - 100, The
ToggleSwitch component's button defaults to type="submit" inside forms which can
cause accidental form submissions; update the button in function ToggleSwitch to
include an explicit type="button" attribute so clicking the switch never submits
a form (locate the button element inside ToggleSwitch and add type="button").

175-185: Consider debouncing config patches.

Each input field triggers an immediate patchConfigs API call on blur. If a user quickly tabs through multiple fields, this could cause several sequential listener restarts on the backend. Consider batching changes or adding a "Save" button for the config section.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/pages/Overview.tsx` around lines 175 - 185, Multiple
rapid onBlur calls are causing immediate patchConfigs calls; change the patch
behavior to batch/debounce updates instead of mutating on every field blur:
either (A) buffer incoming PatchableConfig objects in local component state
(e.g., an editsRef or edits state) and start/refresh a debounce timer in the
patch(fields) function that calls patchMutation.mutate(combinedEdits) only after
a short delay, or (B) switch to a controlled edit form UI that writes to local
state and add an explicit "Save" button that calls patchMutation.mutate once
with the accumulated changes; update references to patchMutation, patch(fields),
and patchConfigs (or alternatively use reloadMutation/reloadConfigs if you need
to explicitly refresh) so only the debounced/batched payload is sent to the API.
clash-lib/src/app/inbound/manager.rs (1)

614-624: Port change detection extracts listeners even when port value is unchanged.

The extract_if predicate checks ports.port.is_some() rather than comparing against the current port value. This means a PATCH request with port: 7890 will restart the HTTP listener even if it's already on port 7890.

This is a minor inefficiency rather than a bug. Consider comparing against the current port to avoid unnecessary restarts:

Example for HTTP listener
-InboundOpts::Http { .. } => ports.port.is_some(),
+InboundOpts::Http { common_opts } => {
+    ports.port.is_some_and(|p| p != common_opts.port)
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-lib/src/app/inbound/manager.rs` around lines 614 - 624, The extract_if
predicate in the port-change detection uses only ports.port.is_some() (for
InboundOpts::Http, InboundOpts::Socks, InboundOpts::Mixed, etc.) which causes
listeners to be selected even when the port value is unchanged; update the
predicate used by extract_if to compare the incoming port value against the
currently active listener port (e.g., check ports.port ==
Some(current_http_port) for InboundOpts::Http) so only entries whose port
actually changed are extracted—locate the current listener port from the active
listener/state (listener map or struct used to track running listeners) and
perform equality comparison for each variant (ports.port, ports.socks_port,
ports.mixed_port, tproxy_port, redir_port) before returning true.
clash-dashboard/src/pages/Connections.tsx (1)

52-53: Consider adding error handling for mutations.

The close mutations don't provide user feedback on failure. Consider adding onError callbacks to show a toast/alert if closing connections fails.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/pages/Connections.tsx` around lines 52 - 53, The
closeAllMutation and closeOneMutation created with useMutation({ mutationFn:
closeAllConnections }) and useMutation({ mutationFn: closeConnection }) lack
error handling and user feedback; update these two mutations to pass onError
callbacks to useMutation that display a toast/alert (or set error state) when
the mutation fails, include the mutation name in the message for clarity, and
optionally use onSuccess to clear errors or show success feedback.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@clash-dashboard/src/components/Layout.tsx`:
- Around line 45-60: The nav icon links in the Layout component render only an
icon on small screens and lack accessible names; update the NavLink created in
the navItems.map callback (the JSX that maps to <NavLink key={to} ...>) to
include an aria-label prop set to the link label (aria-label={label}) so screen
readers receive the visible label even when the <span> is hidden; ensure you add
the aria-label on the NavLink element that wraps <Icon size={14} /> and the
hidden <span>.

In `@clash-dashboard/src/lib/api.ts`:
- Around line 222-229: The RuleProvider interface is missing the
backend-returned type field; update the RuleProvider definition to include a
type: string property (similar to ProxyProvider) so it matches the Rust
backend's as_map() response and other provider interfaces; modify the
RuleProvider interface declaration to add type: string alongside name,
vehicleType, etc.

In `@clash-dashboard/src/pages/Logs.tsx`:
- Around line 75-110: The toolbar in Logs.tsx is using hardcoded colors (e.g.
the h1 inline style color '#1d1d1f', button inactive color '#6e6e73',
LEVEL_ACTIVE_STYLE entries, and translucent black backgrounds on the segmented
control and separator) which break dark-theme contrast; update Logs.tsx to use
the theme-aware CSS variables/classes used by the shell header instead of
literal hex/rgba values: replace the h1 inline style, the per-button inline
style branch in the LEVELS map, the segmented-control background, the separator
background, and the Pause/Resume and Clear button inline styles to reference
existing theme CSS variables or utility classes (or add new variables in
src/index.css if needed) so colors switch correctly with the dark theme (refer
to LEVELS, LEVEL_ACTIVE_STYLE, setLevel, setPaused, and the top-level h1 in this
file to locate spots to change).
- Around line 54-59: The buffered history is being stamped with client reconnect
time because useEffect (the block updating setLogs with lastMessage) uses new
Date() instead of the event's original timestamp; update the logic in the effect
that appends logs so it uses the timestamp carried by the LogEntry/LogEvent
payload (e.g., lastMessage.ts, lastMessage.timestamp, or lastMessage.time) and
format that for display (fall back to the existing toLocaleTimeString only if
the event timestamp is missing); keep the id generation via logId and preserve
the slice(-500) retention behavior.

In `@clash-dashboard/src/pages/Proxies.tsx`:
- Around line 171-184: runHealthcheck currently only calls
healthcheckProvider(provider.name) causing inconsistent behavior with
Providers.tsx which calls getProxyDelay for each proxy; update runHealthcheck to
perform the same per-proxy delay checks (call getProxyDelay for each proxy in
provider.proxies, e.g., await Promise.allSettled(provider.proxies.map(p =>
getProxyDelay(p.name))) with individual try/catch handling) before calling
healthcheckProvider(provider.name), or alternatively rename/run a different
"quick" vs "full" test to make the distinction explicit; touch the
runHealthcheck function and use the same helper getProxyDelay and
healthcheckProvider symbols so behavior matches or is intentionally different.

In `@clash-lib/build.rs`:
- Around line 47-61: The build script's build_dashboard() currently treats
missing ../clash-dashboard or npm as fatal (it checks CARGO_FEATURE_DASHBOARD,
calls PathBuf::canonicalize which errors, then runs npm), making default builds
fail; change build_dashboard() so that if canonicalize() for the dashboard_dir
fails it logs a warning and returns Ok(()) instead of mapping to an anyhow
error, and before invoking npm (the place that runs "npm run build") check for
npm availability (e.g., attempt to spawn "npm --version" or use which) and if
npm is missing or the spawn fails, log a non-fatal warning and return Ok(()),
ensuring CARGO_FEATURE_DASHBOARD remains respected but missing dashboard/npm
produce a silent fallback rather than aborting the whole build.

In `@clash-lib/src/app/api/handlers/provider.rs`:
- Around line 281-284: The handler in provider.rs currently calls
p.list_rules(500) which silently truncates results; change the API to either
accept explicit paging/query parameters (e.g. limit and offset/page) and pass
them into list_rules (or the provider's paging API), or keep the full list but
return truncation metadata (e.g. total_count, limit, truncated: true) alongside
"rules" so callers know results were cut; update the request handling in the
provider route to parse query params (limit/offset) and return a JSON object
containing rules plus explicit metadata (total/limit/offset/truncated) instead
of hardcoding 500.

In `@clash-lib/src/app/api/websocket.rs`:
- Around line 143-156: Subscribe to the broadcast channel before replaying
buffered logs: call state.log_source_tx.subscribe() first (assign to rx), then
take the snapshot of state.recent_logs (lock state.recent_logs and collect
history) and send the buffered messages; while sending the history, drain rx for
any concurrent events and deduplicate by sequence/timestamp (compare each
incoming event from rx against the last sequence/timestamp in the snapshot) so
you don't miss or double-send messages; update the logic around
state.recent_logs.lock(), state.log_source_tx.subscribe(), and socket.send to
perform subscribe-first + sequence-based dedupe/merge.

---

Outside diff comments:
In `@clash-lib/src/app/api/handlers/config.rs`:
- Around line 306-358: When handling payload.bind_address in the PATCH handler,
setting the new bind address via inbound_manager.set_bind_address(...) requires
a full restart, but the current flow can leave port_changed true (set by
inbound_manager.change_ports(...)) which causes restart_idle() to be used; after
applying the bind address (the block that calls
inbound_manager.set_bind_address), clear or set port_changed = false so the
later restart branch will call inbound_manager.restart() instead of
inbound_manager.restart_idle(); mirror the allow_lan handling where port_changed
is reset to force a full restart.

---

Duplicate comments:
In `@clash-dashboard/src/components/Layout.tsx`:
- Around line 38-55: The span in Layout.tsx and the NavLink class strings are
hardcoding light-mode hex colors (e.g. style={{ color: '#1d1d1f' }},
'text-[`#0071e3`]', 'text-[`#6e6e73`]', 'hover:text-[`#1d1d1f`]') which override
dark-mode rules; remove the inline style and replace those hardcoded hex classes
with theme-aware CSS (use existing index.css dark-mode variables/classes or
Tailwind dark: variants such as neutral/text color classes or CSS custom
properties) so the brand, nav and status text inherit proper light/dark colors;
update the NavLink mapping and the span in Layout.tsx (and the similar
occurrences around the status block) to use the theme-aware classes instead of
hex values.

---

Nitpick comments:
In `@clash-dashboard/src/components/ProxyGroups.tsx`:
- Around line 7-16: Duplicate helper functions like getLatencyColor and
getLastDelay are present in ProxyGroups.tsx, Providers.tsx and Proxies.tsx;
extract these into a single shared utility module (e.g., export functions
getLatencyColor, getLastDelay, and any other repeated helpers) and replace the
local definitions with imports from that module in ProxyGroups.tsx,
Providers.tsx and Proxies.tsx so the components use the centralized
implementations; ensure exported function names match (getLatencyColor,
getLastDelay) and update all import sites accordingly, removing the duplicated
code blocks from each file.

In `@clash-dashboard/src/pages/Connections.tsx`:
- Around line 52-53: The closeAllMutation and closeOneMutation created with
useMutation({ mutationFn: closeAllConnections }) and useMutation({ mutationFn:
closeConnection }) lack error handling and user feedback; update these two
mutations to pass onError callbacks to useMutation that display a toast/alert
(or set error state) when the mutation fails, include the mutation name in the
message for clarity, and optionally use onSuccess to clear errors or show
success feedback.

In `@clash-dashboard/src/pages/Overview.tsx`:
- Around line 87-100: The ToggleSwitch component's button defaults to
type="submit" inside forms which can cause accidental form submissions; update
the button in function ToggleSwitch to include an explicit type="button"
attribute so clicking the switch never submits a form (locate the button element
inside ToggleSwitch and add type="button").
- Around line 175-185: Multiple rapid onBlur calls are causing immediate
patchConfigs calls; change the patch behavior to batch/debounce updates instead
of mutating on every field blur: either (A) buffer incoming PatchableConfig
objects in local component state (e.g., an editsRef or edits state) and
start/refresh a debounce timer in the patch(fields) function that calls
patchMutation.mutate(combinedEdits) only after a short delay, or (B) switch to a
controlled edit form UI that writes to local state and add an explicit "Save"
button that calls patchMutation.mutate once with the accumulated changes; update
references to patchMutation, patch(fields), and patchConfigs (or alternatively
use reloadMutation/reloadConfigs if you need to explicitly refresh) so only the
debounced/batched payload is sent to the API.

In `@clash-dashboard/src/pages/Providers.tsx`:
- Around line 117-172: The RuleProviderRulesPanel function is currently declared
inside the Providers component causing it to be recreated on every render and
triggering unnecessary React Query remounts; move the entire
RuleProviderRulesPanel definition to module scope (outside the Providers
component) so it's defined once, keep its signature RuleProviderRulesPanel({
name, behavior }: { name: string; behavior?: string }) unchanged, ensure any
values it used from Providers are passed in as props (e.g., name and behavior)
and that useQuery remains using ['rule-provider-rules', name] as its queryKey;
after moving, verify imports/types still resolve and that Providers simply
renders <RuleProviderRulesPanel name={...} behavior={...} />.

In `@clash-dashboard/src/pages/Proxies.tsx`:
- Around line 358-476: The provider card rendering in Proxies.tsx is duplicated
from Providers.tsx; extract a reusable ProviderCard component that accepts props
like provider, isExpanded, isTesting, isUpdating, latencyMap,
toggleProviderExpanded, runHealthcheck, runUpdate, getLastDelay and
getLatencyColor, move the JSX block that starts with the Providers map (the card
container, header buttons, expansion logic and proxy grid) into that component,
and replace the inline map in Proxies.tsx with a call to <ProviderCard ...>
passing the needed handlers/state (use existing identifiers
toggleProviderExpanded, runHealthcheck, runUpdate, latencyMap, getLastDelay,
getLatencyColor and expandedProviders/testingProviders/updatingProviders to
compute booleans). Ensure ProviderCard emits the same events and preserves keys
(proxy.name/provider.name) and styles so behavior remains identical.

In `@clash-lib/src/app/inbound/manager.rs`:
- Around line 614-624: The extract_if predicate in the port-change detection
uses only ports.port.is_some() (for InboundOpts::Http, InboundOpts::Socks,
InboundOpts::Mixed, etc.) which causes listeners to be selected even when the
port value is unchanged; update the predicate used by extract_if to compare the
incoming port value against the currently active listener port (e.g., check
ports.port == Some(current_http_port) for InboundOpts::Http) so only entries
whose port actually changed are extracted—locate the current listener port from
the active listener/state (listener map or struct used to track running
listeners) and perform equality comparison for each variant (ports.port,
ports.socks_port, ports.mixed_port, tproxy_port, redir_port) before returning
true.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 6267140a-4f6b-46ca-b9db-8028bf883b29

📥 Commits

Reviewing files that changed from the base of the PR and between 9b8e582 and 97f87de.

⛔ Files ignored due to path filters (13)
  • clash-bin/tests/data/config/public/apple-touch-icon-precomposed.png is excluded by !**/*.png
  • clash-bin/tests/data/config/public/assets/inter-latin-400-normal.0364d368.woff2 is excluded by !**/*.woff2
  • clash-bin/tests/data/config/public/assets/inter-latin-400-normal.3ea830d4.woff is excluded by !**/*.woff
  • clash-bin/tests/data/config/public/assets/inter-latin-800-normal.a51ac27d.woff2 is excluded by !**/*.woff2
  • clash-bin/tests/data/config/public/assets/inter-latin-800-normal.d08d7178.woff is excluded by !**/*.woff
  • clash-bin/tests/data/config/public/assets/roboto-mono-latin-400-normal.7295944e.woff2 is excluded by !**/*.woff2
  • clash-bin/tests/data/config/public/assets/roboto-mono-latin-400-normal.dffdffa7.woff is excluded by !**/*.woff
  • clash-bin/tests/data/config/public/yacd-128.png is excluded by !**/*.png
  • clash-bin/tests/data/config/public/yacd-64.png is excluded by !**/*.png
  • clash-bin/tests/data/config/public/yacd.ico is excluded by !**/*.ico
  • clash-dashboard/package-lock.json is excluded by !**/package-lock.json
  • clash-dashboard/public/logo.png is excluded by !**/*.png
  • clash-dashboard/src/assets/logo.png is excluded by !**/*.png
📒 Files selected for processing (67)
  • clash-bin/Cargo.toml
  • clash-bin/tests/data/config/public/CNAME
  • clash-bin/tests/data/config/public/_headers
  • clash-bin/tests/data/config/public/assets/Config.39d8d2ef.css
  • clash-bin/tests/data/config/public/assets/Config.c09e8dbe.js
  • clash-bin/tests/data/config/public/assets/Connections.e48eac36.js
  • clash-bin/tests/data/config/public/assets/Connections.fb8ea59b.css
  • clash-bin/tests/data/config/public/assets/Fab.a0a7e573.css
  • clash-bin/tests/data/config/public/assets/Fab.ef67ff10.js
  • clash-bin/tests/data/config/public/assets/Logs.4b8e75d1.css
  • clash-bin/tests/data/config/public/assets/Logs.ac990610.js
  • clash-bin/tests/data/config/public/assets/Proxies.16b46af4.js
  • clash-bin/tests/data/config/public/assets/Proxies.3fa3509d.css
  • clash-bin/tests/data/config/public/assets/Rules.70e6962f.js
  • clash-bin/tests/data/config/public/assets/Rules.e03c54a8.css
  • clash-bin/tests/data/config/public/assets/Select.1e55eba1.css
  • clash-bin/tests/data/config/public/assets/Select.6c389032.js
  • clash-bin/tests/data/config/public/assets/TextFitler.61537a57.js
  • clash-bin/tests/data/config/public/assets/TextFitler.b21c0577.css
  • clash-bin/tests/data/config/public/assets/chart-lib.a8ad03fd.js
  • clash-bin/tests/data/config/public/assets/chevron-down.dd238e96.js
  • clash-bin/tests/data/config/public/assets/debounce.c2d20996.js
  • clash-bin/tests/data/config/public/assets/en.fb34eaf7.js
  • clash-bin/tests/data/config/public/assets/index.171f553a.js
  • clash-bin/tests/data/config/public/assets/index.8bb012c6.js
  • clash-bin/tests/data/config/public/assets/index.92e2d967.js
  • clash-bin/tests/data/config/public/assets/index.b38debfc.css
  • clash-bin/tests/data/config/public/assets/index.esm.e4dd1508.js
  • clash-bin/tests/data/config/public/assets/logs.43986220.js
  • clash-bin/tests/data/config/public/assets/play.7b1a5f99.js
  • clash-bin/tests/data/config/public/assets/useRemainingViewPortHeight.7395542b.js
  • clash-bin/tests/data/config/public/assets/zh.9b79b7bf.js
  • clash-bin/tests/data/config/public/index.html
  • clash-bin/tests/data/config/public/manifest.webmanifest
  • clash-bin/tests/data/config/public/registerSW.js
  • clash-bin/tests/data/config/public/sw.js
  • clash-bin/tests/data/config/rules.yaml
  • clash-dashboard/package.json
  • clash-dashboard/src/App.tsx
  • clash-dashboard/src/components/Layout.tsx
  • clash-dashboard/src/components/ProxyGroups.tsx
  • clash-dashboard/src/components/ui/button.tsx
  • clash-dashboard/src/index.css
  • clash-dashboard/src/lib/api.ts
  • clash-dashboard/src/lib/settings.ts
  • clash-dashboard/src/pages/Connections.tsx
  • clash-dashboard/src/pages/DNS.tsx
  • clash-dashboard/src/pages/Logs.tsx
  • clash-dashboard/src/pages/Overview.tsx
  • clash-dashboard/src/pages/Providers.tsx
  • clash-dashboard/src/pages/Proxies.tsx
  • clash-dashboard/src/pages/Rules.tsx
  • clash-dashboard/src/pages/Settings.tsx
  • clash-dashboard/vite.config.ts
  • clash-lib/Cargo.toml
  • clash-lib/build.rs
  • clash-lib/src/app/api/embedded_dashboard.rs
  • clash-lib/src/app/api/handlers/config.rs
  • clash-lib/src/app/api/handlers/log.rs
  • clash-lib/src/app/api/handlers/provider.rs
  • clash-lib/src/app/api/mod.rs
  • clash-lib/src/app/api/runner.rs
  • clash-lib/src/app/api/websocket.rs
  • clash-lib/src/app/inbound/manager.rs
  • clash-lib/src/app/remote_content_manager/providers/rule_provider/provider.rs
  • clash-lib/src/app/router/mod.rs
  • clash-lib/src/lib.rs
💤 Files with no reviewable changes (32)
  • clash-bin/tests/data/config/public/assets/Rules.e03c54a8.css
  • clash-bin/tests/data/config/public/assets/TextFitler.b21c0577.css
  • clash-bin/tests/data/config/public/assets/Proxies.3fa3509d.css
  • clash-bin/tests/data/config/public/registerSW.js
  • clash-bin/tests/data/config/public/manifest.webmanifest
  • clash-bin/tests/data/config/public/_headers
  • clash-bin/tests/data/config/public/assets/Config.39d8d2ef.css
  • clash-bin/tests/data/config/public/assets/Select.1e55eba1.css
  • clash-bin/tests/data/config/public/assets/Logs.4b8e75d1.css
  • clash-bin/tests/data/config/public/index.html
  • clash-bin/tests/data/config/public/assets/Fab.a0a7e573.css
  • clash-bin/tests/data/config/public/assets/index.b38debfc.css
  • clash-bin/tests/data/config/public/assets/Config.c09e8dbe.js
  • clash-bin/tests/data/config/public/assets/useRemainingViewPortHeight.7395542b.js
  • clash-bin/tests/data/config/public/assets/zh.9b79b7bf.js
  • clash-bin/tests/data/config/public/assets/Proxies.16b46af4.js
  • clash-bin/tests/data/config/public/assets/debounce.c2d20996.js
  • clash-bin/tests/data/config/public/assets/chevron-down.dd238e96.js
  • clash-bin/tests/data/config/public/assets/en.fb34eaf7.js
  • clash-bin/tests/data/config/public/assets/Select.6c389032.js
  • clash-bin/tests/data/config/public/assets/Rules.70e6962f.js
  • clash-bin/tests/data/config/public/assets/Fab.ef67ff10.js
  • clash-bin/tests/data/config/public/assets/Connections.fb8ea59b.css
  • clash-bin/tests/data/config/public/assets/Connections.e48eac36.js
  • clash-bin/tests/data/config/public/assets/index.92e2d967.js
  • clash-bin/tests/data/config/public/assets/play.7b1a5f99.js
  • clash-bin/tests/data/config/public/CNAME
  • clash-bin/tests/data/config/public/assets/TextFitler.61537a57.js
  • clash-bin/tests/data/config/public/sw.js
  • clash-bin/tests/data/config/public/assets/logs.43986220.js
  • clash-bin/tests/data/config/public/assets/index.esm.e4dd1508.js
  • clash-bin/tests/data/config/public/assets/Logs.ac990610.js
✅ Files skipped from review due to trivial changes (3)
  • clash-dashboard/package.json
  • clash-dashboard/vite.config.ts
  • clash-dashboard/src/lib/settings.ts
🚧 Files skipped from review as they are similar to previous changes (6)
  • clash-dashboard/src/App.tsx
  • clash-lib/src/app/api/mod.rs
  • clash-dashboard/src/pages/Settings.tsx
  • clash-dashboard/src/pages/DNS.tsx
  • clash-dashboard/src/components/ui/button.tsx
  • clash-lib/Cargo.toml

Comment thread clash-dashboard/src/components/Layout.tsx
Comment thread clash-dashboard/src/lib/api.ts
Comment on lines +54 to +59
useEffect(() => {
if (!lastMessage) return;
setLogs((prev) => [
...prev,
{ ...lastMessage, id: ++logId, ts: new Date().toLocaleTimeString() },
].slice(-500));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Buffered history is stamped with the reconnect time, not the log time.

Now that /ws/logs replays prior events, every replayed entry gets new Date() from the client and appears to happen at connect time. That destroys the chronology the history feature is supposed to preserve. Use a timestamp from LogEntry/LogEvent, or add one to the websocket payload.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/pages/Logs.tsx` around lines 54 - 59, The buffered
history is being stamped with client reconnect time because useEffect (the block
updating setLogs with lastMessage) uses new Date() instead of the event's
original timestamp; update the logic in the effect that appends logs so it uses
the timestamp carried by the LogEntry/LogEvent payload (e.g., lastMessage.ts,
lastMessage.timestamp, or lastMessage.time) and format that for display (fall
back to the existing toLocaleTimeString only if the event timestamp is missing);
keep the id generation via logId and preserve the slice(-500) retention
behavior.

Comment thread clash-dashboard/src/pages/Logs.tsx Outdated
Comment on lines +75 to +110
<h1 className="text-2xl font-bold tracking-tight" style={{ color: '#1d1d1f' }}>Logs</h1>
<WsStatus state={readyState} />
</div>
<div className="flex items-center gap-2">
{/* Level segmented control */}
<div className="flex items-center p-1 rounded-full" style={{ background: 'rgba(0,0,0,0.06)' }}>
{LEVELS.map((l) => {
const active = level === l;
return (
<button
key={l}
onClick={() => setLevel(l)}
className="px-3 py-1.5 rounded-full text-[12px] font-medium capitalize transition-all"
style={active ? LEVEL_ACTIVE_STYLE[l] : { color: '#6e6e73' }}
>
{l}
</button>
);
})}
</div>

<div className="w-px h-4 mx-1" style={{ background: 'rgba(0,0,0,0.08)' }} />

<button
onClick={() => setPaused((p) => !p)}
className="px-3 py-1.5 rounded-full text-[12px] font-medium transition-colors"
style={paused
? { background: 'rgba(255,149,0,0.1)', color: '#ff9500' }
: { background: 'rgba(0,0,0,0.05)', color: '#6e6e73' }}
>
{paused ? 'Resume' : 'Pause'}
</button>
<button
onClick={() => setLogs([])}
className="px-3 py-1.5 rounded-full text-[12px] font-medium transition-colors"
style={{ background: 'rgba(0,0,0,0.05)', color: '#6e6e73' }}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The toolbar is locked to the light palette.

#1d1d1f, #6e6e73, and the translucent black backgrounds work on the light theme, but they become low-contrast against the dark theme introduced in src/index.css. These should be theme-aware classes/variables like the shell header.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/pages/Logs.tsx` around lines 75 - 110, The toolbar in
Logs.tsx is using hardcoded colors (e.g. the h1 inline style color '#1d1d1f',
button inactive color '#6e6e73', LEVEL_ACTIVE_STYLE entries, and translucent
black backgrounds on the segmented control and separator) which break dark-theme
contrast; update Logs.tsx to use the theme-aware CSS variables/classes used by
the shell header instead of literal hex/rgba values: replace the h1 inline
style, the per-button inline style branch in the LEVELS map, the
segmented-control background, the separator background, and the Pause/Resume and
Clear button inline styles to reference existing theme CSS variables or utility
classes (or add new variables in src/index.css if needed) so colors switch
correctly with the dark theme (refer to LEVELS, LEVEL_ACTIVE_STYLE, setLevel,
setPaused, and the top-level h1 in this file to locate spots to change).

Comment on lines +171 to +184
async function runHealthcheck(provider: ProxyProvider) {
setTestingProviders((s) => new Set(s).add(provider.name));
setExpandedProviders((prev) => new Set(prev).add(provider.name));
try {
await healthcheckProvider(provider.name);
await queryClient.invalidateQueries({ queryKey: ['providers'] });
} finally {
setTestingProviders((s) => {
const next = new Set(s);
next.delete(provider.name);
return next;
});
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent healthcheck behavior between pages.

runHealthcheck here only calls healthcheckProvider(provider.name), while in Providers.tsx (lines 65-86) it also runs individual getProxyDelay calls for each proxy before the healthcheck. Users clicking "Test" will see different behavior depending on which page they're on.

Consider aligning the behavior or making the difference intentional (e.g., "Quick Test" vs "Full Test").

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/pages/Proxies.tsx` around lines 171 - 184, runHealthcheck
currently only calls healthcheckProvider(provider.name) causing inconsistent
behavior with Providers.tsx which calls getProxyDelay for each proxy; update
runHealthcheck to perform the same per-proxy delay checks (call getProxyDelay
for each proxy in provider.proxies, e.g., await
Promise.allSettled(provider.proxies.map(p => getProxyDelay(p.name))) with
individual try/catch handling) before calling
healthcheckProvider(provider.name), or alternatively rename/run a different
"quick" vs "full" test to make the distinction explicit; touch the
runHealthcheck function and use the same helper getProxyDelay and
healthcheckProvider symbols so behavior matches or is intentionally different.

Comment thread clash-lib/build.rs Outdated
Comment on lines +281 to +284
let rules = p.list_rules(500).await;
let mut res = HashMap::new();
res.insert("rules", rules);
axum::response::Json(res).into_response()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't silently cap rule lists at 500.

list_rules(500) truncates larger providers without telling the caller, so the new expansion view can look complete while omitting entries. Please make the limit explicit via query/pagination, or return truncation metadata with the response.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-lib/src/app/api/handlers/provider.rs` around lines 281 - 284, The
handler in provider.rs currently calls p.list_rules(500) which silently
truncates results; change the API to either accept explicit paging/query
parameters (e.g. limit and offset/page) and pass them into list_rules (or the
provider's paging API), or keep the full list but return truncation metadata
(e.g. total_count, limit, truncated: true) alongside "rules" so callers know
results were cut; update the request handling in the provider route to parse
query params (limit/offset) and return a JSON object containing rules plus
explicit metadata (total/limit/offset/truncated) instead of hardcoding 500.

Comment thread clash-lib/src/app/api/websocket.rs Outdated
Comment on lines 143 to 156
// Replay recent log history so the client sees context immediately.
let history: Vec<String> = {
let buf = state.recent_logs.lock().unwrap();
buf.iter()
.filter_map(|evt| serde_json::to_string(evt).ok())
.collect()
};
for s in history {
if socket.send(Message::Text(s.into())).await.is_err() {
return;
}
}

let mut rx = state.log_source_tx.subscribe();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Subscribe before replaying buffered logs.

Anything emitted after the snapshot at Line 145 but before the subscription at Line 156 is in neither stream, so reconnecting clients can still miss exactly the logs this replay is meant to preserve. This needs a subscribe-first bridge with dedupe/sequence handling, or another atomic handoff.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-lib/src/app/api/websocket.rs` around lines 143 - 156, Subscribe to the
broadcast channel before replaying buffered logs: call
state.log_source_tx.subscribe() first (assign to rx), then take the snapshot of
state.recent_logs (lock state.recent_logs and collect history) and send the
buffered messages; while sending the history, drain rx for any concurrent events
and deduplicate by sequence/timestamp (compare each incoming event from rx
against the last sequence/timestamp in the snapshot) so you don't miss or
double-send messages; update the logic around state.recent_logs.lock(),
state.log_source_tx.subscribe(), and socket.send to perform subscribe-first +
sequence-based dedupe/merge.

@ibigbug ibigbug force-pushed the feat/builtin-dashboard branch from d84c4f0 to 8203a05 Compare April 27, 2026 07:29
@ibigbug ibigbug changed the title feat: built-in Apple-style web dashboard feat: built-in web dashboard Apr 27, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

♻️ Duplicate comments (4)
clash-dashboard/src/pages/Logs.tsx (2)

16-22: ⚠️ Potential issue | 🟠 Major

These toolbar/status colors still do not follow the dark theme.

The page now has a dark theme in src/index.css, but these segments still hard-code the light palette (#1d1d1f, #6e6e73, translucent black backgrounds). In dark mode the header and controls drop to very low contrast. Please switch them to theme-aware variables/classes so the page stays readable.

Also applies to: 31-40, 75-110

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/pages/Logs.tsx` around lines 16 - 22, LEVEL_ACTIVE_STYLE
is using hard-coded hex colors that break dark theme; replace these static color
values with theme-aware CSS variables or theme tokens (e.g., use
var(--bg-toolbar), var(--text-toolbar), or the app's theme context values) and
update all occurrences referenced in Logs.tsx (LEVEL_ACTIVE_STYLE and the other
toolbar/status color maps around the 31-40 and 75-110 regions) to read from
those variables or compute colors from the theme provider; ensure fallback
values exist for older themes and adjust the component classes to apply the CSS
variables (or call the theme hook) so the header and controls maintain proper
contrast in dark mode.

24-27: ⚠️ Potential issue | 🟠 Major

Preserve server timestamps for replayed history.

Line 58 assigns new Date() on receipt. Replayed entries from /ws/logs therefore all appear at reconnect time, which defeats the history feature’s chronology. Use a timestamp carried in LogEntry, or add one on the backend and render that here.

Also applies to: 54-59

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/pages/Logs.tsx` around lines 24 - 27, The handler is
overwriting server timestamps by assigning new Date() when creating
TimestampedLog items; update the code that constructs TimestampedLog (interface
TimestampedLog and the receiver that currently sets ts = new Date()) to use the
timestamp provided on the incoming LogEntry (e.g., use LogEntry.timestamp or a
provided ts field), parsing/formatting that value into the TimestampedLog.ts
string instead of generating a client-side date; if the backend lacks a
timestamp field add one there and consume it here so replayed entries keep their
original chronology.
clash-lib/src/app/api/websocket.rs (1)

143-156: ⚠️ Potential issue | 🟠 Major

Subscribe before replaying the snapshot.

Line 144 snapshots recent_logs, and Line 156 subscribes afterwards. Any event emitted in that window is in neither stream, so reconnecting clients can still miss logs. This needs a subscribe-first handoff with dedupe/merge instead of replay-then-subscribe.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-lib/src/app/api/websocket.rs` around lines 143 - 156, Take the
subscription before taking the snapshot: call state.log_source_tx.subscribe()
first to get rx, then lock state.recent_logs and build the history Vec<String>;
send the history to the socket, then start receiving from rx. To avoid
duplicates, record the serialized history into a HashSet and when processing
messages from rx (or when merging streams) skip any event whose serialized form
is already in that set; reference state.log_source_tx.subscribe(),
state.recent_logs, history, rx, socket.send and Message::Text for where to
change the ordering and add the dedupe set/logic.
clash-dashboard/src/pages/Overview.tsx (1)

175-185: ⚠️ Potential issue | 🟠 Major

Serialize the config PATCH writes from this editor.

These controls still call patchMutation.mutate() while an earlier PATCH is in flight, so rapid toggles or blur commits can race and leave the server on an older value. The mode buttons were guarded already, but the rest of the config editor still has the same write-order problem.

Also applies to: 337-372

🧹 Nitpick comments (10)
clash-dashboard/src/pages/Rules.tsx (2)

6-27: Use the precomputed badge background to avoid style drift.

getRuleTypeBadgeStyle returns background, but rendering recomputes it from color. This makes the helper partially redundant and easier to desync later.

Suggested diff
-                <span
-                  className="text-[11px] font-semibold tracking-wider px-2 py-0.5 rounded-full flex-shrink-0"
-                  style={{ background: typeStyle.color + '22', color: typeStyle.color }}
-                >
+                <span
+                  className="text-[11px] font-semibold tracking-wider px-2 py-0.5 rounded-full flex-shrink-0"
+                  style={{ background: typeStyle.background, color: typeStyle.color }}
+                >

Also applies to: 102-103

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/pages/Rules.tsx` around lines 6 - 27, The helper
getRuleTypeBadgeStyle currently returns a precomputed background but downstream
rendering recomputes the background from the color (causing drift); update all
badge render sites (the code that calls getRuleTypeBadgeStyle — including the
usages around the earlier switch and the locations referenced at lines ~102-103)
to consume and apply the returned background value directly (use
style.background or the returned.background) instead of deriving it from
returned.color, so the badge color/background stay in sync with the single
source of truth.

36-41: Normalize the search token once before filtering.

search.toLowerCase() is recomputed for every rule/field. Precomputing improves readability and avoids repeated work on large rule lists.

Suggested diff
-  const filtered = rules.filter(
-    (r) =>
-      r.type.toLowerCase().includes(search.toLowerCase()) ||
-      (r.payload ?? '').toLowerCase().includes(search.toLowerCase()) ||
-      r.proxy.toLowerCase().includes(search.toLowerCase())
-  );
+  const needle = search.trim().toLowerCase();
+  const filtered = !needle
+    ? rules
+    : rules.filter(
+        (r) =>
+          r.type.toLowerCase().includes(needle) ||
+          (r.payload ?? '').toLowerCase().includes(needle) ||
+          r.proxy.toLowerCase().includes(needle)
+      );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/pages/Rules.tsx` around lines 36 - 41, Precompute a
lowercase search token once and use it inside the rules.filter predicate instead
of calling search.toLowerCase() repeatedly; create a local const like
searchLower = search.toLowerCase() above the filtered = rules.filter(...) and
replace each search.toLowerCase() occurrence in the predicate with searchLower
(keep the existing payload nullish fallback and calls to
r.type.toLowerCase()/r.proxy.toLowerCase()).
clash-dashboard/src/components/ui/table.tsx (1)

68-79: Add a default header scope for better table semantics.

Consider defaulting scope="col" in TableHead when consumers don’t provide one.

Suggested patch
-function TableHead({ className, ...props }: React.ComponentProps<"th">) {
+function TableHead({ className, scope, ...props }: React.ComponentProps<"th">) {
   return (
     <th
       data-slot="table-head"
+      scope={scope ?? "col"}
       className={cn(
         "h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
         className
       )}
       {...props}
     />
   )
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/components/ui/table.tsx` around lines 68 - 79, TableHead
currently renders a <th> without a default scope which hurts table semantics;
update the TableHead component (function TableHead) to default scope="col" when
callers don't supply a scope prop by reading scope from props and passing
scope={scope ?? "col"} into the returned th element so consumer-provided scope
still overrides the default.
clash-dashboard/src/pages/Proxies.tsx (1)

151-159: Batch latency state updates to avoid N rerenders per test run.

setLatencyMap is called once per proxy. For large groups this creates unnecessary render churn.

⚡ Suggested batching approach
-      await Promise.allSettled(
-        group.all.map(async (name) => {
-          try {
-            const res = await getProxyDelay(name, TEST_URL, TEST_TIMEOUT);
-            setLatencyMap((prev) => ({ ...prev, [name]: res.delay }));
-          } catch {
-            setLatencyMap((prev) => ({ ...prev, [name]: 0 }));
-          }
-        })
-      );
+      const updates: Record<string, number> = {};
+      await Promise.allSettled(
+        group.all.map(async (name) => {
+          try {
+            const res = await getProxyDelay(name, TEST_URL, TEST_TIMEOUT);
+            updates[name] = res.delay;
+          } catch {
+            updates[name] = 0;
+          }
+        })
+      );
+      setLatencyMap((prev) => ({ ...prev, ...updates }));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/pages/Proxies.tsx` around lines 151 - 159, The per-proxy
setLatencyMap calls cause N re-renders; instead, inside the group testing block
(the async map over group.all using getProxyDelay), collect each proxy's delay
into a local updates object (use Promise.allSettled to await all tests), set
failed entries to 0, then call setLatencyMap once with setLatencyMap(prev => ({
...prev, ...updates })); update the code around the Promise.allSettled mapping
so it returns results into that local map and performs a single state update for
the whole group.
clash-dashboard/src/pages/DNS.tsx (1)

50-77: Add explicit accessible labels to DNS form controls.

The input/select/button rely on visual context only. Add aria-label (or <label htmlFor> + id) so screen readers can identify controls reliably.

♿ Suggested change
           <input
             type="text"
+            aria-label="DNS hostname"
             placeholder="Hostname (e.g. example.com)"
@@
         <select
           value={type}
+          aria-label="DNS record type"
           onChange={(e) => setType(e.target.value)}
@@
         <button
           onClick={handleQuery}
+          aria-label="Run DNS query"
           disabled={loading || !hostname.trim()}

Also applies to: 78-85

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/pages/DNS.tsx` around lines 50 - 77, The hostname input
and DNS type select (controlled by state variables hostname/setHostname and
type/setType and rendered from DNS_TYPES) lack accessible labels; add either
aria-label attributes (e.g. aria-label="Hostname" and aria-label="DNS record
type") or an associated <label htmlFor="..."> with matching id attributes on the
<input> and <select>, and do the same for the query button that calls
handleQuery so screen readers can identify each control.
clash-dashboard/src/components/ui/badge.tsx (2)

29-36: Reuse badgeVariants(...) inside Badge to avoid class drift.

Line 33 duplicates the base class string already defined in badgeVariants (Line 21). Calling the helper here keeps one source of truth.

Proposed fix
 function Badge({ className, variant = 'default', ...props }: BadgeProps) {
   return (
     <span
-      className={cn(
-        'inline-flex items-center text-[11px] font-semibold px-2 py-0.5 rounded-full',
-        variantStyles[variant] ?? variantStyles.default,
-        className
-      )}
+      className={cn(badgeVariants({ variant }), className)}
       {...props}
     />
   )
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/components/ui/badge.tsx` around lines 29 - 36, The Badge
component duplicates the base class string instead of reusing the badgeVariants
helper; update the Badge function to call badgeVariants (the variant helper
defined as badgeVariants) to compute the full className for the <span> (pass the
current variant and className into badgeVariants) and remove the hard-coded base
class string and direct use of variantStyles inside Badge so all classes come
from badgeVariants.

4-27: Keep variant strongly typed instead of widening to string.

At line 4, variantStyles is typed as Record<string, string>, which makes keyof typeof variantStyles widen to string rather than preserve literal keys. Combined with line 19 accepting variant?: string, this allows any string to be passed without compile-time validation of valid variant names. Invalid variants silently fall back to the default.

Use as const and a type alias to preserve literal key safety:

Proposed fix
-const variantStyles: Record<string, string> = {
+const variantStyles = {
   default: 'bg-black/[0.06] text-[`#6e6e73`]',
   secondary: 'bg-black/[0.06] text-[`#6e6e73`]',
   destructive: 'bg-[`#ff3b30`]/10 text-[`#ff3b30`]',
   outline: 'border border-black/[0.08] text-[`#1d1d1f`]',
   ghost: 'text-[`#6e6e73`] hover:bg-black/[0.05]',
   link: 'text-[`#0071e3`] underline-offset-4 hover:underline',
   blue: 'bg-[`#0071e3`]/10 text-[`#0071e3`]',
   green: 'bg-[`#34c759`]/10 text-[`#34c759`]',
   amber: 'bg-[`#ff9500`]/10 text-[`#ff9500`]',
   violet: 'bg-[`#af52de`]/10 text-[`#af52de`]',
   gray: 'bg-black/[0.06] text-[`#6e6e73`]',
   red: 'bg-[`#ff3b30`]/10 text-[`#ff3b30`]',
-}
+} as const
+
+type BadgeVariant = keyof typeof variantStyles
 
-export const badgeVariants = ({ variant = 'default' }: { variant?: string } = {}) =>
+export const badgeVariants = ({ variant = 'default' }: { variant?: BadgeVariant } = {}) =>
   cn(
     'inline-flex items-center text-[11px] font-semibold px-2 py-0.5 rounded-full',
     variantStyles[variant] ?? variantStyles.default
   )
 
 interface BadgeProps extends React.ComponentProps<"span"> {
-  variant?: keyof typeof variantStyles;
+  variant?: BadgeVariant;
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/components/ui/badge.tsx` around lines 4 - 27,
variantStyles is currently typed as Record<string,string>, which widens keyof to
string and lets any variant pass; change its declaration to a literal readonly
object to preserve literal keys (e.g. const variantStyles = { ... } as const)
and remove the Record<> typing, then tighten badgeVariants' parameter type to
use the preserved keys (change badgeVariants signature to ({ variant = 'default'
}: { variant?: keyof typeof variantStyles } = {})). Also ensure BadgeProps stays
as variant?: keyof typeof variantStyles so consumers get compile-time
validation.
clash-lib/src/app/inbound/manager.rs (1)

615-621: Semantic change: change_ports now restarts listeners even when port values are unchanged.

The predicate changed from checking port equality to checking presence (ports.port.is_some()). This means if the API payload includes a port field with the same value as the current port, the listener is still extracted, aborted, and reinserted—resulting in a brief interruption even when no actual change occurred.

The return value changed (Line 626) will be true even if all port values are identical, which may cause unnecessary restarts upstream.

Consider comparing actual values to avoid spurious restarts:

♻️ Proposed fix to compare port values
         let listeners: HashMap<InboundOpts, Option<_>> = guard
             .extract_if(|opts, _| match &opts {
-                InboundOpts::Http { .. } => ports.port.is_some(),
-                InboundOpts::Socks { .. } => ports.socks_port.is_some(),
-                InboundOpts::Mixed { .. } => ports.mixed_port.is_some(),
+                InboundOpts::Http { common_opts } => {
+                    ports.port.is_some_and(|p| p != common_opts.port)
+                }
+                InboundOpts::Socks { common_opts, .. } => {
+                    ports.socks_port.is_some_and(|p| p != common_opts.port)
+                }
+                InboundOpts::Mixed { common_opts, .. } => {
+                    ports.mixed_port.is_some_and(|p| p != common_opts.port)
+                }
                 #[cfg(feature = "tproxy")]
-                InboundOpts::TProxy { .. } => ports.tproxy_port.is_some(),
+                InboundOpts::TProxy { common_opts, .. } => {
+                    ports.tproxy_port.is_some_and(|p| p != common_opts.port)
+                }
                 #[cfg(feature = "redir")]
-                InboundOpts::Redir { .. } => ports.redir_port.is_some(),
+                InboundOpts::Redir { common_opts } => {
+                    ports.redir_port.is_some_and(|p| p != common_opts.port)
+                }
                 _ => false,
             })
             .collect();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-lib/src/app/inbound/manager.rs` around lines 615 - 621, The
change_ports logic currently treats any presence of a port field
(ports.port.is_some(), ports.socks_port.is_some(), etc.) as a change and forces
listener restart; update the predicate to compare the incoming Option value to
the existing listener port so only actual changes trigger extraction/restart. In
practice, inside change_ports use equality checks (e.g., ports.port !=
current_listener.port) or compare Option values directly for each variant
(InboundOpts::Http, ::Socks, ::Mixed, #[cfg(feature = "tproxy")] ::TProxy,
#[cfg(feature = "redir")] ::Redir) and only set the local changed flag/perform
extract/abort/reinsert when the value differs, ensuring the returned changed
reflects real modifications.
clash-dashboard/src/components/ui/tooltip.tsx (1)

50-53: Consider extracting tooltip popup classes to a constant.

This improves readability and reduces merge-friction in future style updates.

♻️ Optional refactor
+const tooltipContentClasses =
+  "z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95"
+
         <TooltipPrimitive.Popup
           data-slot="tooltip-content"
-          className={cn(
-            "z-50 inline-flex w-fit max-w-xs origin-(--transform-origin) items-center gap-1.5 rounded-md bg-foreground px-3 py-1.5 text-xs text-background has-data-[slot=kbd]:pr-1.5 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 **:data-[slot=kbd]:relative **:data-[slot=kbd]:isolate **:data-[slot=kbd]:z-50 **:data-[slot=kbd]:rounded-sm data-[state=delayed-open]:animate-in data-[state=delayed-open]:fade-in-0 data-[state=delayed-open]:zoom-in-95 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
-            className
-          )}
+          className={cn(tooltipContentClasses, className)}
           {...props}
         >
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/components/ui/tooltip.tsx` around lines 50 - 53, The long
inline class list passed into cn(...) for the tooltip popup should be extracted
into a named constant (e.g., TOOLTIP_POPUP_CLASSES) to improve readability and
make future style changes simpler; update the tooltip component to import/use
that constant in place of the long string in the cn call (keep the existing
cn(...) and className merge), ensure the constant contains the exact class
string shown (including the data-[slot=kbd] and data-[state] variants) and
export or colocate it near the Tooltip component so other components can reuse
it.
clash-dashboard/src/components/ui/scroll-area.tsx (1)

22-23: Consider making the default internal scrollbar configurable.

ScrollArea always injects a vertical scrollbar, which can be limiting for specialized layouts.

♻️ Optional refactor
+type ScrollAreaProps = ScrollAreaPrimitive.Root.Props & {
+  showScrollbar?: boolean
+}
+
 function ScrollArea({
   className,
   children,
+  showScrollbar = true,
   ...props
-}: ScrollAreaPrimitive.Root.Props) {
+}: ScrollAreaProps) {
   return (
     <ScrollAreaPrimitive.Root
       data-slot="scroll-area"
       className={cn("relative", className)}
       {...props}
     >
@@
-      <ScrollBar />
+      {showScrollbar && <ScrollBar />}
       <ScrollAreaPrimitive.Corner />
     </ScrollAreaPrimitive.Root>
   )
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/components/ui/scroll-area.tsx` around lines 22 - 23, The
ScrollArea component always renders a vertical scrollbar (ScrollBar) and the
Corner (ScrollAreaPrimitive.Corner); make this behaviour configurable by adding
a prop (e.g., showScrollbar?: boolean or renderScrollbar?: boolean) to
ScrollArea's props with a default of true, update the ScrollArea component to
conditionally render <ScrollBar /> and <ScrollAreaPrimitive.Corner /> only when
the prop is true, and update the component's TypeScript type/interface and any
places where ScrollArea is used to pass the prop or rely on the default.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@clash-dashboard/src/components/TrafficChart.tsx`:
- Around line 28-30: The ResizeObserver callback is closing over a stale
buildChart and old timestamps/up/down; fix by introducing a mutable dataRef
(e.g., dataRef.current = { timestamps, up, down }) that is updated in the data
effect and change buildChart to read from dataRef.current instead of the
closed-over props; also ensure the ResizeObserver (created where buildChart is
currently referenced) is re-created or its callback reads the latest
buildChart/dataRef so resizing uses current data (update references to
containerRef and chartRef as needed and keep setData usage for incremental
updates).

In `@clash-dashboard/src/hooks/useWebSocket.ts`:
- Around line 18-19: When connect() returns early because url is falsy or the
hook is unmounted, reset the hook state so consumers don't see stale values:
inside useWebSocket's connect function (symbol: connect) before the early return
for !url or unmounted.current, set readyState to 'CLOSED' (via setReadyState or
equivalent) and clear/cleanup the socket reference (setSocket(null) or similar).
Also ensure the onclose handler still runs or that cleanup logic doesn't rely
solely on onclose to set readyState so changes of url properly reset state when
the socket is closed or replaced.

In `@clash-dashboard/src/pages/Connections.tsx`:
- Around line 113-128: The search input and the icon-only row close button lack
accessible names; update the search input (the element using value={search} and
onChange={(e) => setSearch(e.target.value)}) to include an accessible name by
adding either a linked <label> or an aria-label/aria-labelledby (e.g.,
aria-label="Filter connections by host, rule, or chain"), and add an explicit
aria-label (or visually-hidden text) to the icon-only close button for each row
(the icon button that performs the row-close action) such as aria-label="Clear
search" or aria-label="Close connection row" so screen readers receive
meaningful descriptions. Ensure the labels reflect the control action and are
localized if needed.

In `@clash-dashboard/src/pages/DNS.tsx`:
- Around line 27-39: handleQuery currently uses the raw hostname and allows
overlapping requests; to fix it, normalize and validate the hostname at the
start (e.g. const normalized = hostname.trim().toLowerCase() or appropriate
normalization) and use that for the queryDNS call, and add a guard at the top of
handleQuery to return early if loading is true to prevent concurrent requests;
ensure you set error/result state based on the normalized query and pass
normalized to queryDNS (references: function handleQuery, state vars hostname,
loading, setLoading, setError, setResult, and call to queryDNS).

In `@clash-dashboard/src/pages/Overview.tsx`:
- Around line 87-149: EditRow and the input components (ToggleSwitch, PortInput,
TextInput, SelectInput) lack accessible programmatic labels; update EditRow to
accept an inputId (or ariaId) prop and render the visible label as a <label
htmlFor={inputId}> (or set aria-labelledby using that id), then pass a stable id
from each parent usage into the child input components; update PortInput,
TextInput, SelectInput to accept an id prop and assign it to the input element,
and update ToggleSwitch to accept id and aria-pressed/aria-label (or
aria-labelledby) so screen readers can identify the control; apply the same
changes to the other editor instances around the 337-372 area so every form
control has either an id linked to a label or an appropriate
aria-label/aria-labelledby.

In `@clash-dashboard/src/pages/Proxies.tsx`:
- Around line 251-255: The expandable headers are currently plain clickable divs
(the onClick using toggleExpanded(group.name)), which blocks keyboard users;
replace each clickable div with a semantic button (or add role="button",
tabindex="0", and keyboard handlers) and add proper ARIA attributes: use a
<button> (or ensure element has role="button") with aria-expanded={isExpanded}
and aria-controls pointing to the associated panel id, keep the existing onClick
handler (toggleExpanded(group.name)), and ensure Enter/Space keypresses call
toggleExpanded as well; apply the same change to both header instances
referenced (the div wrapping the header and the other at 382-385) so keyboard
users can expand/collapse groups/providers.

In `@clash-dashboard/src/pages/Settings.tsx`:
- Around line 121-143: The API URL and Secret fields are missing proper label
bindings; update the JSX around the apiUrl and secret inputs so each visible
text element is a <label> tied to its input via matching id/htmlFor (e.g., give
the API URL input an id like "api-url" and change the span to <label
htmlFor="api-url">, and do the same for the Secret input with "api-secret"),
preserving existing props (value={apiUrl}, onChange={setApiUrlState},
onKeyDown={handleSave} and value={secret}, onChange={setSecretState}) so screen
readers can programmatically associate the labels with the inputs.
- Around line 33-39: handleSave currently starts two setTimeouts that call
setSaved and refetch, which can run after the component unmounts; modify
handleSave to store the timer IDs (e.g., in a ref like saveTimersRef) when
calling setTimeout and add a useEffect cleanup that iterates the stored IDs and
calls clearTimeout for each on unmount; ensure you clear the saved timers when
they have fired (or clear the ref) and update references to the timers when
calling setTimeout so that setSaved and refetch are never invoked after unmount.

In `@clash-lib/Cargo.toml`:
- Line 8: The default features currently include "dashboard", causing plain
cargo build to run clash-lib/build.rs (which shells out to npm) and require
../clash-dashboard/dist/ at compile time; remove "dashboard" from the default =
[...] list in Cargo.toml so the dashboard feature is opt-in, and ensure code
paths are gated by cfg(feature = "dashboard") (e.g., in build.rs and
clash-lib/src/app/api/embedded_dashboard.rs) or provide prebuilt assets that
avoid invoking npm during a default build.

In `@clash-lib/src/app/api/embedded_dashboard.rs`:
- Around line 45-54: The SPA fallback currently serves Assets::get("index.html")
for any missing asset; change the None branch to inspect the incoming request
path (the route handled by this handler) and, if the path looks like a static
asset (contains a file extension such as .js, .css, .png, .svg, etc.), return
StatusCode::NOT_FOUND.into_response() immediately, otherwise continue with
serving index.html via Assets::get("index.html") as before; update the logic
around Assets::get and the index.html fallback so only non-asset/route-like
paths get the SPA HTML while true asset misses return 404.

In `@clash-lib/src/app/inbound/manager.rs`:
- Around line 487-503: The restart logic currently stops both inbound_handlers
and provider_handles but only restarts inbound_handlers; update restart() to
also restart provider listeners by cloning and passing provider_handles (and any
related provider dispatcher/authenticator context) into the startup routine
(either extend start_all_listeners to accept provider_handles or call the
existing provider startup helper), and likewise adjust restart_idle() to
consider provider_handles when appropriate so provider listener tasks are
recreated after being aborted; reference the symbols restart(),
stop_all_listeners(), start_all_listeners(), restart_idle(), inbound_handlers,
and provider_handles to locate and modify the code paths that stop and start
provider listener tasks.

In `@clash-lib/src/app/remote_content_manager/providers/rule_provider/mrs.rs`:
- Line 136: The code is persisting storage-structure lengths
(leaves_len/ranges_len) as the logical ruleCount for Domain/Range variants;
change the persistence to use the MRS header's parsed count (the value extracted
in rules_mrs_parse) instead of leaves_len or ranges_len when constructing
RuleContent::Domain and RuleContent::Range (and wherever ruleCount metadata is
set). Locate the creation sites that currently return
Ok(RuleContent::Domain(domain_set, leaves_len)) and the analogous Range return
using ranges_len and replace the persisted count with the header count passed
from rules_mrs_parse (use the header `count` value rather than
leaves_len/ranges_len) so API/UI ruleCount reflects logical rules, not storage
length.

In `@clash-lib/src/lib.rs`:
- Around line 127-129: The struct field config_path is only set during initial
construction and not updated on reloads; update the reload logic so whenever you
parse or switch to Config::File you assign self.config_path = Some(path.clone())
(and when switching to a non-file variant clear it with self.config_path = None)
— locate the reload/config-loading code that matches on Config::File (and the
other Config variants) and add these assignments; apply the same change to the
other reload handling block around the code referenced at the second location
(lines ~268-275) so the exposed /configs value always reflects the latest chosen
file.

---

Duplicate comments:
In `@clash-dashboard/src/pages/Logs.tsx`:
- Around line 16-22: LEVEL_ACTIVE_STYLE is using hard-coded hex colors that
break dark theme; replace these static color values with theme-aware CSS
variables or theme tokens (e.g., use var(--bg-toolbar), var(--text-toolbar), or
the app's theme context values) and update all occurrences referenced in
Logs.tsx (LEVEL_ACTIVE_STYLE and the other toolbar/status color maps around the
31-40 and 75-110 regions) to read from those variables or compute colors from
the theme provider; ensure fallback values exist for older themes and adjust the
component classes to apply the CSS variables (or call the theme hook) so the
header and controls maintain proper contrast in dark mode.
- Around line 24-27: The handler is overwriting server timestamps by assigning
new Date() when creating TimestampedLog items; update the code that constructs
TimestampedLog (interface TimestampedLog and the receiver that currently sets ts
= new Date()) to use the timestamp provided on the incoming LogEntry (e.g., use
LogEntry.timestamp or a provided ts field), parsing/formatting that value into
the TimestampedLog.ts string instead of generating a client-side date; if the
backend lacks a timestamp field add one there and consume it here so replayed
entries keep their original chronology.

In `@clash-lib/src/app/api/websocket.rs`:
- Around line 143-156: Take the subscription before taking the snapshot: call
state.log_source_tx.subscribe() first to get rx, then lock state.recent_logs and
build the history Vec<String>; send the history to the socket, then start
receiving from rx. To avoid duplicates, record the serialized history into a
HashSet and when processing messages from rx (or when merging streams) skip any
event whose serialized form is already in that set; reference
state.log_source_tx.subscribe(), state.recent_logs, history, rx, socket.send and
Message::Text for where to change the ordering and add the dedupe set/logic.

---

Nitpick comments:
In `@clash-dashboard/src/components/ui/badge.tsx`:
- Around line 29-36: The Badge component duplicates the base class string
instead of reusing the badgeVariants helper; update the Badge function to call
badgeVariants (the variant helper defined as badgeVariants) to compute the full
className for the <span> (pass the current variant and className into
badgeVariants) and remove the hard-coded base class string and direct use of
variantStyles inside Badge so all classes come from badgeVariants.
- Around line 4-27: variantStyles is currently typed as Record<string,string>,
which widens keyof to string and lets any variant pass; change its declaration
to a literal readonly object to preserve literal keys (e.g. const variantStyles
= { ... } as const) and remove the Record<> typing, then tighten badgeVariants'
parameter type to use the preserved keys (change badgeVariants signature to ({
variant = 'default' }: { variant?: keyof typeof variantStyles } = {})). Also
ensure BadgeProps stays as variant?: keyof typeof variantStyles so consumers get
compile-time validation.

In `@clash-dashboard/src/components/ui/scroll-area.tsx`:
- Around line 22-23: The ScrollArea component always renders a vertical
scrollbar (ScrollBar) and the Corner (ScrollAreaPrimitive.Corner); make this
behaviour configurable by adding a prop (e.g., showScrollbar?: boolean or
renderScrollbar?: boolean) to ScrollArea's props with a default of true, update
the ScrollArea component to conditionally render <ScrollBar /> and
<ScrollAreaPrimitive.Corner /> only when the prop is true, and update the
component's TypeScript type/interface and any places where ScrollArea is used to
pass the prop or rely on the default.

In `@clash-dashboard/src/components/ui/table.tsx`:
- Around line 68-79: TableHead currently renders a <th> without a default scope
which hurts table semantics; update the TableHead component (function TableHead)
to default scope="col" when callers don't supply a scope prop by reading scope
from props and passing scope={scope ?? "col"} into the returned th element so
consumer-provided scope still overrides the default.

In `@clash-dashboard/src/components/ui/tooltip.tsx`:
- Around line 50-53: The long inline class list passed into cn(...) for the
tooltip popup should be extracted into a named constant (e.g.,
TOOLTIP_POPUP_CLASSES) to improve readability and make future style changes
simpler; update the tooltip component to import/use that constant in place of
the long string in the cn call (keep the existing cn(...) and className merge),
ensure the constant contains the exact class string shown (including the
data-[slot=kbd] and data-[state] variants) and export or colocate it near the
Tooltip component so other components can reuse it.

In `@clash-dashboard/src/pages/DNS.tsx`:
- Around line 50-77: The hostname input and DNS type select (controlled by state
variables hostname/setHostname and type/setType and rendered from DNS_TYPES)
lack accessible labels; add either aria-label attributes (e.g.
aria-label="Hostname" and aria-label="DNS record type") or an associated <label
htmlFor="..."> with matching id attributes on the <input> and <select>, and do
the same for the query button that calls handleQuery so screen readers can
identify each control.

In `@clash-dashboard/src/pages/Proxies.tsx`:
- Around line 151-159: The per-proxy setLatencyMap calls cause N re-renders;
instead, inside the group testing block (the async map over group.all using
getProxyDelay), collect each proxy's delay into a local updates object (use
Promise.allSettled to await all tests), set failed entries to 0, then call
setLatencyMap once with setLatencyMap(prev => ({ ...prev, ...updates })); update
the code around the Promise.allSettled mapping so it returns results into that
local map and performs a single state update for the whole group.

In `@clash-dashboard/src/pages/Rules.tsx`:
- Around line 6-27: The helper getRuleTypeBadgeStyle currently returns a
precomputed background but downstream rendering recomputes the background from
the color (causing drift); update all badge render sites (the code that calls
getRuleTypeBadgeStyle — including the usages around the earlier switch and the
locations referenced at lines ~102-103) to consume and apply the returned
background value directly (use style.background or the returned.background)
instead of deriving it from returned.color, so the badge color/background stay
in sync with the single source of truth.
- Around line 36-41: Precompute a lowercase search token once and use it inside
the rules.filter predicate instead of calling search.toLowerCase() repeatedly;
create a local const like searchLower = search.toLowerCase() above the filtered
= rules.filter(...) and replace each search.toLowerCase() occurrence in the
predicate with searchLower (keep the existing payload nullish fallback and calls
to r.type.toLowerCase()/r.proxy.toLowerCase()).

In `@clash-lib/src/app/inbound/manager.rs`:
- Around line 615-621: The change_ports logic currently treats any presence of a
port field (ports.port.is_some(), ports.socks_port.is_some(), etc.) as a change
and forces listener restart; update the predicate to compare the incoming Option
value to the existing listener port so only actual changes trigger
extraction/restart. In practice, inside change_ports use equality checks (e.g.,
ports.port != current_listener.port) or compare Option values directly for each
variant (InboundOpts::Http, ::Socks, ::Mixed, #[cfg(feature = "tproxy")]
::TProxy, #[cfg(feature = "redir")] ::Redir) and only set the local changed
flag/perform extract/abort/reinsert when the value differs, ensuring the
returned changed reflects real modifications.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8bf412b0-ebac-46a5-a2c9-657130c9f8e3

📥 Commits

Reviewing files that changed from the base of the PR and between d84c4f0 and 8203a05.

⛔ Files ignored due to path filters (7)
  • Cargo.lock is excluded by !**/*.lock
  • clash-dashboard/package-lock.json is excluded by !**/package-lock.json
  • clash-dashboard/public/favicon.svg is excluded by !**/*.svg
  • clash-dashboard/public/icons.svg is excluded by !**/*.svg
  • clash-dashboard/public/logo.png is excluded by !**/*.png
  • clash-dashboard/src/assets/hero.png is excluded by !**/*.png
  • clash-dashboard/src/assets/logo.png is excluded by !**/*.png
📒 Files selected for processing (52)
  • clash-bin/Cargo.toml
  • clash-dashboard/.gitignore
  • clash-dashboard/README.md
  • clash-dashboard/components.json
  • clash-dashboard/eslint.config.js
  • clash-dashboard/index.html
  • clash-dashboard/package.json
  • clash-dashboard/src/App.tsx
  • clash-dashboard/src/components/Layout.tsx
  • clash-dashboard/src/components/ProxyGroups.tsx
  • clash-dashboard/src/components/TrafficChart.tsx
  • clash-dashboard/src/components/ui/badge.tsx
  • clash-dashboard/src/components/ui/button.tsx
  • clash-dashboard/src/components/ui/card.tsx
  • clash-dashboard/src/components/ui/scroll-area.tsx
  • clash-dashboard/src/components/ui/separator.tsx
  • clash-dashboard/src/components/ui/table.tsx
  • clash-dashboard/src/components/ui/tabs.tsx
  • clash-dashboard/src/components/ui/tooltip.tsx
  • clash-dashboard/src/hooks/useTraffic.ts
  • clash-dashboard/src/hooks/useWebSocket.ts
  • clash-dashboard/src/index.css
  • clash-dashboard/src/lib/api.ts
  • clash-dashboard/src/lib/settings.ts
  • clash-dashboard/src/lib/utils.ts
  • clash-dashboard/src/main.tsx
  • clash-dashboard/src/pages/Connections.tsx
  • clash-dashboard/src/pages/DNS.tsx
  • clash-dashboard/src/pages/Logs.tsx
  • clash-dashboard/src/pages/Overview.tsx
  • clash-dashboard/src/pages/Providers.tsx
  • clash-dashboard/src/pages/Proxies.tsx
  • clash-dashboard/src/pages/Rules.tsx
  • clash-dashboard/src/pages/Settings.tsx
  • clash-dashboard/tsconfig.app.json
  • clash-dashboard/tsconfig.json
  • clash-dashboard/tsconfig.node.json
  • clash-dashboard/vite.config.ts
  • clash-lib/Cargo.toml
  • clash-lib/build.rs
  • clash-lib/src/app/api/embedded_dashboard.rs
  • clash-lib/src/app/api/handlers/config.rs
  • clash-lib/src/app/api/handlers/log.rs
  • clash-lib/src/app/api/handlers/provider.rs
  • clash-lib/src/app/api/mod.rs
  • clash-lib/src/app/api/runner.rs
  • clash-lib/src/app/api/websocket.rs
  • clash-lib/src/app/inbound/manager.rs
  • clash-lib/src/app/remote_content_manager/providers/rule_provider/mrs.rs
  • clash-lib/src/app/remote_content_manager/providers/rule_provider/provider.rs
  • clash-lib/src/app/router/mod.rs
  • clash-lib/src/lib.rs
✅ Files skipped from review due to trivial changes (16)
  • clash-dashboard/tsconfig.json
  • clash-dashboard/index.html
  • clash-dashboard/src/main.tsx
  • clash-dashboard/.gitignore
  • clash-dashboard/src/components/ui/separator.tsx
  • clash-dashboard/README.md
  • clash-dashboard/tsconfig.node.json
  • clash-dashboard/tsconfig.app.json
  • clash-dashboard/package.json
  • clash-dashboard/components.json
  • clash-dashboard/src/lib/settings.ts
  • clash-dashboard/src/components/ui/button.tsx
  • clash-dashboard/src/components/ui/card.tsx
  • clash-lib/src/app/api/handlers/config.rs
  • clash-lib/src/app/api/handlers/provider.rs
  • clash-dashboard/src/lib/api.ts
🚧 Files skipped from review as they are similar to previous changes (10)
  • clash-dashboard/src/lib/utils.ts
  • clash-dashboard/eslint.config.js
  • clash-dashboard/vite.config.ts
  • clash-lib/src/app/router/mod.rs
  • clash-dashboard/src/components/Layout.tsx
  • clash-dashboard/src/hooks/useTraffic.ts
  • clash-dashboard/src/components/ProxyGroups.tsx
  • clash-dashboard/src/App.tsx
  • clash-lib/src/app/api/runner.rs
  • clash-dashboard/src/pages/Providers.tsx

Comment thread clash-dashboard/src/components/TrafficChart.tsx
Comment thread clash-dashboard/src/hooks/useWebSocket.ts Outdated
Comment thread clash-dashboard/src/pages/Connections.tsx
Comment thread clash-dashboard/src/pages/DNS.tsx
Comment on lines +87 to +149
function ToggleSwitch({ value, onChange }: { value: boolean; onChange: (v: boolean) => void }) {
return (
<button
onClick={() => onChange(!value)}
className="w-10 h-6 rounded-full transition-colors flex-shrink-0 relative"
style={{ background: value ? '#34c759' : 'rgba(0,0,0,0.15)' }}
>
<span
className="absolute top-0.5 w-5 h-5 rounded-full bg-white shadow transition-all"
style={{ left: value ? '50%' : '2px' }}
/>
</button>
);
}

function PortInput({ value, onCommit }: { value: number | undefined; onCommit: (v: number | null) => void }) {
const [local, setLocal] = useState<string>(value != null && value !== 0 ? String(value) : '');
useEffect(() => { setLocal(value != null && value !== 0 ? String(value) : ''); }, [value]);
return (
<input
type="number" min="0" max="65535" value={local}
onChange={(e) => setLocal(e.target.value)}
onBlur={() => { const n = parseInt(local, 10); onCommit(!local || isNaN(n) ? null : n); }}
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
className="form-input w-24 text-right text-[13px] font-mono rounded-lg border-0 bg-black/[0.04] focus:ring-2 focus:ring-[#0071e3]/30 focus:bg-white py-1 px-2 transition-all"
placeholder="disabled"
/>
);
}

function TextInput({ value, onCommit }: { value: string | undefined; onCommit: (v: string) => void }) {
const [local, setLocal] = useState<string>(value ?? '');
useEffect(() => { setLocal(value ?? ''); }, [value]);
return (
<input
type="text" value={local}
onChange={(e) => setLocal(e.target.value)}
onBlur={() => onCommit(local)}
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
className="form-input w-40 text-right text-[13px] font-mono rounded-lg border-0 bg-black/[0.04] focus:ring-2 focus:ring-[#0071e3]/30 focus:bg-white py-1 px-2 transition-all"
/>
);
}

function SelectInput({ value, options, onChange }: { value: string | undefined; options: readonly string[]; onChange: (v: string) => void }) {
return (
<select
value={value ?? ''}
onChange={(e) => onChange(e.target.value)}
className="form-select text-[13px] font-mono rounded-lg border-0 bg-black/[0.04] focus:ring-2 focus:ring-[#0071e3]/30 py-1 px-2 capitalize transition-all"
>
{options.map((o) => <option key={o} value={o} className="capitalize">{o}</option>)}
</select>
);
}

function EditRow({ label, icon, iconBg, children }: { label: string; icon: React.ReactNode; iconBg: string; children: React.ReactNode }) {
return (
<div className="flex items-center gap-3 px-4" style={{ minHeight: 52, borderBottom: '1px solid rgba(0,0,0,0.06)' }}>
<IconBadge bg={iconBg}>{icon}</IconBadge>
<span className="text-[15px] flex-1" style={{ color: '#1d1d1f' }}>{label}</span>
{children}
</div>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The config form controls are missing programmatic labels.

EditRow renders the visible label as a plain <span>, and the input/select/button controls don't get id, aria-labelledby, or aria-label. That makes the Ports/Access/Logging editor largely unusable with screen readers.

Also applies to: 337-372

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-dashboard/src/pages/Overview.tsx` around lines 87 - 149, EditRow and
the input components (ToggleSwitch, PortInput, TextInput, SelectInput) lack
accessible programmatic labels; update EditRow to accept an inputId (or ariaId)
prop and render the visible label as a <label htmlFor={inputId}> (or set
aria-labelledby using that id), then pass a stable id from each parent usage
into the child input components; update PortInput, TextInput, SelectInput to
accept an id prop and assign it to the input element, and update ToggleSwitch to
accept id and aria-pressed/aria-label (or aria-labelledby) so screen readers can
identify the control; apply the same changes to the other editor instances
around the 337-372 area so every form control has either an id linked to a label
or an appropriate aria-label/aria-labelledby.

Comment thread clash-lib/Cargo.toml
Comment on lines +45 to +54
None => {
// SPA fallback: any route the React Router doesn't match gets
// index.html so the client can render the correct page.
match Assets::get("index.html") {
Some(asset) => Response::builder()
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CACHE_CONTROL, "no-cache")
.body(Body::from(asset.data.into_owned()))
.unwrap(),
None => StatusCode::NOT_FOUND.into_response(),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't serve index.html for missing static assets.

This fallback also turns missing /ui/*.js, /ui/*.css, etc. into 200 text/html, which makes stale or mistyped asset URLs fail as blank-page script errors instead of a clean 404. Keep the SPA fallback for route-like paths only, and return 404 when the miss looks like a file asset.

♻️ Possible fix
         None => {
+            if std::path::Path::new(path).extension().is_some() {
+                return StatusCode::NOT_FOUND.into_response();
+            }
+
             // SPA fallback: any route the React Router doesn't match gets
             // index.html so the client can render the correct page.
             match Assets::get("index.html") {
                 Some(asset) => Response::builder()
                     .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
None => {
// SPA fallback: any route the React Router doesn't match gets
// index.html so the client can render the correct page.
match Assets::get("index.html") {
Some(asset) => Response::builder()
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CACHE_CONTROL, "no-cache")
.body(Body::from(asset.data.into_owned()))
.unwrap(),
None => StatusCode::NOT_FOUND.into_response(),
None => {
if std::path::Path::new(path).extension().is_some() {
return StatusCode::NOT_FOUND.into_response();
}
// SPA fallback: any route the React Router doesn't match gets
// index.html so the client can render the correct page.
match Assets::get("index.html") {
Some(asset) => Response::builder()
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(header::CACHE_CONTROL, "no-cache")
.body(Body::from(asset.data.into_owned()))
.unwrap(),
None => StatusCode::NOT_FOUND.into_response(),
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-lib/src/app/api/embedded_dashboard.rs` around lines 45 - 54, The SPA
fallback currently serves Assets::get("index.html") for any missing asset;
change the None branch to inspect the incoming request path (the route handled
by this handler) and, if the path looks like a static asset (contains a file
extension such as .js, .css, .png, .svg, etc.), return
StatusCode::NOT_FOUND.into_response() immediately, otherwise continue with
serving index.html via Assets::get("index.html") as before; update the logic
around Assets::get and the index.html fallback so only non-asset/route-like
paths get the SPA HTML while true asset misses return 404.

Comment thread clash-lib/src/app/inbound/manager.rs Outdated
Comment thread clash-lib/src/app/remote_content_manager/providers/rule_provider/mrs.rs Outdated
Comment thread clash-lib/src/lib.rs Outdated
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
clash-lib/build.rs (1)

112-128: Consider watching directories to detect new files.

The function only emits rerun-if-changed for existing files. If a developer adds a new .tsx file to src/, Cargo won't re-run the build script until an already-watched file changes.

♻️ Suggested fix to also watch directories
 fn emit_rerun_if_changed(dir: &std::path::Path) {
     if !dir.exists() {
         return;
     }
+    // Watch the directory itself so new files trigger a rebuild
+    println!("cargo:rerun-if-changed={}", dir.display());
     let Ok(entries) = std::fs::read_dir(dir) else {
         return;
     };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clash-lib/build.rs` around lines 112 - 128, The current emit_rerun_if_changed
function only prints cargo:rerun-if-changed for files, so adding new files in a
watched directory won't retrigger the build; modify emit_rerun_if_changed to
also print "cargo:rerun-if-changed={}" for each directory (e.g. when
path.is_dir()) before recursing into it, and ensure the initial dir passed to
emit_rerun_if_changed is printed as well so Cargo watches the directory itself
for new entries.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@clash-lib/build.rs`:
- Around line 112-128: The current emit_rerun_if_changed function only prints
cargo:rerun-if-changed for files, so adding new files in a watched directory
won't retrigger the build; modify emit_rerun_if_changed to also print
"cargo:rerun-if-changed={}" for each directory (e.g. when path.is_dir()) before
recursing into it, and ensure the initial dir passed to emit_rerun_if_changed is
printed as well so Cargo watches the directory itself for new entries.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c82f4524-2be3-471a-a439-b0c3dc950c35

📥 Commits

Reviewing files that changed from the base of the PR and between 8203a05 and ffe9d77.

📒 Files selected for processing (1)
  • clash-lib/build.rs

@ibigbug ibigbug force-pushed the feat/builtin-dashboard branch from ffe9d77 to fd0b02b Compare April 27, 2026 08:30
ibigbug and others added 19 commits April 27, 2026 03:41
- Add clash-dashboard/ React+Vite+TS+Tailwind+shadcn frontend
- Add builtin-dashboard feature flag with rust-embed
- Add build.rs to bundle dashboard assets at compile time
- Add /ui endpoint to serve the dashboard via clash-lib API
- Light theme redesign with top nav layout

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add SVG feTurbulence + feDisplacementMap filter for refraction warp
- Add .liquid-glass class: 40px backdrop-blur, specular highlights,
  angled shimmer overlay, colored shadow — applied to nav bar
- Add .liquid-glass-card class: frosted white translucent cards with
  inner specular top edge — applied to all white content cards
- Add .nav-pill-glass: tiny liquid glass chip for active nav item
- Add vivid-shimmer keyframe sweep animation on gradient stat cards
- Apply liquid-glass distort filter to VividStatCard + VividCard
- Full dark mode support for all glass layers

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Axum 0.8 requires wildcard segments to be written as `{*name}` rather
than the old v0.7 `*name` form.  The `/ui/*path` route panicked at
startup with "Path segments must not start with `*`".

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…logs

- Providers (Proxies page): each provider now expands/collapses to show
  a proxy latency grid; added Test (healthcheck) and Update buttons with
  loading states; shows updatedAt timestamp when available.

- Config page: rewrites display to match actual GetConfigResponse fields
  (port, socks-port, mixed-port, redir-port, tproxy-port, allow-lan,
  bind-address, mode, log-level, ipv6, listeners, lan-ips, dns-listen).
  All supported fields are now inline-editable via PATCH /configs: mode
  and log-level use dropdowns, allow-lan and ipv6 use toggle switches,
  ports use number inputs, bind-address uses a text input. Read-only
  sections show active listeners (with type badge + active dot), LAN IPs
  (when allow-lan is on), and DNS listener addresses.

- Logs page: shows WebSocket connection status indicator (green=OPEN,
  orange=CONNECTING, red=CLOSED) next to the title. The empty state
  message now differentiates between "Waiting for logs", "Connecting…",
  and "Disconnected — check API settings".

- api.ts: replaces the old catch-all ClashConfig type with exact
  InboundEndpoint / DnsListenInfo / ClashConfig interfaces that match
  GetConfigResponse; adds PatchableConfig type (excludes read-only
  fields); adds patchConfigs() (PATCH /configs); fixes reloadConfigs()
  to use PUT instead of PATCH for path-based reload.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…view

- Replace placeholder 'C' square with actual clash-rs favicon.svg in nav
- Wrap nav content and page content in max-w-5xl mx-auto for large screens
- Hide wordmark and version on mobile (sm:inline) to save nav space
- Add Network + System info sections to Overview (ports, allow-lan,
  log level, IPv6, bind address) — read-only SwiftUI inset-group cards
- Remove per-page max-w overrides now handled by layout wrapper

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add asn?: string to Connection.metadata type (serialized by session.as_map())
- Display ASN as a blue pill badge below the host name in the Host column
  (shows country code or org name depending on which MMDB is configured)
- Include ASN in the search filter
- Process name shown alongside ASN when both are present

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… styles

- Install @tailwindcss/forms plugin, register via @plugin directive with class strategy
- Apply form-input class to PortInput and TextInput (border-0, focus ring, bg transition)
- Apply form-select class to SelectInput (consistent styling with inputs)
- Remove Config.tsx (fully merged into Overview)
- Fix Overview.tsx: remove duplicate IconBadge, add missing export function declaration,
  remove stale unused helpers (InfoSection/PortValue/BoolPill/InfoRow), trim orphaned return block

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Mode is already controlled by the segmented switcher above.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
3-column grid (1-col on mobile): Ports card has all 5 port inputs,
Access card has Allow LAN + Bind Address + IPv6, Logging card has
Log Level. Replaces the unbalanced 2-col Logging/Network split.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace invalidateQueries-only approach with proper optimistic update:
- Cancel in-flight queries before mutating
- Immediately update cache so selected proxy highlights instantly
- Revert on error, then background-refetch to confirm server state

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- CSS: add global cursor:pointer for button:not(:disabled) and select
- Overview: sort Active Listeners active-first then alphabetically
- Proxies: all group types (not just Selector) get clickable proxy chips;
  non-Selector groups show a hint explaining auto-select + force-override;
  optimistic update reverts on backend rejection

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The feature existed in clash-lib but was never wired into clash-bin,
so the binary was always compiled without the embedded dashboard.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Direct: full-page empty state, no groups shown
- Global: only the GLOBAL selector group shown
- Rule: all groups except GLOBAL shown
- Mode badge shown in page header

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Rename feature flag from `builtin-dashboard` → `dashboard`
- Add `dashboard` to clash-lib default features
- Remove unused scaffold SVGs (vite.svg, react.svg)
- Fix button.tsx: default type='button' to prevent accidental form submits
- Fix api.ts: handle empty response bodies before JSON parse
- Fix api.ts: encode connection ID in DELETE /connections/:id
- Fix api.ts: use URL API for WS token to handle existing query params
- Fix vite.config.ts: use import.meta.url instead of __dirname (ESM compat)
- Fix build.rs: watch tsconfig.app.json as dashboard build input
- Fix Rules.tsx: surface fetch errors; guard payload before toLowerCase
- Fix Overview.tsx: disable mode buttons while mutation is pending
- Fix Providers.tsx: show error state in RuleProviderRulesPanel
- Fix Settings.tsx: use sessionStorage for secret (not localStorage)
- Add rule provider expand: list_rules() on RuleProvider trait,
  GET /providers/rules/:name/rules endpoint, frontend expand panel
- Fix double #[async_trait] annotation on RuleProvider impl

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Store the original payload length in RuleContent::Domain and
RuleContent::Ipcidr so as_map() can return the correct count.
YAML/Text parsers capture len() before building the trie/set.
MRS parser uses leaves_len/ranges_len as the count.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
ibigbug and others added 3 commits April 27, 2026 03:41
Add GET /providers/rules/:name/match?target=<domain|ip> endpoint that
builds a minimal Session and calls provider.search() so users can test
whether a given host or IP is covered by a rule provider.

In the Providers page, expand panel for every rule provider now shows:
- A search input + Test button (all behavior types)
- ✓ Match / ✗ No match result badge after testing
- Rule list still shown for Classical providers as before
- Info note for Domain/IPCIDR explaining entries aren't enumerable

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CI runners don't have node_modules pre-populated, so npm run build
fails with missing type definitions. npm ci installs from package-lock.json
reliably on a clean checkout. Also watch package-lock.json so cargo
reruns when dependencies change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… API changes

Move all non-embed Rust API improvements (config reload, rule provider
routes, log lag handling, inbound restart_idle) to feat/api-improvements
PR #1343.

Dashboard branch now contains only:
- Embedded dashboard serving (embedded_dashboard.rs, /ui/* routing)
- build.rs npm ci steps
- Cargo.toml dashboard feature flag
- All clash-dashboard frontend files

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@ibigbug ibigbug force-pushed the feat/builtin-dashboard branch from 546801f to e4b3714 Compare April 27, 2026 10:41
ibigbug and others added 3 commits April 27, 2026 03:48
- Separate hover auto-scroll pause from user-controlled Pause/Resume toggle
- Add aria-label/title to nav links for screen readers
- Add type field to RuleProvider interface
- Add timer cleanup on unmount in Settings (saveTimersRef)
- Prevent overlapping DNS queries; trim hostname before querying
- Add aria-label to Connections search input and row close button
- Add keyboard support (role=button, tabIndex, onKeyDown) to expandable
  proxy/provider headers in Proxies page
- Fix TrafficChart resize to always use latest data via dataRef
- Reset useWebSocket state (CLOSED) when url becomes null
- Make build.rs dashboard build non-fatal when clash-dashboard dir or
  npm is missing (warns instead of aborting the build)
- Toolbar text uses theme-aware Tailwind classes for dark mode compat

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
When npm build is skipped (missing npm or dashboard dir), the dist/
folder may not exist. rust-embed's proc-macro requires the folder to
exist at compile time — if it's absent, the derive generates an empty
struct with no impl, causing 'no method get found for Assets'.

Always create dist/ upfront (empty if needed) so rust-embed can compile
cleanly even when the frontend build is skipped.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
On Windows npm is a .cmd script, not a binary — Command::new("npm")
fails to spawn it. Use npm.cmd when cfg!(windows).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@ibigbug ibigbug merged commit 7dcd110 into master Apr 27, 2026
35 of 36 checks passed
@ibigbug ibigbug deleted the feat/builtin-dashboard branch April 27, 2026 21:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant