Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,8 @@ database.db-*
/packages/*/dist
/packages/*/**/node_modules
/apps/*/**/node_modules
/supabase
/supabase
/tests/load/.tmp
/tests/chaos/.tmp
/tests/load/config.test.toml.local
/hls/*
66 changes: 66 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# CLAUDE.md

Guidance for Claude Code working in the Vessel repository.

## Project

Vessel is C2 (Command & Control) software for connecting, monitoring, and orchestrating physical sensors through a visual flow-based interface. Local-first, offline-first. Apache-2.0.

## Repository layout

Cargo + npm workspaces (monorepo).

- `apps/server` — Rust (axum, tokio) backend. SQLite via Diesel (`migrations/`, schema in `src/db/schema.rs`). MQTT broker (rumqttd) + client (rumqttc), WebRTC, RTP/RTSP via GStreamer, ONNX inference (ort/tract). Entry: `src/main.rs`. Routes wired in `src/routes.rs`, handlers under `src/handler/`. Flow engine in `src/flow/` (engine, manager, nodes). Built-in flow nodes live in `src/flow/nodes/`. The compiled server embeds the client `dist/` via `rust-embed`.
- `apps/client` — React 19 + Vite + TypeScript + Tailwind v4 + Radix + Zustand. FSD-ish layout: `app/`, `pages/`, `widgets/`, `features/`, `entities/`, `shared/`, plus `components/ui` (shadcn) and `hooks/`, `contexts/`, `lib/`. Routing in `src/App.tsx` (react-router v7).
- `apps/desktop` — Tauri v2 shell. The Tauri build runs `cargo build --release -p server` and bundles the server binary as a sidecar; frontend served from `apps/client/dist`. Crate at `apps/desktop/src-tauri`.
- `apps/landing` — Marketing site (React + Vite). Deployed at vessel.cartesiancs.com.
- `apps/capsule` — Standalone Rust service for "Capsule" zero-knowledge LLM proxy (X25519 + ChaCha20-Poly1305). Has its own Dockerfile/docker-compose.
- `packages/capsule-client` — TS client SDK for Capsule (`@vessel/capsule-client`). Built before `client` in the build chain.
- `packages/custom-node-utils` — Python helpers for user-authored custom flow nodes.
- `packages/shared-types` — empty placeholder.
- `docs/` — VitePress site (vessel.cartesiancs.com/docs).
- `tests/` — Jest + supertest E2E against a running server at `http://localhost:6174`. Default creds `admin/admin1`.
- `configs/`, `config.toml`, `.env.example` — server config (jwt_secret, listen_address, database_url).
- `migrations/` lives under `apps/server/`. `database.db` at repo root is the dev DB.

## Common commands

Run from repo root unless noted.

- `npm run server` — `cargo run -p server` (debug). `npm run server:prod` for release.
- `npm run client` — Vite dev server for the React client.
- `npm run desktop` — Tauri dev (builds capsule-client + client + release server, then launches shell). `npm run desktop:build` to package.
- `npm run landing` — landing site dev server.
- `npm run capsule` — capsule service.
- `npm run build` — `cargo build --release` of the server only. Output: `target/release/server` (needs a `.env` next to it to run).
- `npm run client:build` — builds capsule-client then the client.
- `npm test` — Jest E2E in `tests/`. Server must already be running on `:6174` with seeded admin.
- `npm run docs:dev` / `docs:build` — VitePress.
- Diesel: `cd apps/server && diesel setup && diesel migration run` (requires diesel_cli). Migrations are also embedded and auto-run on server boot via `MIGRATIONS`.

## Architecture notes

- The server is the integration point: HTTP/WS API, embedded client UI, MQTT broker + client, RTP receiver, RTSP puller, WebRTC, recording manager, flow engine, tunnel manager. All share `Arc<AppState>` (`apps/server/src/state.rs`). Background tasks are spawned into a `JoinSet` driven by a `watch` shutdown channel — prefer that pattern for new long-running tasks.
- Flow runtime: `FlowManagerActor` (mpsc-driven) owns the live engine. New node types go in `src/flow/nodes/` and are registered in `mod.rs`. Engine/types in `src/flow/engine.rs` and `src/flow/types.rs`.
- DB access uses Diesel with an r2d2 pool. Repositories in `src/db/repository/`. When adding tables, add a migration *and* update `schema.rs` (`diesel print-schema`) and models.
- Auth is JWT (`jsonwebtoken`). Initial admin is seeded by `init::db_record::create_initial_admin`. RBAC tables created by the `2025-09-08_create_rbac_tables` migration; permissions seeded on boot.
- Client follows feature-sliced design. New screens: add a route in `src/App.tsx`, page under `pages/`, feature logic under `features/<name>/`, domain models under `entities/<name>/`, reusable UI in `components/ui` (shadcn-style) or `widgets/`. State is Zustand; data fetching is axios in `shared/api`.
- Maps use MapLibre/Leaflet. Code editor uses Monaco + CodeMirror. Charts via d3.
- Desktop sidecar: `apps/desktop/src-tauri/src/main.rs` launches the bundled server binary; client detects desktop via `useDesktopSidecar`. A separate `desktop_settings` window is dispatched in `App.tsx` based on URL params.

## Coding rules (enforced by `CODE_RULE.md`)

Mission-critical posture. Highlights to keep in mind:

- Fail-safe: every fallible op returns `Result`/`Promise`; handle both arms — no silent failures. Lints fail builds on unhandled results.
- Deterministic: no recursion (use iterative forms), no magic numbers (use `const`/`enum`), fixed-size buffers, no float `==` (use epsilon).
- Security by design: validate all external input at runtime, default to deny, least privilege, keep control flow simple.
- Concurrency: prefer message passing (we already do — `mpsc`/`broadcast`/`watch`) over shared mutable state.
- Tooling: zero warnings, format with `rustfmt` / `prettier` (`.prettierrc` is at repo root: 2-space, semi, double-quote, trailing-comma all, jsxSingleQuote), pass clippy/eslint at strict settings. Lockfiles (`Cargo.lock`, `package-lock.json`) are committed and authoritative.

## Conventions

- UI copy is **English only**. Even when prompts/issues are in Korean, ship English strings in `apps/client`.
- Don't create docs/markdown unless asked.
- Don't add comments for the "what"; only for non-obvious "why".
- Branch is `develop` for active work; PRs target `main`.
7 changes: 5 additions & 2 deletions apps/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@
"test:watch": "vitest"
},
"dependencies": {
"@vessel/capsule-client": "file:../../packages/capsule-client",
"@codemirror/lang-json": "^6.0.2",
"@emotion/react": "^11.14.0",
"@monaco-editor/react": "^4.7.0",
Expand All @@ -42,12 +41,14 @@
"@tauri-apps/api": "^2.2.0",
"@tauri-apps/plugin-shell": "^2.3.4",
"@uiw/react-codemirror": "^4.24.2",
"@vessel/capsule-client": "file:../../packages/capsule-client",
"autoprefixer": "^10.4.21",
"axios": "^1.11.0",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3": "^7.9.0",
"electron": "^35.0.0",
"hls.js": "^1.5.0",
"js-cookie": "^3.0.5",
"leaflet": "^1.9.4",
"maplibre-gl": "^5.6.2",
Expand All @@ -63,11 +64,13 @@
"zustand": "^5.0.5"
},
"devDependencies": {
"vitest": "^3.0.0",
"@eslint/js": "^9.21.0",
"@feature-sliced/steiger-plugin": "^0.5.7",
"@types/d3": "^7.4.3",
"@types/js-cookie": "^3.0.6",
"@types/leaflet": "^1.9.20",
"steiger": "^0.5.11",
"vitest": "^3.0.0",
"wait-on": "^8.0.3"
}
}
49 changes: 25 additions & 24 deletions apps/client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,33 @@
import { AuthPage } from "./pages/auth";
import { AuthPage } from "@/pages/auth";

import { createBrowserRouter, RouterProvider } from "react-router";
import {
DashboardSwipeLayout,
DashboardSwipeRoutePlaceholder,
} from "./features/dashboard-swipe/DashboardSwipeLayout";
import { DevicePage } from "./pages/devices";
import { FlowPage } from "./pages/flow";
import { AuthInterceptor } from "./features/auth/AuthInterceptor";
import { NotFound } from "./pages/notfound";
import LandingPage from "./pages/landing";
import { MapPage } from "./pages/map";
import { SetupPage } from "./pages/setup";
import { CodePage } from "./pages/code";
import { AuthenticatedLayout } from "./widgets/auth/AuthenticatedLayout";
import { TopBarWrapper } from "./widgets/auth/TopBarWrapper";
import { useDesktopSidecar } from "./hooks/useDesktopSidecar";
import { usePreventBackNavigation } from "./hooks/usePreventBackNavigation";
import { SettingsPage } from "./pages/settings";
import { AccountSettingsPage } from "./pages/settings/account";
import { ServicesSettingsPage } from "./pages/settings/services";
import { UsersSettingsPage } from "./pages/settings/users";
import { NetworksSettingsPage } from "./pages/settings/networks";
import { IntegrationSettingsPage } from "./pages/settings/integration";
import { LogSettingsPage } from "./pages/settings/log";
import { ConfigSettingsPage } from "./pages/settings/config";
import { RecordingsPage } from "./pages/recordings";
import { DesktopSettingsPage } from "./pages/desktop-settings";
} from "@/features/dashboard-swipe";
import { DevicePage } from "@/pages/devices";
import { FlowPage } from "@/pages/flow";
import { AuthInterceptor } from "@/features/auth";
import { NotFound } from "@/pages/notfound";
import LandingPage from "@/pages/landing";
import { MapPage } from "@/pages/map";
import { SetupPage } from "@/pages/setup";
import { CodePage } from "@/pages/code";
import { AuthenticatedLayout, TopBarWrapper } from "@/widgets/auth";
import { useDesktopSidecar } from "@/shared/lib/hooks/useDesktopSidecar";
import { usePreventBackNavigation } from "@/shared/lib/hooks/usePreventBackNavigation";
import {
SettingsPage,
AccountSettingsPage,
ServicesSettingsPage,
UsersSettingsPage,
NetworksSettingsPage,
IntegrationSettingsPage,
LogSettingsPage,
ConfigSettingsPage,
} from "@/pages/settings";
import { RecordingsPage } from "@/pages/recordings";
import { DesktopSettingsPage } from "@/pages/desktop-settings";

const router = createBrowserRouter([
{
Expand Down
2 changes: 1 addition & 1 deletion apps/client/src/app/pageWrapper/page-wrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { isElectron } from "@/lib/electron";
import { isElectron } from "@/shared/lib/electron";
import type { PropsWithChildren } from "react";

export function PageWrapper(props: PropsWithChildren) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
type ReactNode,
} from "react";
import type { Session } from "@supabase/supabase-js";
import { supabase } from "@/lib/supabase";
import { supabase } from "@/shared/lib/supabase";

const isTauri = (): boolean => {
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { apiClient } from "@/shared/api";
import type { SystemConfiguration, SystemConfigurationPayload } from "./types";
import type { SystemConfiguration, SystemConfigurationPayload } from "../model/types";

export const getConfigs = () =>
apiClient.get<SystemConfiguration[]>("/configurations");
Expand Down
5 changes: 5 additions & 0 deletions apps/client/src/entities/configurations/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export * from "./api";
export * from "./model/types";
export * from "./model/store";
export * from "./lib/codeService";
export * from "./lib/streamMode";
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SystemConfiguration } from "./types";
import type { SystemConfiguration } from "../model/types";

export const CODE_SERVICE_CONFIG_KEY = "code_service_enabled";

Expand Down
15 changes: 15 additions & 0 deletions apps/client/src/entities/configurations/lib/streamMode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { SystemConfiguration } from "../model/types";

export const STREAM_MODE_CONFIG_KEY = "default_stream_mode";

export type StreamMode = "webrtc" | "http";

const DEFAULT_MODE: StreamMode = "webrtc";

export function getDefaultStreamMode(
configurations: SystemConfiguration[],
): StreamMode {
const row = configurations.find((c) => c.key === STREAM_MODE_CONFIG_KEY);
if (!row) return DEFAULT_MODE;
return row.value === "http" ? "http" : "webrtc";
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { create } from "zustand";
import * as api from "./api";
import * as api from "../api";
import type { SystemConfiguration, SystemConfigurationPayload } from "./types";

interface ConfigState {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { apiClient } from "@/shared/api";
import { CustomNodeDynamicData, CustomNodeFromApi } from "./types";
import { CustomNodeDynamicData, CustomNodeFromApi } from "../model/types";

export const getAllCustomNodes = async (): Promise<CustomNodeFromApi[]> => {
const response = await apiClient.get("/custom-nodes");
Expand Down
4 changes: 4 additions & 0 deletions apps/client/src/entities/custom-nodes/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "./api";
export * from "./model/types";
export * from "./model/store";
export * from "./lib/presets";
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { create } from "zustand";
import { CustomNodeState } from "./types";
import * as api from "./api";
import * as api from "../api";

// const parseNodeData = (nodeFromApi: CustomNodeFromApi): CustomNodeFromApi => {
// try {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { apiClient } from "@/shared/api";
import type { DeviceToken, IssuedTokenResponse } from "./types";
import type { DeviceToken, IssuedTokenResponse } from "../model/types";

export const issueDeviceToken = (deviceId: number) =>
apiClient.post<IssuedTokenResponse>(`/devices/${deviceId}/token`);
Expand Down
3 changes: 3 additions & 0 deletions apps/client/src/entities/device-token/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./api";
export * from "./model/types";
export * from "./model/store";
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { create } from "zustand";
import * as api from "./api";
import * as api from "../api";
import type { DeviceToken } from "./types";

interface DeviceTokenState {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { apiClient } from "@/shared/api";
import type { Device, DevicePayload, DeviceWithEntity } from "./types";
import type { Device, DevicePayload, DeviceWithEntity } from "../model/types";

export const getDevices = () => apiClient.get<Device[]>("/devices");
export const getDeviceById = (pk_id: number) =>
Expand Down
3 changes: 3 additions & 0 deletions apps/client/src/entities/device/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./api";
export * from "./model/types";
export * from "./model/store";
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { create } from "zustand";
import * as api from "./api";
import * as api from "../api";
import type { Device, DevicePayload } from "./types";

interface DeviceState {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EntityAll } from "../entity/types";
import { EntityAll } from "../../entity/model/types";

export interface Device {
id: number;
Expand Down
4 changes: 4 additions & 0 deletions apps/client/src/entities/dynamic-dashboard/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export * from "./api";
export * from "./model/store";
export * from "./lib/layoutResolve";
export * from "./lib/interaction";
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { DashboardGroup, DashboardItem } from "./store";
import type { DashboardGroup, DashboardItem } from "../model/store";

/** Clamp top-left grid position to group bounds (matches store behavior). */
export function clampItemPosition(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { create } from "zustand";
import * as api from "./api";
import { clampItemPosition, itemsCollide } from "./layoutResolve";
import * as api from "../api";
import { clampItemPosition, itemsCollide } from "../lib/layoutResolve";

export type DashboardItemType =
| "entity-card"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { apiClient } from "@/shared/api";
import type { Entity, EntityAll, EntityPayload, State } from "./types";
import type { Entity, EntityAll, EntityPayload, State } from "../model/types";

export const getEntities = () => apiClient.get<Entity[]>("/entities");
export const getAllEntities = () => apiClient.get<EntityAll[]>("/entities/all");
Expand Down
3 changes: 3 additions & 0 deletions apps/client/src/entities/entity/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./api";
export * from "./model/types";
export * from "./model/store";
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { create } from "zustand";
import * as api from "./api";
import * as api from "../api";
import type { Entity, EntityPayload } from "./types";

interface EntityState {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { DirEntry } from "./types";
import { DirEntry } from "../model/types";
import { apiClient } from "@/shared/api";

export const getDirectoryListing = async (
Expand Down
3 changes: 3 additions & 0 deletions apps/client/src/entities/file/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./api";
export * from "./model/types";
export * from "./model/store";
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { create } from "zustand";
import { IdeState } from "./types";
import { getFileContent, updateFileContent } from "./api";
import { getFileContent, updateFileContent } from "../api";
import { toast } from "sonner";

export interface FileTreeState {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { apiClient } from "@/shared/api";
import { Flow, FlowPayload, FlowVersion, FlowVersionPayload } from "./types";
import { Flow, FlowPayload, FlowVersion, FlowVersionPayload } from "../model/types";

export const getFlows = async (): Promise<Flow[]> => {
const { data } = await apiClient.get<Flow[]>("/flows");
Expand Down
3 changes: 3 additions & 0 deletions apps/client/src/entities/flow/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./api";
export * from "./model/types";
export * from "./model/store";
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { create } from "zustand";
import { DataNodeType, Edge, Node } from "@/features/flow/flowTypes";
import { DataNodeType, Edge, Node } from "@/features/flow";
import {
getFlows,
getFlowVersions,
saveFlowVersion,
createFlow,
} from "@/entities/flow/api";
import { Flow } from "@/entities/flow/types";
} from "@/entities/flow";
import { Flow } from "@/entities/flow";

interface FlowState {
flows: Flow[];
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { apiClient } from "@/shared/api";

import { HaState } from "./types";
import { HaState } from "../model/types";

export const fetchAllHaStates = async (): Promise<HaState[]> => {
try {
Expand Down
3 changes: 3 additions & 0 deletions apps/client/src/entities/ha/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export * from "./api";
export * from "./model/types";
export * from "./model/store";
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { create } from "zustand";
import { HaStateStore } from "./types";
import { fetchAllHaStates } from "./api";
import { fetchAllHaStates } from "../api";

export const useHaStore = create<HaStateStore>((set) => ({
states: [],
Expand Down
Loading