diff --git a/Dockerfile b/Dockerfile index 962144df..c1b14d21 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,7 +17,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ libcairo2 libasound2 libx11-xcb1 libfontconfig1 libx11-6 \ libxcb1 libxext6 libxshmfence1 \ libglib2.0-0 libgtk-3-0 libpangocairo-1.0-0 libcairo-gobject2 \ - libgdk-pixbuf-2.0-0 libxss1 libxtst6 fonts-liberation \ + libgdk-pixbuf-2.0-0 libxss1 libxtst6 fonts-liberation fonts-wqy-zenhei \ libgl1-mesa-dri libegl-mesa0 \ procps wget ca-certificates xclip \ && rm -rf /var/lib/apt/lists/* @@ -57,7 +57,7 @@ RUN python -c "from cloakbrowser.download import ensure_binary; ensure_binary()" EXPOSE 8080 HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ - CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/api/status')" || exit 1 + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8080/api/health')" || exit 1 VOLUME /data diff --git a/README.md b/README.md index e413c555..6c6f339b 100644 --- a/README.md +++ b/README.md @@ -173,10 +173,27 @@ When `AUTH_TOKEN` is set: - The web UI shows a login page. Enter the token to unlock. - API consumers pass the token via `Authorization: Bearer ` header. - VNC WebSocket connections are authenticated via the login cookie. -- The `/api/status` endpoint remains unauthenticated (for Docker healthcheck). +- The `/api/health` endpoint remains unauthenticated (for Docker healthcheck); it exposes no system details. The `/api/status` endpoint (running counts, version) now requires authentication. > **Note**: The auth token is transmitted in cleartext over HTTP. If you expose the Manager to the internet, put it behind a reverse proxy with HTTPS (Caddy, nginx, Traefik). +## Runtime UI configuration + +The profile sidebar defaults to `16rem`. To use a wider or narrower sidebar without changing the frontend build, set `SIDEBAR_WIDTH` to a CSS length: + +```bash +docker run -p 8080:8080 -v cloakprofiles:/data -e SIDEBAR_WIDTH=20rem cloakhq/cloakbrowser-manager +``` + +Or in `docker-compose.yml`: + +```yaml +environment: + - SIDEBAR_WIDTH=20rem +``` + +Supported units are `px`, `rem`, `em`, and `vw`. + ## License - **This application** (GUI source code) — MIT. See [LICENSE](LICENSE). diff --git a/backend/browser_manager.py b/backend/browser_manager.py index 327f91b5..af9d4443 100644 --- a/backend/browser_manager.py +++ b/backend/browser_manager.py @@ -233,6 +233,12 @@ async def launch(self, profile: dict[str, Any]) -> RunningProfile: env={**os.environ, "DISPLAY": f":{display}"}, ) + await self._fit_window_to_vnc( + context, + width=profile.get("screen_width", 1920), + height=profile.get("screen_height", 1080), + ) + # Inject clipboard listener: captures copied text on every page # so the GET /clipboard endpoint can read it via page.evaluate() _clipboard_init_js = """ @@ -339,6 +345,45 @@ async def cleanup_stale(self): """Kill orphan processes from previous container runs.""" await self.vnc.cleanup_stale() + async def _fit_window_to_vnc(self, context: Any, width: int, height: int) -> None: + """Keep the native browser window inside the VNC framebuffer.""" + if not getattr(context, "pages", None): + logger.debug("Skipping VNC window fit: no pages available") + return + + try: + session = await context.new_cdp_session(context.pages[0]) + result = await session.send("Browser.getWindowForTarget") + bounds = result.get("bounds", {}) + left = int(bounds.get("left", 0)) + top = int(bounds.get("top", 0)) + current_width = int(bounds.get("width", width)) + current_height = int(bounds.get("height", height)) + + overflows = ( + left != 0 + or top != 0 + or left + current_width > width + or top + current_height > height + ) + if not overflows: + return + + await session.send( + "Browser.setWindowBounds", + { + "windowId": result["windowId"], + "bounds": {"left": 0, "top": 0, "width": width, "height": height}, + }, + ) + logger.info( + "Adjusted browser window to fit VNC framebuffer %dx%d", + width, + height, + ) + except Exception as exc: + logger.warning("Failed to adjust browser window bounds: %s", exc) + async def auto_launch_all(self): """Launch all profiles with auto_launch=True. Called on startup.""" from . import database as db diff --git a/backend/main.py b/backend/main.py index 92727a53..3df39020 100644 --- a/backend/main.py +++ b/backend/main.py @@ -10,6 +10,7 @@ import hmac import logging import os +import re import struct import shutil from contextlib import asynccontextmanager @@ -34,6 +35,7 @@ ProfileResponse, ProfileStatusResponse, ProfileUpdate, + RuntimeConfigResponse, StatusResponse, TagResponse, ) @@ -47,11 +49,14 @@ # Optional authentication via AUTH_TOKEN env var. # If not set, all routes are open (local dev). If set, all /api/* routes -# (except /api/auth/* and /api/status) require Bearer token or cookie. +# (except /api/auth/status, /api/auth/login and /api/health) require Bearer +# token or cookie. AUTH_TOKEN: str | None = os.environ.get("AUTH_TOKEN") or None +DEFAULT_SIDEBAR_WIDTH = "16rem" +_SIDEBAR_WIDTH_RE = re.compile(r"^\d+(?:\.\d+)?(?:px|rem|em|vw)$") # Paths that bypass authentication even when AUTH_TOKEN is set -_AUTH_EXEMPT = frozenset({"/api/auth/status", "/api/auth/login", "/api/status"}) +_AUTH_EXEMPT = frozenset({"/api/auth/status", "/api/auth/login", "/api/health"}) def _check_auth(scope: Scope) -> bool: @@ -180,6 +185,17 @@ async def __call__(self, scope: Scope, receive: Receive, send: Send): FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist" +def get_sidebar_width() -> str: + """Return a validated CSS length for the profile sidebar.""" + value = os.environ.get("SIDEBAR_WIDTH", "").strip() + if not value: + return DEFAULT_SIDEBAR_WIDTH + if _SIDEBAR_WIDTH_RE.fullmatch(value): + return value + logger.warning("Ignoring invalid SIDEBAR_WIDTH=%r", value) + return DEFAULT_SIDEBAR_WIDTH + + # --------------------------------------------------------------------------- # RFB server message translator — KasmVNC BinaryClipboard → standard RFB # --------------------------------------------------------------------------- @@ -405,6 +421,12 @@ async def auth_status(request: starlette.requests.Request): return {"auth_required": AUTH_TOKEN is not None, "authenticated": authenticated} +@app.get("/api/config", response_model=RuntimeConfigResponse) +async def runtime_config(): + """Return runtime UI configuration.""" + return RuntimeConfigResponse(sidebar_width=get_sidebar_width()) + + @app.post("/api/auth/login") async def auth_login(body: LoginRequest, request: Request, response: Response): if not AUTH_TOKEN: @@ -567,6 +589,16 @@ async def get_profile_status(profile_id: str): # ── System Status ───────────────────────────────────────────────────────────── +@app.get("/api/health") +async def health_check(): + """Unauthenticated liveness probe for the Docker healthcheck. + + Intentionally returns no system details; see /api/status (auth-gated) + for running counts and versions. + """ + return {"status": "ok"} + + @app.get("/api/status", response_model=StatusResponse) async def get_system_status(): from cloakbrowser.config import CHROMIUM_VERSION diff --git a/backend/models.py b/backend/models.py index e3ba3521..5ba15aca 100644 --- a/backend/models.py +++ b/backend/models.py @@ -119,6 +119,10 @@ class StatusResponse(BaseModel): profiles_total: int +class RuntimeConfigResponse(BaseModel): + sidebar_width: str + + class ProfileStatusResponse(BaseModel): status: str # "running" | "stopped" vnc_ws_port: int | None = None diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 9b2bca33..fb65a5f0 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -191,6 +191,27 @@ def test_system_status(app_client: TestClient): assert data["profiles_total"] >= 1 +# ── Runtime Config ────────────────────────────────────────────────────────── + + +def test_runtime_config_default_sidebar_width(app_client: TestClient, monkeypatch): + monkeypatch.delenv("SIDEBAR_WIDTH", raising=False) + + resp = app_client.get("/api/config") + + assert resp.status_code == 200 + assert resp.json() == {"sidebar_width": "16rem"} + + +def test_runtime_config_sidebar_width_from_env(app_client: TestClient, monkeypatch): + monkeypatch.setenv("SIDEBAR_WIDTH", "20rem") + + resp = app_client.get("/api/config") + + assert resp.status_code == 200 + assert resp.json() == {"sidebar_width": "20rem"} + + # ── Launch Args ───────────────────────────────────────────────────────────── diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 2dd6f124..5baeaed4 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -128,8 +128,18 @@ def test_logout_clears_cookie(client_auth: TestClient): def test_healthcheck_always_accessible(client_auth: TestClient): - """GET /api/status must work without auth (Docker healthcheck).""" + """GET /api/health must work without auth (Docker healthcheck).""" + resp = client_auth.get("/api/health") + assert resp.status_code == 200 + + +def test_status_requires_auth(client_auth: TestClient): + """GET /api/status must require auth (leaks profile count and version).""" resp = client_auth.get("/api/status") + assert resp.status_code == 401 + resp = client_auth.get( + "/api/status", headers={"Authorization": "Bearer test-secret"} + ) assert resp.status_code == 200 diff --git a/backend/tests/test_browser_manager.py b/backend/tests/test_browser_manager.py index ba4665ef..ec7a8cc1 100644 --- a/backend/tests/test_browser_manager.py +++ b/backend/tests/test_browser_manager.py @@ -8,6 +8,7 @@ import pytest import socket +from unittest.mock import AsyncMock, MagicMock from backend.browser_manager import ( BASE_CDP_PORT, @@ -176,6 +177,55 @@ def test_launch_args_none_no_effect(): assert len(args) == base_count +# ── VNC browser window bounds ───────────────────────────────────────────────── + + +@pytest.mark.anyio +async def test_fit_window_to_vnc_moves_oversized_window_back_inside_framebuffer(): + mgr = BrowserManager() + page = MagicMock() + session = MagicMock() + session.send = AsyncMock( + side_effect=[ + { + "windowId": 123, + "bounds": { + "left": 10, + "top": 10, + "width": 1928, + "height": 1078, + "windowState": "normal", + }, + }, + { + "windowId": 123, + "bounds": { + "left": 0, + "top": 0, + "width": 1919, + "height": 1079, + "windowState": "normal", + }, + }, + ] + ) + context = MagicMock() + context.pages = [page] + context.new_cdp_session = AsyncMock(return_value=session) + + await mgr._fit_window_to_vnc(context, width=1920, height=1080) + + context.new_cdp_session.assert_awaited_once_with(page) + session.send.assert_any_await("Browser.getWindowForTarget") + session.send.assert_any_await( + "Browser.setWindowBounds", + { + "windowId": 123, + "bounds": {"left": 0, "top": 0, "width": 1920, "height": 1080}, + }, + ) + + # ── _allocate_cdp_port ─────────────────────────────────────────────────────── diff --git a/docker-compose.yml b/docker-compose.yml index 24deb6c1..fd3505f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,3 +7,4 @@ services: - ~/.cloakbrowser-manager:/data environment: - AUTH_TOKEN=${AUTH_TOKEN:-} + - SIDEBAR_WIDTH=${SIDEBAR_WIDTH:-} diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx new file mode 100644 index 00000000..2273ed2d --- /dev/null +++ b/frontend/src/App.test.tsx @@ -0,0 +1,54 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import App from "./App"; +import { api } from "./lib/api"; +import { useProfiles } from "./hooks/useProfiles"; + +vi.mock("./lib/api", () => ({ + api: { + authStatus: vi.fn(), + getConfig: vi.fn(), + logout: vi.fn(), + }, + setOnUnauthorized: vi.fn(), +})); + +vi.mock("./hooks/useProfiles", () => ({ + useProfiles: vi.fn(), +})); + +const mockApi = api as { + authStatus: ReturnType; + getConfig: ReturnType; + logout: ReturnType; +}; + +const mockUseProfiles = useProfiles as ReturnType; + +beforeEach(() => { + mockApi.authStatus.mockResolvedValue({ auth_required: false, authenticated: false }); + mockApi.getConfig.mockResolvedValue({ sidebar_width: "20rem" }); + mockApi.logout.mockResolvedValue({ ok: true }); + mockUseProfiles.mockReturnValue({ + profiles: [], + loading: false, + error: null, + refresh: vi.fn(), + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + launch: vi.fn(), + stop: vi.fn(), + }); +}); + +describe("App", () => { + it("applies the configured sidebar width", async () => { + render(); + + const sidebar = await screen.findByLabelText("Profile sidebar"); + + await waitFor(() => expect(mockApi.getConfig).toHaveBeenCalled()); + expect(sidebar.getAttribute("style")).toContain("width: 20rem"); + }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a1e3195d..8f1580cd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ import { useState, useCallback, useEffect } from "react"; import { Lock, PanelLeftClose, PanelLeft } from "lucide-react"; import { useProfiles } from "./hooks/useProfiles"; -import { api, setOnUnauthorized, type ProfileCreateData } from "./lib/api"; +import { api, setOnUnauthorized, type ProfileCreateData, type RuntimeConfig } from "./lib/api"; import { ProfileList } from "./components/ProfileList"; import { ProfileForm } from "./components/ProfileForm"; import { ProfileViewer } from "./components/ProfileViewer"; @@ -12,6 +12,10 @@ import { LoginPage } from "./components/LoginPage"; type AuthState = "checking" | "required" | "ok" | "error"; type View = "empty" | "create" | "edit" | "view"; +const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = { + sidebar_width: "16rem", +}; + export default function App() { const [authState, setAuthState] = useState("checking"); const [authRequired, setAuthRequired] = useState(false); @@ -93,9 +97,28 @@ function AppContent({ authRequired, onLogout }: AppContentProps) { const [selectedId, setSelectedId] = useState(null); const [view, setView] = useState("empty"); const [sidebarOpen, setSidebarOpen] = useState(true); + const [runtimeConfig, setRuntimeConfig] = useState(DEFAULT_RUNTIME_CONFIG); const selected = profiles.find((p) => p.id === selectedId) ?? null; + useEffect(() => { + let cancelled = false; + + api.getConfig() + .then((config) => { + if (!cancelled) { + setRuntimeConfig({ ...DEFAULT_RUNTIME_CONFIG, ...config }); + } + }) + .catch((err) => { + console.warn("[config] failed to load runtime config:", err); + }); + + return () => { + cancelled = true; + }; + }, []); + const handleSelect = useCallback((id: string) => { setSelectedId(id); const profile = profiles.find((p) => p.id === id); @@ -155,7 +178,11 @@ function AppContent({ authRequired, onLogout }: AppContentProps) {
{/* Sidebar */} {sidebarOpen && ( -
+
): Profile { + return { + id: "profile-1", + name: "Base Profile", + fingerprint_seed: 12345, + proxy: null, + timezone: null, + locale: null, + platform: "windows", + user_agent: null, + screen_width: 1920, + screen_height: 1080, + gpu_vendor: null, + gpu_renderer: null, + hardware_concurrency: null, + humanize: false, + human_preset: "default", + headless: false, + geoip: false, + clipboard_sync: true, + auto_launch: false, + color_scheme: null, + launch_args: [], + notes: null, + user_data_dir: "/data/profiles/profile-1", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + tags: [], + status: "stopped", + vnc_ws_port: null, + cdp_url: null, + ...overrides, + }; +} + +function renderList(profiles: Profile[]) { + render( + , + ); +} + +describe("ProfileList", () => { + it("does not match tags in the text search", () => { + renderList([ + profile({ + id: "tagged", + name: "Tagged Account", + tags: [{ tag: "claude", color: null }], + }), + profile({ id: "plain", name: "Plain Account" }), + ]); + + fireEvent.change(screen.getByPlaceholderText("Search profiles..."), { + target: { value: "claude" }, + }); + + expect(screen.queryByText("Tagged Account")).toBeNull(); + expect(screen.queryByText("Plain Account")).toBeNull(); + expect(screen.queryByText("No matches")).not.toBeNull(); + }); + + it("defaults to All and toggles All and ungrouped as mutually exclusive filters", () => { + renderList([ + profile({ + id: "plain", + name: "Plain Account", + }), + profile({ + id: "claude", + name: "Claude Account", + tags: [{ tag: "claude", color: null }], + }), + profile({ + id: "team", + name: "Team Account", + tags: [{ tag: "team", color: null }], + }), + ]); + + expect(screen.getByRole("button", { name: /^All\s+3$/ }).getAttribute("aria-pressed")).toBe("true"); + expect(screen.getByRole("button", { name: /^未分组\s+1$/ }).getAttribute("aria-pressed")).toBe("false"); + expect(screen.queryByRole("button", { name: /Filter by tag/ })).toBeNull(); + expect(screen.queryByText("Plain Account")).not.toBeNull(); + expect(screen.queryByText("Claude Account")).not.toBeNull(); + expect(screen.queryByText("Team Account")).not.toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: /^未分组\s+1$/ })); + + expect(screen.getByRole("button", { name: /^All\s+3$/ }).getAttribute("aria-pressed")).toBe("false"); + expect(screen.getByRole("button", { name: /^未分组\s+1$/ }).getAttribute("aria-pressed")).toBe("true"); + expect(screen.queryByText("Plain Account")).not.toBeNull(); + expect(screen.queryByText("Claude Account")).toBeNull(); + expect(screen.queryByText("Team Account")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: /^All\s+3$/ })); + + expect(screen.getByRole("button", { name: /^All\s+3$/ }).getAttribute("aria-pressed")).toBe("true"); + expect(screen.getByRole("button", { name: /^未分组\s+1$/ }).getAttribute("aria-pressed")).toBe("false"); + expect(screen.queryByText("Plain Account")).not.toBeNull(); + expect(screen.queryByText("Claude Account")).not.toBeNull(); + expect(screen.queryByText("Team Account")).not.toBeNull(); + }); + + it("filters ordinary tags with multi-select AND semantics and returns to All when cleared", () => { + renderList([ + profile({ + id: "claude", + name: "Claude Account", + tags: [{ tag: "claude", color: null }], + }), + profile({ + id: "team", + name: "Team Account", + tags: [{ tag: "team", color: null }], + }), + profile({ + id: "both", + name: "Claude Team Account", + tags: [ + { tag: "claude", color: null }, + { tag: "team", color: null }, + ], + }), + ]); + + fireEvent.click(screen.getByRole("button", { name: /^claude\s+2$/ })); + + expect(screen.getByRole("button", { name: /^All\s+3$/ }).getAttribute("aria-pressed")).toBe("false"); + expect(screen.getByRole("button", { name: /^claude\s+2$/ }).getAttribute("aria-pressed")).toBe("true"); + expect(screen.queryByText("Claude Account")).not.toBeNull(); + expect(screen.queryByText("Claude Team Account")).not.toBeNull(); + expect(screen.queryByText("Team Account")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: /^team\s+2$/ })); + + expect(screen.getByRole("button", { name: /^claude\s+2$/ }).getAttribute("aria-pressed")).toBe("true"); + expect(screen.getByRole("button", { name: /^team\s+2$/ }).getAttribute("aria-pressed")).toBe("true"); + expect(screen.queryByText("Claude Team Account")).not.toBeNull(); + expect(screen.queryByText("Claude Account")).toBeNull(); + expect(screen.queryByText("Team Account")).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: /^All\s+3$/ })); + + expect(screen.getByRole("button", { name: /^All\s+3$/ }).getAttribute("aria-pressed")).toBe("true"); + expect(screen.getByRole("button", { name: /^claude\s+2$/ }).getAttribute("aria-pressed")).toBe("false"); + expect(screen.getByRole("button", { name: /^team\s+2$/ }).getAttribute("aria-pressed")).toBe("false"); + expect(screen.queryByText("Claude Account")).not.toBeNull(); + expect(screen.queryByText("Claude Team Account")).not.toBeNull(); + expect(screen.queryByText("Team Account")).not.toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: /^team\s+2$/ })); + fireEvent.click(screen.getByRole("button", { name: /^team\s+2$/ })); + + expect(screen.getByRole("button", { name: /^All\s+3$/ }).getAttribute("aria-pressed")).toBe("true"); + expect(screen.getByRole("button", { name: /^team\s+2$/ }).getAttribute("aria-pressed")).toBe("false"); + expect(screen.queryByText("Claude Account")).not.toBeNull(); + expect(screen.queryByText("Claude Team Account")).not.toBeNull(); + expect(screen.queryByText("Team Account")).not.toBeNull(); + }); + + it("treats legacy default group tags as ungrouped metadata", () => { + renderList([ + profile({ + id: "legacy-default", + name: "Legacy Default Account", + tags: [{ tag: "默认分组", color: null }], + }), + profile({ + id: "claude", + name: "Claude Account", + tags: [{ tag: "claude", color: null }], + }), + ]); + + expect(screen.getByRole("button", { name: /^All\s+2$/ }).getAttribute("aria-pressed")).toBe("true"); + expect(screen.queryByRole("button", { name: /^默认分组\s+1$/ })).toBeNull(); + expect(screen.queryByText("Legacy Default Account")).not.toBeNull(); + expect(screen.queryByText("Claude Account")).not.toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: /^未分组\s+1$/ })); + + expect(screen.queryByText("Legacy Default Account")).not.toBeNull(); + expect(screen.queryByText("Claude Account")).toBeNull(); + expect(screen.queryByText("默认分组")).toBeNull(); + }); + + it("wraps tag filter tabs instead of horizontal scrolling", () => { + renderList([ + profile({ + id: "claude", + name: "Claude Account", + tags: [{ tag: "claude", color: null }], + }), + profile({ + id: "team", + name: "Team Account", + tags: [{ tag: "team", color: null }], + }), + ]); + + const filterGroup = screen.getByRole("group", { name: "Profile tag filters" }); + expect(filterGroup.className).toContain("flex-wrap"); + expect(filterGroup.className).not.toContain("overflow-x-auto"); + }); + + it("filters profiles by notes and renders notes in each list item", () => { + renderList([ + profile({ + id: "noted", + name: "Noted Account", + notes: "billing appeal sent", + }), + profile({ id: "unrelated", name: "Unrelated Account", notes: "clean" }), + ]); + + expect(screen.queryByText("billing appeal sent")).not.toBeNull(); + + fireEvent.change(screen.getByPlaceholderText("Search profiles..."), { + target: { value: "appeal" }, + }); + + expect(screen.queryByText("Noted Account")).not.toBeNull(); + expect(screen.queryByText("Unrelated Account")).toBeNull(); + }); + + it("renders compact list metadata with tags and notes on one line", () => { + renderList([ + profile({ + id: "compact", + name: "Compact Account", + platform: "windows", + proxy: "http://127.0.0.1:7890", + notes: "team note", + tags: [{ tag: "team", color: null }], + }), + ]); + + fireEvent.click(screen.getByRole("button", { name: /^team\s+1$/ })); + + const item = screen.getByText("Compact Account").closest('[role="button"]'); + expect(item).not.toBeNull(); + + const itemQueries = within(item as HTMLElement); + expect(itemQueries.queryByText("windows")).toBeNull(); + expect(itemQueries.queryByText("Proxy")).toBeNull(); + + const tag = itemQueries.getByText("team"); + const notes = itemQueries.getByText("team note"); + expect(tag.parentElement).toBe(notes.parentElement); + }); +}); diff --git a/frontend/src/components/ProfileList.tsx b/frontend/src/components/ProfileList.tsx index c4bcfc8f..5b11ce70 100644 --- a/frontend/src/components/ProfileList.tsx +++ b/frontend/src/components/ProfileList.tsx @@ -1,5 +1,5 @@ import { Plus, Search, Monitor } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import type { Profile } from "../lib/api"; import { StatusIndicator } from "./StatusIndicator"; @@ -10,13 +10,120 @@ interface ProfileListProps { onNew: () => void; } +function getProfileSearchText(profile: Profile): string { + return [ + profile.name, + profile.platform, + profile.proxy, + profile.notes, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); +} + +interface TagFilter { + tag: string; + color: string | null; + count: number; +} + +type FilterMode = "all" | "ungrouped" | "tags"; + +const LEGACY_DEFAULT_GROUP_TAG = "默认分组"; + +function getVisibleTags(profile: Profile): Profile["tags"] { + return profile.tags.filter((tag) => tag.tag !== LEGACY_DEFAULT_GROUP_TAG); +} + +function getTagFilters(profiles: Profile[]): TagFilter[] { + const tags = new Map(); + + profiles.forEach((profile) => { + getVisibleTags(profile).forEach((tag) => { + const current = tags.get(tag.tag); + if (current) { + current.count += 1; + current.color = current.color ?? tag.color; + return; + } + tags.set(tag.tag, { tag: tag.tag, color: tag.color, count: 1 }); + }); + }); + + return Array.from(tags.values()).sort((a, b) => + a.tag.localeCompare(b.tag, undefined, { numeric: true, sensitivity: "base" }), + ); +} + +function filterButtonClass(active: boolean): string { + return [ + "shrink-0 rounded-full border px-2 py-1 text-[11px] font-medium transition-colors", + "focus:outline-none focus:ring-1 focus:ring-accent/50", + active + ? "border-accent bg-accent text-white" + : "border-border bg-surface-2 text-gray-400 hover:bg-surface-3 hover:text-gray-200", + ].join(" "); +} + export function ProfileList({ profiles, selectedId, onSelect, onNew }: ProfileListProps) { const [search, setSearch] = useState(""); - - const filtered = profiles.filter((p) => - p.name.toLowerCase().includes(search.toLowerCase()), + const [filterMode, setFilterMode] = useState("all"); + const [selectedTags, setSelectedTags] = useState([]); + const normalizedSearch = search.trim().toLowerCase(); + const tagFilters = useMemo(() => getTagFilters(profiles), [profiles]); + const selectedTagSet = useMemo(() => new Set(selectedTags), [selectedTags]); + const ungroupedCount = useMemo( + () => profiles.filter((profile) => getVisibleTags(profile).length === 0).length, + [profiles], ); + useEffect(() => { + const availableTags = new Set(tagFilters.map((tag) => tag.tag)); + setSelectedTags((current) => { + const next = current.filter((tag) => availableTags.has(tag)); + return next.length === current.length ? current : next; + }); + }, [tagFilters]); + + useEffect(() => { + if (filterMode === "tags" && selectedTags.length === 0) { + setFilterMode("all"); + } + }, [filterMode, selectedTags.length]); + + const selectAll = () => { + setFilterMode("all"); + setSelectedTags([]); + }; + + const selectUngrouped = () => { + setFilterMode("ungrouped"); + setSelectedTags([]); + }; + + const toggleTagFilter = (tag: string) => { + const nextTags = selectedTagSet.has(tag) + ? selectedTags.filter((selected) => selected !== tag) + : [...selectedTags, tag]; + setSelectedTags(nextTags); + setFilterMode(nextTags.length === 0 ? "all" : "tags"); + }; + + const filtered = profiles.filter((p) => { + const visibleTags = getVisibleTags(p); + const matchesSearch = getProfileSearchText(p).includes(normalizedSearch); + const matchesTag = (() => { + if (filterMode === "all") return true; + if (filterMode === "ungrouped") return visibleTags.length === 0; + if (selectedTags.length === 0) return true; + + const profileTags = new Set(visibleTags.map((tag) => tag.tag)); + return selectedTags.every((tag) => profileTags.has(tag)); + })(); + return matchesSearch && matchesTag; + }); + const runningCount = profiles.filter((p) => p.status === "running").length; return ( @@ -43,6 +150,48 @@ export function ProfileList({ profiles, selectedId, onSelect, onNew }: ProfileLi className="input pl-8 py-1.5 text-xs" />
+ {profiles.length > 0 && ( +
+ + + {tagFilters.map((tag) => ( + + ))} +
+ )}
{/* Profile list */} @@ -52,44 +201,53 @@ export function ProfileList({ profiles, selectedId, onSelect, onNew }: ProfileLi {profiles.length === 0 ? "No profiles yet" : "No matches"}
)} - {filtered.map((profile) => ( - - ))} + ); + })} {/* New profile button */} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fb502009..d2b87e4c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -73,6 +73,10 @@ export interface SystemStatus { profiles_total: number; } +export interface RuntimeConfig { + sidebar_width: string; +} + class ApiError extends Error { constructor( public status: number, @@ -147,6 +151,8 @@ export const api = { getStatus: () => request("/api/status"), + getConfig: () => request("/api/config"), + setClipboard: (id: string, text: string) => request<{ ok: boolean }>(`/api/profiles/${id}/clipboard`, { method: "POST",