From 8b7dcce1ef006c0ec7515031c7e2a763c942fc6d Mon Sep 17 00:00:00 2001 From: GeJiaXiang <353358601@qq.com> Date: Sun, 28 Jun 2026 23:54:22 +0800 Subject: [PATCH 1/9] feat: add profile list tag filter tabs --- frontend/src/components/ProfileList.test.tsx | 120 +++++++++++++++++++ frontend/src/components/ProfileList.tsx | 120 +++++++++++++++++-- 2 files changed, 233 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/ProfileList.test.tsx diff --git a/frontend/src/components/ProfileList.test.tsx b/frontend/src/components/ProfileList.test.tsx new file mode 100644 index 00000000..4f3ff808 --- /dev/null +++ b/frontend/src/components/ProfileList.test.tsx @@ -0,0 +1,120 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { Profile } from "../lib/api"; +import { ProfileList } from "./ProfileList"; + +function profile(overrides: Partial): 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("filters profiles with single-select tag tabs", () => { + renderList([ + 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("tab", { name: /^All\s+2$/ }).getAttribute("aria-selected")).toBe("true"); + expect(screen.queryByRole("button", { name: /Filter by tag/ })).toBeNull(); + + fireEvent.click(screen.getByRole("tab", { name: /^claude\s+1$/ })); + + expect(screen.queryByText("Claude Account")).not.toBeNull(); + expect(screen.queryByText("Team Account")).toBeNull(); + expect(screen.getByRole("tab", { name: /^claude\s+1$/ }).getAttribute("aria-selected")).toBe("true"); + + fireEvent.click(screen.getByRole("tab", { name: /^All\s+2$/ })); + + expect(screen.queryByText("Claude Account")).not.toBeNull(); + expect(screen.queryByText("Team Account")).not.toBeNull(); + }); + + 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(); + }); +}); diff --git a/frontend/src/components/ProfileList.tsx b/frontend/src/components/ProfileList.tsx index c4bcfc8f..090335c5 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,12 +10,71 @@ 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; +} + +function getTagFilters(profiles: Profile[]): TagFilter[] { + const tags = new Map(); + + profiles.forEach((profile) => { + profile.tags.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 tagTabClass(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 [selectedTag, setSelectedTag] = useState(null); + const normalizedSearch = search.trim().toLowerCase(); + const tagFilters = useMemo(() => getTagFilters(profiles), [profiles]); - const filtered = profiles.filter((p) => - p.name.toLowerCase().includes(search.toLowerCase()), - ); + useEffect(() => { + if (selectedTag && !tagFilters.some((tag) => tag.tag === selectedTag)) { + setSelectedTag(null); + } + }, [selectedTag, tagFilters]); + + const filtered = profiles.filter((p) => { + const matchesSearch = getProfileSearchText(p).includes(normalizedSearch); + const matchesTag = selectedTag === null || p.tags.some((tag) => tag.tag === selectedTag); + return matchesSearch && matchesTag; + }); const runningCount = profiles.filter((p) => p.status === "running").length; @@ -43,6 +102,40 @@ export function ProfileList({ profiles, selectedId, onSelect, onNew }: ProfileLi className="input pl-8 py-1.5 text-xs" /> + {tagFilters.length > 0 && ( +
+ + {tagFilters.map((tag) => ( + + ))} +
+ )} {/* Profile list */} @@ -53,10 +146,18 @@ export function ProfileList({ profiles, selectedId, onSelect, onNew }: ProfileLi )} {filtered.map((profile) => ( - + {profile.notes && ( +
+ {profile.notes} +
+ )} + ))} From 9983938dd810e95a21d3342983f63353d458e262 Mon Sep 17 00:00:00 2001 From: GeJiaXiang <353358601@qq.com> Date: Mon, 29 Jun 2026 09:33:51 +0800 Subject: [PATCH 2/9] feat: allow runtime sidebar width configuration --- README.md | 17 ++++++++++++ backend/main.py | 21 +++++++++++++++ backend/models.py | 4 +++ backend/tests/test_api.py | 21 +++++++++++++++ docker-compose.yml | 1 + frontend/src/App.test.tsx | 54 +++++++++++++++++++++++++++++++++++++++ frontend/src/App.tsx | 31 ++++++++++++++++++++-- frontend/src/lib/api.ts | 6 +++++ 8 files changed, 153 insertions(+), 2 deletions(-) create mode 100644 frontend/src/App.test.tsx diff --git a/README.md b/README.md index e413c555..7cd9b80e 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,23 @@ When `AUTH_TOKEN` is set: > **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/main.py b/backend/main.py index 92727a53..b931b43d 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, ) @@ -49,6 +51,8 @@ # 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. 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"}) @@ -180,6 +184,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 +420,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: 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/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 && ( -
+
request("/api/status"), + getConfig: () => request("/api/config"), + setClipboard: (id: string, text: string) => request<{ ok: boolean }>(`/api/profiles/${id}/clipboard`, { method: "POST", From 99d3532d78cb050b88dc5cd9b9290094d606e60c Mon Sep 17 00:00:00 2001 From: GeJiaXiang <353358601@qq.com> Date: Mon, 29 Jun 2026 14:02:23 +0800 Subject: [PATCH 3/9] feat: compact profile list metadata --- frontend/src/components/ProfileList.test.tsx | 26 +++++++++++++++++- frontend/src/components/ProfileList.tsx | 28 +++++++------------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/frontend/src/components/ProfileList.test.tsx b/frontend/src/components/ProfileList.test.tsx index 4f3ff808..bf927c53 100644 --- a/frontend/src/components/ProfileList.test.tsx +++ b/frontend/src/components/ProfileList.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import type { Profile } from "../lib/api"; import { ProfileList } from "./ProfileList"; @@ -117,4 +117,28 @@ describe("ProfileList", () => { 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 }], + }), + ]); + + 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 090335c5..c73c8992 100644 --- a/frontend/src/components/ProfileList.tsx +++ b/frontend/src/components/ProfileList.tsx @@ -157,7 +157,7 @@ export function ProfileList({ profiles, selectedId, onSelect, onNew }: ProfileLi onSelect(profile.id); } }} - className={`w-full cursor-pointer text-left px-3 py-2.5 rounded-md mb-1 transition-colors focus:outline-none focus:ring-1 focus:ring-accent/50 ${ + className={`w-full cursor-pointer text-left px-3 py-1 rounded-md mb-1 transition-colors focus:outline-none focus:ring-1 focus:ring-accent/50 ${ selectedId === profile.id ? "bg-surface-3 border border-border-hover" : "hover:bg-surface-2 border border-transparent" @@ -167,31 +167,23 @@ export function ProfileList({ profiles, selectedId, onSelect, onNew }: ProfileLi {profile.name}
-
- {profile.platform} - {profile.proxy && ( - <> - · - Proxy - - )} -
- {profile.tags.length > 0 && ( -
+ {(profile.tags.length > 0 || profile.notes) && ( +
{profile.tags.map((t) => ( {t.tag} ))} -
- )} - {profile.notes && ( -
- {profile.notes} + {profile.notes && ( + + {profile.notes} + + )}
)}
From 8ad384f6ae69b27a57e2967930f8e05d2a4f0407 Mon Sep 17 00:00:00 2001 From: GeJiaXiang <353358601@qq.com> Date: Mon, 29 Jun 2026 14:52:24 +0800 Subject: [PATCH 4/9] feat: wrap profile tag filters --- frontend/src/components/ProfileList.test.tsx | 19 +++++++++++++++++++ frontend/src/components/ProfileList.tsx | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/ProfileList.test.tsx b/frontend/src/components/ProfileList.test.tsx index bf927c53..4d462c1f 100644 --- a/frontend/src/components/ProfileList.test.tsx +++ b/frontend/src/components/ProfileList.test.tsx @@ -98,6 +98,25 @@ describe("ProfileList", () => { expect(screen.queryByText("Team Account")).not.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 tablist = screen.getByRole("tablist", { name: "Profile tag filters" }); + expect(tablist.className).toContain("flex-wrap"); + expect(tablist.className).not.toContain("overflow-x-auto"); + }); + it("filters profiles by notes and renders notes in each list item", () => { renderList([ profile({ diff --git a/frontend/src/components/ProfileList.tsx b/frontend/src/components/ProfileList.tsx index c73c8992..b83ba71f 100644 --- a/frontend/src/components/ProfileList.tsx +++ b/frontend/src/components/ProfileList.tsx @@ -106,7 +106,7 @@ export function ProfileList({ profiles, selectedId, onSelect, onNew }: ProfileLi
- {tagFilters.length > 0 && ( + {profiles.length > 0 && (
setSelectedTag(null)} className={tagTabClass(selectedTag === null)} > - All - {profiles.length} + 未分组 + {ungroupedCount} {tagFilters.map((tag) => (
{profiles.length > 0 && (
+