Publish LumaOps source
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "coverage"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommendedTypeChecked],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
globals: globals.browser,
|
||||
parserOptions: {
|
||||
project: ["./tsconfig.app.json", "./tsconfig.node.json"],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-misused-promises": ["error", { checksVoidReturn: { attributes: false } }],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0b1020" />
|
||||
<meta name="description" content="LumaOps lokaal RGB- en smart-lightingbeheer" />
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/favicon.svg" />
|
||||
<title>LumaOps</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+4845
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "lumaops-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint . --max-warnings 0",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"preview": "vite preview --host 0.0.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "5.80.7",
|
||||
"lucide-react": "0.511.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-router-dom": "7.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.28.0",
|
||||
"@testing-library/jest-dom": "6.6.3",
|
||||
"@testing-library/react": "16.3.0",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/node": "22.15.29",
|
||||
"@types/react": "19.1.6",
|
||||
"@types/react-dom": "19.1.5",
|
||||
"@vitejs/plugin-react": "4.5.1",
|
||||
"eslint": "9.28.0",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-react-refresh": "0.4.20",
|
||||
"globals": "16.2.0",
|
||||
"jsdom": "26.1.0",
|
||||
"typescript": "5.8.3",
|
||||
"typescript-eslint": "8.33.1",
|
||||
"vite": "6.4.3",
|
||||
"vitest": "3.2.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<defs>
|
||||
<linearGradient id="luma" x1="14" y1="10" x2="51" y2="53" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#9EA5FF"/>
|
||||
<stop offset="0.52" stop-color="#6570FF"/>
|
||||
<stop offset="1" stop-color="#36D9B6"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="64" height="64" rx="15" fill="#0B1020"/>
|
||||
<path d="M19 15v29c0 3.3 2.7 6 6 6h25" fill="none" stroke="url(#luma)" stroke-width="8" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="47" cy="19" r="5" fill="#36D9B6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 589 B |
@@ -0,0 +1,113 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { lazy, Suspense, useState, type FormEvent, type ReactNode } from "react";
|
||||
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
|
||||
import { api } from "./api/client";
|
||||
import type { SetupState } from "./api/types";
|
||||
import { AppShell } from "./components/AppShell";
|
||||
|
||||
type AuthStatus = { auth_enabled: boolean; authenticated: boolean };
|
||||
|
||||
const AboutPage = lazy(() => import("./pages/AboutPage").then((module) => ({ default: module.AboutPage })));
|
||||
const ActivityPage = lazy(() => import("./pages/ActivityPage").then((module) => ({ default: module.ActivityPage })));
|
||||
const AutomationsPage = lazy(() => import("./pages/AutomationsPage").then((module) => ({ default: module.AutomationsPage })));
|
||||
const BackupsPage = lazy(() => import("./pages/BackupsPage").then((module) => ({ default: module.BackupsPage })));
|
||||
const ConnectorsPage = lazy(() => import("./pages/ConnectorsPage").then((module) => ({ default: module.ConnectorsPage })));
|
||||
const DeviceDetailPage = lazy(() => import("./pages/DeviceDetailPage").then((module) => ({ default: module.DeviceDetailPage })));
|
||||
const DeviceGroupPage = lazy(() => import("./pages/DeviceGroupPage").then((module) => ({ default: module.DeviceGroupPage })));
|
||||
const DevicesPage = lazy(() => import("./pages/DevicesPage").then((module) => ({ default: module.DevicesPage })));
|
||||
const DiagnosticsPage = lazy(() => import("./pages/DiagnosticsPage").then((module) => ({ default: module.DiagnosticsPage })));
|
||||
const DiscoveryPage = lazy(() => import("./pages/DiscoveryPage").then((module) => ({ default: module.DiscoveryPage })));
|
||||
const NetworkPage = lazy(() => import("./pages/NetworkPage").then((module) => ({ default: module.NetworkPage })));
|
||||
const OverviewPage = lazy(() => import("./pages/OverviewPage").then((module) => ({ default: module.OverviewPage })));
|
||||
const ScenesPage = lazy(() => import("./pages/ScenesPage").then((module) => ({ default: module.ScenesPage })));
|
||||
const SettingsPage = lazy(() => import("./pages/SettingsPage").then((module) => ({ default: module.SettingsPage })));
|
||||
const SetupPage = lazy(() => import("./pages/SetupPage").then((module) => ({ default: module.SetupPage })));
|
||||
const SpacesPage = lazy(() => import("./pages/SpacesPage").then((module) => ({ default: module.SpacesPage })));
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<AuthGate>
|
||||
<Suspense fallback={<div className="app-loading"><span className="brand__mark" /><p>Pagina laden…</p></div>}>
|
||||
<Routes>
|
||||
<Route path="/setup" element={<SetupPage />} />
|
||||
<Route element={<SetupGate />}>
|
||||
<Route index element={<OverviewPage />} />
|
||||
<Route path="devices" element={<DevicesPage />} />
|
||||
<Route path="devices/:deviceId" element={<DeviceDetailPage />} />
|
||||
<Route path="device-groups/:groupId" element={<DeviceGroupPage />} />
|
||||
<Route path="spaces" element={<SpacesPage />} />
|
||||
<Route path="scenes" element={<ScenesPage />} />
|
||||
<Route path="automations" element={<AutomationsPage />} />
|
||||
<Route path="network" element={<NetworkPage />} />
|
||||
<Route path="connectors" element={<ConnectorsPage />} />
|
||||
<Route path="discovery" element={<DiscoveryPage />} />
|
||||
<Route path="activity" element={<ActivityPage mode="activity" />} />
|
||||
<Route path="audit" element={<ActivityPage mode="audit" />} />
|
||||
<Route path="diagnostics" element={<DiagnosticsPage />} />
|
||||
<Route path="backups" element={<BackupsPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="about" element={<AboutPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</AuthGate>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const queryClient = useQueryClient();
|
||||
const [token, setToken] = useState("");
|
||||
const status = useQuery({
|
||||
queryKey: ["auth-status"],
|
||||
queryFn: () => api<AuthStatus>("/api/v1/auth/status"),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const login = useMutation({
|
||||
mutationFn: () => api<AuthStatus>("/api/v1/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token }),
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
setToken("");
|
||||
await queryClient.invalidateQueries({ queryKey: ["auth-status"] });
|
||||
},
|
||||
});
|
||||
|
||||
function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
login.mutate();
|
||||
}
|
||||
|
||||
if (status.isLoading) {
|
||||
return <div className="app-loading"><span className="brand__mark" /><p>Beveiligde sessie controleren…</p></div>;
|
||||
}
|
||||
if (status.isError) {
|
||||
return <main className="login-shell"><section className="login-panel" role="alert"><h1>LumaOps is niet bereikbaar</h1><p>Controleer de server en vernieuw deze pagina.</p></section></main>;
|
||||
}
|
||||
if (status.data?.auth_enabled && !status.data.authenticated) {
|
||||
return (
|
||||
<main className="login-shell">
|
||||
<section className="login-panel">
|
||||
<span className="brand__mark" aria-hidden />
|
||||
<div><h1>Aanmelden bij LumaOps</h1><p>Voer de beheertoken van deze installatie in.</p></div>
|
||||
<form onSubmit={submit}>
|
||||
<label htmlFor="admin-token">Beheertoken</label>
|
||||
<input id="admin-token" type="password" autoComplete="current-password" value={token} onChange={(event) => setToken(event.target.value)} minLength={32} required autoFocus />
|
||||
{login.isError ? <p className="login-error" role="alert">Aanmelden mislukt. Controleer de token.</p> : null}
|
||||
<button className="button button--primary" type="submit" disabled={login.isPending}>{login.isPending ? "Aanmelden…" : "Aanmelden"}</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
function SetupGate() {
|
||||
const location = useLocation();
|
||||
const setup = useQuery({ queryKey: ["setup"], queryFn: () => api<SetupState>("/api/v1/setup"), staleTime: 30_000 });
|
||||
if (setup.isLoading) return <div className="app-loading"><span className="brand__mark" /><p>LumaOps voorbereiden…</p></div>;
|
||||
if (setup.data && !setup.data.completed) return <Navigate to="/setup" replace state={{ from: location.pathname }} />;
|
||||
return <AppShell />;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { AuthGate } from "./App";
|
||||
import { jsonResponse, renderApp, requestJson, requestUrl } from "./test/render";
|
||||
|
||||
afterEach(() => vi.mocked(globalThis.fetch).mockRestore());
|
||||
|
||||
test("shows protected content when authentication is disabled", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||
jsonResponse({ auth_enabled: false, authenticated: true }),
|
||||
);
|
||||
renderApp(<AuthGate><div>protected content</div></AuthGate>);
|
||||
expect(await screen.findByText("protected content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("requires the admin token before rendering protected content", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation((input, init) => {
|
||||
const url = requestUrl(input);
|
||||
if (url.endsWith("/auth/login")) {
|
||||
expect(requestJson(init)).toEqual({ token: "a".repeat(32) });
|
||||
return Promise.resolve(jsonResponse({ auth_enabled: true, authenticated: true }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse({
|
||||
auth_enabled: true,
|
||||
authenticated: fetchMock.mock.calls.some(([, request]) => request?.method === "POST"),
|
||||
}));
|
||||
});
|
||||
const user = userEvent.setup();
|
||||
renderApp(<AuthGate><div>protected content</div></AuthGate>);
|
||||
|
||||
await user.type(await screen.findByLabelText("Beheertoken"), "a".repeat(32));
|
||||
await user.click(screen.getByRole("button", { name: "Aanmelden" }));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("protected content")).toBeInTheDocument());
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mutationId } from "./client";
|
||||
|
||||
const originalCrypto = globalThis.crypto;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, "crypto", { configurable: true, value: originalCrypto });
|
||||
});
|
||||
|
||||
describe("mutationId", () => {
|
||||
it("uses randomUUID when the browser exposes it", () => {
|
||||
Object.defineProperty(globalThis, "crypto", {
|
||||
configurable: true,
|
||||
value: { randomUUID: () => "11111111-2222-4333-8444-555555555555" },
|
||||
});
|
||||
|
||||
expect(mutationId()).toBe("11111111-2222-4333-8444-555555555555");
|
||||
});
|
||||
|
||||
it("creates a UUID v4 when randomUUID is unavailable on an HTTP LAN origin", () => {
|
||||
const getRandomValues = vi.fn((bytes: Uint8Array) => {
|
||||
bytes.fill(0);
|
||||
return bytes;
|
||||
});
|
||||
Object.defineProperty(globalThis, "crypto", {
|
||||
configurable: true,
|
||||
value: { getRandomValues },
|
||||
});
|
||||
|
||||
expect(mutationId()).toBe("00000000-0000-4000-8000-000000000000");
|
||||
expect(getRandomValues).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("keeps the UUID format when Web Crypto is entirely unavailable", () => {
|
||||
Object.defineProperty(globalThis, "crypto", { configurable: true, value: undefined });
|
||||
vi.spyOn(Math, "random").mockReturnValue(0);
|
||||
|
||||
expect(mutationId()).toBe("00000000-0000-4000-8000-000000000000");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { ErrorEnvelope } from "./types";
|
||||
|
||||
export class ApiError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
readonly requestId?: string;
|
||||
readonly recovery: string[];
|
||||
|
||||
constructor(status: number, payload: ErrorEnvelope) {
|
||||
super(payload.error.message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
this.code = payload.error.code;
|
||||
this.requestId = payload.error.request_id;
|
||||
this.recovery = payload.error.recovery;
|
||||
}
|
||||
}
|
||||
|
||||
function csrfToken(): string | undefined {
|
||||
return document.cookie
|
||||
.split(";")
|
||||
.map((part) => part.trim())
|
||||
.find((part) => part.startsWith("lumaops_csrf="))
|
||||
?.split("=")[1];
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
if (init.body && !(init.body instanceof FormData)) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
const csrf = csrfToken();
|
||||
if (csrf && !["GET", "HEAD", "OPTIONS"].includes(init.method ?? "GET")) {
|
||||
headers.set("X-CSRF-Token", decodeURIComponent(csrf));
|
||||
}
|
||||
const response = await fetch(path, { ...init, headers, credentials: "same-origin" });
|
||||
if (!response.ok) {
|
||||
const fallback: ErrorEnvelope = {
|
||||
error: {
|
||||
code: "http_error",
|
||||
message: `HTTP ${response.status}`,
|
||||
request_id: response.headers.get("X-Request-ID") ?? "",
|
||||
details: {},
|
||||
recovery: [],
|
||||
},
|
||||
};
|
||||
let payload = fallback;
|
||||
try {
|
||||
payload = (await response.json()) as ErrorEnvelope;
|
||||
} catch {
|
||||
// The stable fallback keeps non-JSON proxy errors usable.
|
||||
}
|
||||
throw new ApiError(response.status, payload);
|
||||
}
|
||||
if (response.status === 204) return undefined as T;
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export function mutationId(): string {
|
||||
const cryptoApi = globalThis.crypto;
|
||||
if (typeof cryptoApi?.randomUUID === "function") {
|
||||
return cryptoApi.randomUUID();
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(16);
|
||||
if (typeof cryptoApi?.getRandomValues === "function") {
|
||||
cryptoApi.getRandomValues(bytes);
|
||||
} else {
|
||||
// Idempotency keys are identifiers rather than secrets. This final fallback
|
||||
// keeps device control usable in older or restricted LAN browsers.
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
bytes[index] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
|
||||
const hex = Array.from(bytes, (value) => value.toString(16).padStart(2, "0"));
|
||||
return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export function useRealtimeUpdates(): void {
|
||||
const queryClient = useQueryClient();
|
||||
useEffect(() => {
|
||||
const events = new EventSource("/api/v1/events");
|
||||
const refresh = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["system"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["activity"] });
|
||||
};
|
||||
events.addEventListener("inventory.updated", refresh);
|
||||
events.addEventListener("command.completed", refresh);
|
||||
events.onerror = () => {
|
||||
// EventSource reconnects automatically. REST remains fully functional.
|
||||
};
|
||||
return () => events.close();
|
||||
}, [queryClient]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
export type HealthStatus = "healthy" | "degraded" | "unhealthy" | "unknown";
|
||||
|
||||
export interface ErrorEnvelope {
|
||||
error: {
|
||||
code: string;
|
||||
message: string;
|
||||
request_id: string;
|
||||
details: Record<string, unknown>;
|
||||
recovery: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface Page<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface RGBColor {
|
||||
red: number;
|
||||
green: number;
|
||||
blue: number;
|
||||
}
|
||||
|
||||
export interface DeviceState {
|
||||
power?: boolean | null;
|
||||
brightness?: number | null;
|
||||
colors?: RGBColor[] | null;
|
||||
mode?: string | null;
|
||||
mode_index?: number | null;
|
||||
speed?: number | null;
|
||||
direction?: number | null;
|
||||
zone_index?: number | null;
|
||||
led_index?: number | null;
|
||||
}
|
||||
|
||||
export interface Capabilities {
|
||||
power: boolean;
|
||||
restore: boolean;
|
||||
rgb: boolean;
|
||||
brightness: boolean;
|
||||
color_temperature: boolean;
|
||||
effect: boolean;
|
||||
speed: boolean;
|
||||
direction: boolean;
|
||||
multiple_colors: boolean;
|
||||
per_zone: boolean;
|
||||
per_segment: boolean;
|
||||
per_led: boolean;
|
||||
profiles: boolean;
|
||||
readable_state: boolean;
|
||||
max_leds: number;
|
||||
min_brightness: number;
|
||||
max_brightness: number;
|
||||
min_speed: number | null;
|
||||
max_speed: number | null;
|
||||
}
|
||||
|
||||
export interface DeviceZone {
|
||||
index: number;
|
||||
name: string;
|
||||
type: number;
|
||||
led_count: number;
|
||||
leds_min: number;
|
||||
leds_max: number;
|
||||
start_index?: number;
|
||||
resizable_effects_only?: boolean;
|
||||
segments?: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface DeviceMode {
|
||||
index: number;
|
||||
name: string;
|
||||
flags: number;
|
||||
speed_min: number | null;
|
||||
speed_max: number | null;
|
||||
brightness: boolean;
|
||||
colors_min: number;
|
||||
colors_max: number;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
id: string;
|
||||
connector_id: string;
|
||||
external_id: string;
|
||||
fingerprint: string;
|
||||
source: string;
|
||||
device_type: string;
|
||||
owner: string;
|
||||
name: string;
|
||||
alias: string | null;
|
||||
vendor: string | null;
|
||||
model: string | null;
|
||||
serial: string | null;
|
||||
location: string | null;
|
||||
ip_address: string | null;
|
||||
firmware_version: string | null;
|
||||
controller_index: number | null;
|
||||
capabilities: Capabilities;
|
||||
state: DeviceState;
|
||||
zones: DeviceZone[];
|
||||
modes: DeviceMode[];
|
||||
metadata: Record<string, unknown>;
|
||||
led_count: number;
|
||||
online: boolean;
|
||||
hidden: boolean;
|
||||
favorite: boolean;
|
||||
exclude_global: boolean;
|
||||
read_only: boolean;
|
||||
blocked: boolean;
|
||||
experimental: boolean;
|
||||
room_id: string | null;
|
||||
room_name?: string | null;
|
||||
tags: string[];
|
||||
error_status: string | null;
|
||||
last_detected_at: string | null;
|
||||
last_command_at: string | null;
|
||||
}
|
||||
|
||||
export interface DeviceGroup {
|
||||
id: string;
|
||||
kind: "device-family";
|
||||
device_type: string;
|
||||
connector_id: string;
|
||||
name: string;
|
||||
vendor: string | null;
|
||||
model: string | null;
|
||||
online: boolean;
|
||||
online_count: number;
|
||||
module_count: number;
|
||||
led_count: number;
|
||||
capabilities: Capabilities;
|
||||
state: DeviceState;
|
||||
modes: DeviceMode[];
|
||||
mixed: boolean;
|
||||
devices: Device[];
|
||||
}
|
||||
|
||||
export interface ComponentHealth {
|
||||
status: HealthStatus;
|
||||
message: string;
|
||||
connected?: boolean;
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
name: string;
|
||||
version: string;
|
||||
openrgb_version: string;
|
||||
sdk_protocol: number;
|
||||
environment: string;
|
||||
mock_mode: boolean;
|
||||
health: {
|
||||
status: HealthStatus;
|
||||
components: Record<string, ComponentHealth>;
|
||||
checked_at: string;
|
||||
};
|
||||
devices: { total: number; online: number; offline: number };
|
||||
active_scene: Scene | null;
|
||||
recent_commands: Command[];
|
||||
warnings: Activity[];
|
||||
emergency_stop: boolean;
|
||||
}
|
||||
|
||||
export interface Room {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
sort_order: number;
|
||||
device_count: number;
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
dynamic_query: { tags?: string[]; match?: "all" | "any" } | null;
|
||||
sort_order: number;
|
||||
device_count: number;
|
||||
devices?: Device[];
|
||||
}
|
||||
|
||||
export interface SceneItem {
|
||||
id: string;
|
||||
target_type: "device" | "group";
|
||||
target_id: string;
|
||||
state: DeviceState;
|
||||
required: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface Scene {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
favorite: boolean;
|
||||
version: number;
|
||||
item_count?: number;
|
||||
items?: SceneItem[];
|
||||
last_applied_at: string | null;
|
||||
}
|
||||
|
||||
export interface Automation {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
trigger: Record<string, unknown>;
|
||||
actions: Array<Record<string, unknown>>;
|
||||
timezone: string;
|
||||
cooldown_seconds: number;
|
||||
conflict_key: string | null;
|
||||
last_run_at: string | null;
|
||||
next_run_at: string | null;
|
||||
last_error: string | null;
|
||||
}
|
||||
|
||||
export interface AutomationRun {
|
||||
id: string;
|
||||
automation_id: string;
|
||||
status: string;
|
||||
trigger: Record<string, unknown>;
|
||||
result: unknown;
|
||||
error: string | null;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
export interface SceneApplyResult {
|
||||
scene_id: string;
|
||||
status: "succeeded" | "failed";
|
||||
applied: Array<{ device_id: string; status: string }>;
|
||||
failed: Array<{ device_id: string; error: string }>;
|
||||
skipped: Array<{ device_id: string; reason: string }>;
|
||||
rolled_back: Array<{ device_id: string; status: string }>;
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
id: string;
|
||||
target_id: string;
|
||||
status: string;
|
||||
action: string;
|
||||
created_at: string;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
id: string;
|
||||
category: string;
|
||||
severity: string;
|
||||
title: string;
|
||||
message: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SetupState {
|
||||
completed: boolean;
|
||||
current_step: string;
|
||||
report: Record<string, unknown>;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
ArchiveRestore,
|
||||
Blocks,
|
||||
Cable,
|
||||
ChevronLeft,
|
||||
CircleGauge,
|
||||
ClipboardList,
|
||||
Compass,
|
||||
Cpu,
|
||||
FlaskConical,
|
||||
Info,
|
||||
Lightbulb,
|
||||
Menu,
|
||||
Moon,
|
||||
Network,
|
||||
Orbit,
|
||||
Settings,
|
||||
Sun,
|
||||
WandSparkles,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { NavLink, Outlet } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import { useRealtimeUpdates } from "../api/hooks";
|
||||
import type { SystemStatus } from "../api/types";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { useTheme } from "../lib/theme";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Badge, IconButton, StatusBadge } from "./ui";
|
||||
|
||||
const navigation = [
|
||||
{ to: "/", key: "overview", icon: CircleGauge },
|
||||
{ to: "/devices", key: "devices", icon: Cpu },
|
||||
{ to: "/spaces", key: "spaces", icon: Blocks },
|
||||
{ to: "/scenes", key: "scenes", icon: WandSparkles },
|
||||
{ to: "/automations", key: "automations", icon: Orbit },
|
||||
{ to: "/network", key: "network", icon: Network },
|
||||
{ to: "/connectors", key: "connectors", icon: Cable },
|
||||
{ to: "/discovery", key: "discovery", icon: Compass },
|
||||
] as const;
|
||||
|
||||
const systemNavigation = [
|
||||
{ to: "/activity", key: "activity", icon: Activity },
|
||||
{ to: "/audit", key: "audit", icon: ClipboardList },
|
||||
{ to: "/diagnostics", key: "diagnostics", icon: FlaskConical },
|
||||
{ to: "/backups", key: "backups", icon: ArchiveRestore },
|
||||
{ to: "/settings", key: "settings", icon: Settings },
|
||||
{ to: "/about", key: "about", icon: Info },
|
||||
] as const;
|
||||
|
||||
export function AppShell() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const { t } = useI18n();
|
||||
const { resolved, setTheme } = useTheme();
|
||||
const system = useQuery({
|
||||
queryKey: ["system"],
|
||||
queryFn: () => api<SystemStatus>("/api/v1/system"),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
useRealtimeUpdates();
|
||||
|
||||
const nav = (items: typeof navigation | typeof systemNavigation) => items.map(({ to, key, icon: Icon }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={to === "/"}
|
||||
className={({ isActive }) => cn("nav-link", isActive && "nav-link--active")}
|
||||
onClick={() => setOpen(false)}
|
||||
title={collapsed ? t(key) : undefined}
|
||||
>
|
||||
<Icon size={18} aria-hidden />
|
||||
<span>{t(key)}</span>
|
||||
</NavLink>
|
||||
));
|
||||
|
||||
return (
|
||||
<div className={cn("app-shell", collapsed && "app-shell--collapsed")}>
|
||||
<aside className={cn("sidebar", open && "sidebar--open")}>
|
||||
<div className="brand">
|
||||
<span className="brand__mark"><Lightbulb size={20} aria-hidden /></span>
|
||||
<div className="brand__text"><strong>LumaOps</strong><span>OpenRGB control plane</span></div>
|
||||
<IconButton className="sidebar__close" label={t("closeMenu")} onClick={() => setOpen(false)}><X size={18} /></IconButton>
|
||||
</div>
|
||||
<nav aria-label="Hoofdnavigatie" className="sidebar__nav">
|
||||
<span className="nav-label">Beheer</span>
|
||||
{nav(navigation)}
|
||||
<span className="nav-label">Systeem</span>
|
||||
{nav(systemNavigation)}
|
||||
</nav>
|
||||
<button className="sidebar__collapse" onClick={() => setCollapsed((value) => !value)}>
|
||||
<ChevronLeft size={16} aria-hidden /><span>Navigatie inklappen</span>
|
||||
</button>
|
||||
</aside>
|
||||
{open ? <button className="sidebar-backdrop" aria-label={t("closeMenu")} onClick={() => setOpen(false)} /> : null}
|
||||
<div className="app-main">
|
||||
<header className="topbar">
|
||||
<IconButton className="mobile-menu" label={t("openMenu")} onClick={() => setOpen(true)}><Menu size={20} /></IconButton>
|
||||
<div className="topbar__status">
|
||||
{system.data ? <StatusBadge status={system.data.health.status} /> : <Badge>Laden…</Badge>}
|
||||
<span className="topbar__device-count">{system.data?.devices.online ?? 0} apparaten online</span>
|
||||
</div>
|
||||
<IconButton label={resolved === "dark" ? "Licht thema" : "Donker thema"} onClick={() => setTheme(resolved === "dark" ? "light" : "dark")}>
|
||||
{resolved === "dark" ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</IconButton>
|
||||
</header>
|
||||
{system.data?.mock_mode ? <div className="mock-banner"><FlaskConical size={16} /> {t("mockWarning")}</div> : null}
|
||||
<main className="page"><Outlet /></main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Save, X } from "lucide-react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import type { Automation, Page, Scene } from "../api/types";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { formValue } from "../lib/utils";
|
||||
import { Button, Card, CardHeader, Field, Input, Select } from "./ui";
|
||||
|
||||
export interface AutomationPayload {
|
||||
name: string;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
trigger: { type: "time"; at: string; weekdays: number[] };
|
||||
actions: Array<{ type: "scene"; scene_id: string }>;
|
||||
timezone: string;
|
||||
cooldown_seconds: number;
|
||||
conflict_key: string | null;
|
||||
}
|
||||
|
||||
interface AutomationEditorProps {
|
||||
automation?: Automation;
|
||||
scenes?: Page<Scene>;
|
||||
busy: boolean;
|
||||
onSubmit: (payload: AutomationPayload) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const weekdays = [
|
||||
[0, "Ma", "Mon"],
|
||||
[1, "Di", "Tue"],
|
||||
[2, "Wo", "Wed"],
|
||||
[3, "Do", "Thu"],
|
||||
[4, "Vr", "Fri"],
|
||||
[5, "Za", "Sat"],
|
||||
[6, "Zo", "Sun"],
|
||||
] as const;
|
||||
|
||||
export function AutomationEditor({ automation, scenes, busy, onSubmit, onClose }: AutomationEditorProps) {
|
||||
const { text } = useI18n();
|
||||
const selectedWeekdays = Array.isArray(automation?.trigger.weekdays) ? automation.trigger.weekdays.map(Number) : [0, 1, 2, 3, 4, 5, 6];
|
||||
const [selectedDays, setSelectedDays] = useState(selectedWeekdays);
|
||||
const sceneId = typeof automation?.actions[0]?.scene_id === "string" ? automation.actions[0].scene_id : "";
|
||||
const at = typeof automation?.trigger.at === "string" ? automation.trigger.at : "20:00";
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
onSubmit({
|
||||
name: formValue(data, "name").trim(),
|
||||
description: formValue(data, "description").trim() || null,
|
||||
enabled: data.get("enabled") === "on",
|
||||
trigger: { type: "time", at: formValue(data, "at"), weekdays: selectedDays },
|
||||
actions: [{ type: "scene", scene_id: formValue(data, "scene") }],
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
cooldown_seconds: Number(data.get("cooldown")),
|
||||
conflict_key: formValue(data, "conflict_key").trim() || null,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="resource-editor">
|
||||
<CardHeader title={automation ? text("Automation bewerken", "Edit automation") : text("Automation aanmaken", "Create automation")} description={text("Plan een scène op geselecteerde weekdagen en voorkom conflicterende uitvoeringen.", "Schedule a scene on selected weekdays and prevent conflicting runs.")} action={<Button type="button" variant="ghost" onClick={onClose}><X size={16} /> {text("Sluiten", "Close")}</Button>} />
|
||||
<form onSubmit={submit} className="form-stack">
|
||||
<div className="form-grid form-grid--two"><Field label={text("Naam", "Name")}><Input name="name" required autoFocus defaultValue={automation?.name ?? ""} placeholder={text("Avondverlichting", "Evening lights")} /></Field><Field label={text("Omschrijving", "Description")}><Input name="description" defaultValue={automation?.description ?? ""} /></Field></div>
|
||||
<div className="form-grid form-grid--three"><Field label={text("Tijdstip", "Time")}><Input name="at" type="time" required defaultValue={at} /></Field><Field label={text("Scène", "Scene")}><Select name="scene" required defaultValue={sceneId}><option value="">{text("Selecteer een scène", "Select a scene")}</option>{scenes?.items.map((scene) => <option key={scene.id} value={scene.id}>{scene.name}</option>)}</Select></Field><Field label={text("Cooldown (seconden)", "Cooldown (seconds)")}><Input name="cooldown" type="number" min="0" max="604800" defaultValue={automation?.cooldown_seconds ?? 60} /></Field></div>
|
||||
<fieldset className="weekday-grid"><legend>{text("Weekdagen", "Weekdays")}</legend>{weekdays.map(([value, dutch, english]) => <label key={value}><input type="checkbox" name="weekdays" value={value} checked={selectedDays.includes(value)} onChange={(event) => setSelectedDays((current) => event.target.checked ? [...current, value].sort() : current.filter((day) => day !== value))} /> <span>{text(dutch, english)}</span></label>)}</fieldset>
|
||||
{!selectedDays.length ? <p className="field-error" role="alert">{text("Selecteer minstens één weekdag.", "Select at least one weekday.")}</p> : null}
|
||||
<Field label={text("Conflictgroep (optioneel)", "Conflict group (optional)")} hint={text("Regels met dezelfde sleutel worden nooit gelijktijdig uitgevoerd.", "Rules with the same key never run concurrently.")}><Input name="conflict_key" defaultValue={automation?.conflict_key ?? ""} placeholder="woonkamer" /></Field>
|
||||
<label className="checkbox-row"><input name="enabled" type="checkbox" defaultChecked={automation?.enabled ?? true} /> {text("Automation is actief", "Automation is enabled")}</label>
|
||||
<div className="form-actions"><Button variant="ghost" type="button" onClick={onClose}>{text("Annuleren", "Cancel")}</Button><Button type="submit" busy={busy} disabled={!selectedDays.length}><Save size={16} /> {automation ? text("Wijzigingen opslaan", "Save changes") : text("Aanmaken", "Create")}</Button></div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useId, useRef, type ReactNode } from "react";
|
||||
import { Button } from "./ui";
|
||||
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
confirmLabel,
|
||||
danger = false,
|
||||
busy = false,
|
||||
confirmDisabled = false,
|
||||
onConfirm,
|
||||
onClose,
|
||||
children,
|
||||
}: {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmLabel: string;
|
||||
danger?: boolean;
|
||||
busy?: boolean;
|
||||
confirmDisabled?: boolean;
|
||||
onConfirm: () => void;
|
||||
onClose: () => void;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
const titleId = useId();
|
||||
useEffect(() => {
|
||||
if (open && !dialog.current?.open) dialog.current?.showModal();
|
||||
if (!open && dialog.current?.open) dialog.current.close();
|
||||
}, [open]);
|
||||
return (
|
||||
<dialog ref={dialog} className="dialog" aria-labelledby={titleId} onCancel={onClose} onClose={onClose}>
|
||||
<h2 id={titleId}>{title}</h2>
|
||||
<p>{description}</p>
|
||||
{children}
|
||||
<div className="dialog__actions">
|
||||
<Button variant="ghost" onClick={onClose}>Annuleren</Button>
|
||||
<Button variant={danger ? "danger" : "primary"} onClick={onConfirm} busy={busy} disabled={confirmDisabled}>{confirmLabel}</Button>
|
||||
</div>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Cpu, MemoryStick, Star, WifiOff } from "lucide-react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { Device } from "../api/types";
|
||||
import { colorToHex, formatDate } from "../lib/utils";
|
||||
import { Badge } from "./ui";
|
||||
|
||||
interface DeviceCardProps {
|
||||
device: Device;
|
||||
list?: boolean;
|
||||
moduleLabel?: string;
|
||||
}
|
||||
|
||||
export function DeviceCard({ device, list = false, moduleLabel }: DeviceCardProps) {
|
||||
const color = colorToHex(device.state.colors?.[0]);
|
||||
const Icon = device.device_type === "dram" ? MemoryStick : Cpu;
|
||||
return <Link to={`/devices/${device.id}`} className={list ? "device-row" : "device-card"}>
|
||||
<div className="device-card__visual" style={{ "--device-color": color } as CSSProperties}>
|
||||
<span className="device-glow" /><Icon size={list ? 22 : 30} /><span className={`presence ${device.online ? "presence--online" : ""}`} />
|
||||
</div>
|
||||
<div className="device-card__content">
|
||||
<div className="device-card__title"><div><h2>{moduleLabel || device.alias || device.name}</h2><p>{[device.vendor, device.model].filter(Boolean).join(" · ") || device.source}</p></div>{device.favorite ? <Star size={16} className="favorite" fill="currentColor" /> : null}</div>
|
||||
<div className="device-card__meta"><Badge tone={device.online ? "success" : "neutral"}>{device.online ? "Online" : <><WifiOff size={12} /> Offline</>}</Badge>{device.room_name ? <Badge>{device.room_name}</Badge> : null}{device.read_only ? <Badge tone="warning">Alleen lezen</Badge> : null}{device.blocked ? <Badge tone="danger">Geblokkeerd</Badge> : null}</div>
|
||||
{!list ? <div className="device-card__footer"><span><i style={{ background: color }} />{device.state.mode ?? "Onbekende modus"}</span><span>{device.led_count} leds</span></div> : <div className="device-row__extra"><span>{device.state.brightness ?? "—"}%</span><span>{formatDate(device.last_detected_at)}</span></div>}
|
||||
</div>
|
||||
</Link>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { MemoryStick } from "lucide-react";
|
||||
import type { CSSProperties } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { DeviceGroup } from "../api/types";
|
||||
import { colorToHex } from "../lib/utils";
|
||||
import { Badge } from "./ui";
|
||||
|
||||
interface DeviceGroupCardProps {
|
||||
group: DeviceGroup;
|
||||
list?: boolean;
|
||||
}
|
||||
|
||||
export function DeviceGroupCard({ group, list = false }: DeviceGroupCardProps) {
|
||||
const color = colorToHex(group.state.colors?.[0]);
|
||||
return <Link to={`/device-groups/${group.id}`} className={`${list ? "device-row" : "device-card"} device-card--group`}>
|
||||
<div className="device-card__visual" style={{ "--device-color": color } as CSSProperties}>
|
||||
<span className="device-glow" /><MemoryStick size={list ? 22 : 30} /><span className={`presence ${group.online_count ? "presence--online" : ""}`} />
|
||||
</div>
|
||||
<div className="device-card__content">
|
||||
<div className="device-card__title"><div><h2>{group.name}</h2><p>{group.vendor} · gegroepeerd RAM-apparaat</p></div><Badge tone="accent">Groep</Badge></div>
|
||||
<div className="device-card__meta"><Badge tone={group.online ? "success" : "warning"}>{group.online_count}/{group.module_count} online</Badge>{group.mixed ? <Badge tone="warning">Gemengde toestand</Badge> : null}</div>
|
||||
<div className={list ? "device-row__extra" : "device-card__footer"}><span>{group.state.mode ?? "Gemengd"}</span><span>{group.module_count} modules · {group.led_count} leds</span></div>
|
||||
</div>
|
||||
</Link>;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { Power } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { Capabilities, DeviceMode, DeviceState } from "../api/types";
|
||||
import { hexToColor, colorToHex } from "../lib/utils";
|
||||
import { Button, Card, CardHeader, Field, Input, Select } from "./ui";
|
||||
|
||||
const DIRECTION_FLAGS = 2 | 4 | 8;
|
||||
|
||||
interface RgbControlPanelProps {
|
||||
targetKey: string;
|
||||
capabilities: Capabilities;
|
||||
state: DeviceState;
|
||||
modes: DeviceMode[];
|
||||
pending: boolean;
|
||||
disabled: boolean;
|
||||
onApply: (state: DeviceState) => void;
|
||||
}
|
||||
|
||||
export function RgbControlPanel({
|
||||
targetKey,
|
||||
capabilities,
|
||||
state,
|
||||
modes,
|
||||
pending,
|
||||
disabled,
|
||||
onApply,
|
||||
}: RgbControlPanelProps) {
|
||||
const defaultColorMode = findColorMode(modes);
|
||||
const [modeIndex, setModeIndex] = useState<number | undefined>(
|
||||
state.mode_index ?? defaultColorMode?.index,
|
||||
);
|
||||
const [colors, setColors] = useState<string[]>(() => stateColors(state, modes));
|
||||
const [brightness, setBrightness] = useState(state.brightness ?? 100);
|
||||
const [speed, setSpeed] = useState(state.speed ?? 0);
|
||||
const [direction, setDirection] = useState(state.direction ?? 0);
|
||||
const synchronizedTarget = useRef("");
|
||||
const dirty = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const changedTarget = synchronizedTarget.current !== targetKey;
|
||||
if (!changedTarget && dirty.current) return;
|
||||
synchronizedTarget.current = targetKey;
|
||||
dirty.current = false;
|
||||
const nextModeIndex = state.mode_index ?? findColorMode(modes)?.index;
|
||||
setModeIndex(nextModeIndex);
|
||||
setColors(stateColors({ ...state, mode_index: nextModeIndex }, modes));
|
||||
setBrightness(state.brightness ?? 100);
|
||||
setSpeed(state.speed ?? modes.find((mode) => mode.index === nextModeIndex)?.speed_min ?? 0);
|
||||
setDirection(state.direction ?? 0);
|
||||
}, [modes, state, targetKey]);
|
||||
|
||||
const selectedMode = modes.find((mode) => mode.index === modeIndex);
|
||||
const colorSlots = modeColorSlots(selectedMode, capabilities.rgb);
|
||||
const applyColorSlots = colorSlots;
|
||||
const supportsDirection = Boolean((selectedMode?.flags ?? 0) & DIRECTION_FLAGS);
|
||||
const supportsSpeed = selectedMode?.speed_min != null && selectedMode.speed_max != null;
|
||||
|
||||
const selectMode = (nextIndex: number) => {
|
||||
dirty.current = true;
|
||||
const nextMode = modes.find((mode) => mode.index === nextIndex);
|
||||
setModeIndex(nextIndex);
|
||||
setColors(resizeColors(colors, modeColorSlots(nextMode, capabilities.rgb)));
|
||||
setSpeed(clamp(state.speed ?? nextMode?.speed_min ?? 0, nextMode?.speed_min, nextMode?.speed_max));
|
||||
setBrightness(state.brightness ?? 100);
|
||||
setDirection(state.direction ?? 0);
|
||||
};
|
||||
|
||||
const updateColor = (index: number, value: string) => {
|
||||
dirty.current = true;
|
||||
const next = resizeColors(colors, Math.max(colorSlots, index + 1));
|
||||
next[index] = value;
|
||||
setColors(next);
|
||||
if (selectedMode?.name.toLocaleLowerCase() === "direct" && defaultColorMode) {
|
||||
setModeIndex(defaultColorMode.index);
|
||||
}
|
||||
};
|
||||
|
||||
const apply = () => {
|
||||
const command: DeviceState = { power: true };
|
||||
if (modeIndex != null) command.mode_index = modeIndex;
|
||||
if (applyColorSlots) command.colors = colors.slice(0, applyColorSlots).map(hexToColor);
|
||||
if (selectedMode?.brightness) command.brightness = brightness;
|
||||
if (supportsSpeed) command.speed = speed;
|
||||
if (supportsDirection) command.direction = direction;
|
||||
dirty.current = false;
|
||||
onApply(command);
|
||||
};
|
||||
|
||||
return <Card className="control-panel">
|
||||
<CardHeader title="Bediening" description="Elke modus gebruikt uitsluitend de parameters die de hardware ondersteunt." />
|
||||
<div className="power-actions">
|
||||
<Button onClick={() => onApply({ power: true })} disabled={!capabilities.power || disabled}>
|
||||
<Power size={16} /> Aan
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => onApply({ power: false })} disabled={!capabilities.power || disabled}>
|
||||
Uit
|
||||
</Button>
|
||||
</div>
|
||||
{capabilities.effect && modes.length ? <Field label="Effectmodus">
|
||||
<Select aria-label="Effectmodus" value={modeIndex ?? ""} onChange={(event) => selectMode(Number(event.target.value))}>
|
||||
{modes.map((mode) => <option key={mode.index} value={mode.index}>{mode.name}</option>)}
|
||||
</Select>
|
||||
</Field> : null}
|
||||
{colorSlots ? <Field label={colorSlots > 1 ? "Effectkleuren" : "Statische kleur"}>
|
||||
<div className="effect-colors">
|
||||
{resizeColors(colors, colorSlots).map((color, index) => <div className="effect-color" key={`${targetKey}-color-${index}`}>
|
||||
{colorSlots > 1 ? <span>Kleur {index + 1}</span> : null}
|
||||
<div className="large-color">
|
||||
<input aria-label={`Kleur ${index + 1} kiezen`} type="color" value={validColor(color)} onChange={(event) => updateColor(index, event.target.value)} />
|
||||
<Input aria-label={`Kleur ${index + 1}`} value={color.toUpperCase()} onChange={(event) => updateColor(index, event.target.value)} />
|
||||
</div>
|
||||
</div>)}
|
||||
</div>
|
||||
</Field> : <p className="mode-hint">Deze hardwaremodus genereert zijn kleuren automatisch.</p>}
|
||||
{selectedMode?.brightness ? <Field label={`Helderheid · ${brightness}%`}>
|
||||
<input aria-label="Helderheid" className="range" type="range" min="0" max="100" value={brightness} onChange={(event) => { dirty.current = true; setBrightness(Number(event.target.value)); }} />
|
||||
</Field> : null}
|
||||
{supportsSpeed ? <Field label={`Snelheid · ${speed}`}>
|
||||
<input aria-label="Snelheid" className="range" type="range" min={selectedMode.speed_min ?? 0} max={selectedMode.speed_max ?? 0} value={speed} onChange={(event) => { dirty.current = true; setSpeed(Number(event.target.value)); }} />
|
||||
</Field> : null}
|
||||
{supportsDirection ? <Field label="Richting">
|
||||
<Select aria-label="Richting" value={direction} onChange={(event) => { dirty.current = true; setDirection(Number(event.target.value)); }}>
|
||||
<option value={0}>Vooruit</option>
|
||||
<option value={1}>Achteruit</option>
|
||||
</Select>
|
||||
</Field> : null}
|
||||
<Button className="full-width" onClick={apply} busy={pending} disabled={disabled}>
|
||||
Instellingen toepassen
|
||||
</Button>
|
||||
</Card>;
|
||||
}
|
||||
|
||||
function findColorMode(modes: DeviceMode[]) {
|
||||
return modes.find((mode) => mode.name.toLocaleLowerCase() === "custom")
|
||||
?? modes.find((mode) => mode.name.toLocaleLowerCase() === "static")
|
||||
?? modes.find((mode) => mode.name.toLocaleLowerCase() === "direct");
|
||||
}
|
||||
|
||||
function modeColorSlots(mode: DeviceMode | undefined, rgb: boolean) {
|
||||
if (!rgb) return 0;
|
||||
if (!mode) return 1;
|
||||
if (mode.colors_min > 0) return mode.colors_min;
|
||||
return ["direct", "custom"].includes(mode.name.toLocaleLowerCase()) ? 1 : 0;
|
||||
}
|
||||
|
||||
function stateColors(state: DeviceState, modes: DeviceMode[]) {
|
||||
const mode = modes.find((candidate) => candidate.index === state.mode_index);
|
||||
const count = modeColorSlots(mode, true);
|
||||
const values = (state.colors ?? []).map(colorToHex);
|
||||
return resizeColors(values.length ? values : ["#5660ff"], count || 1);
|
||||
}
|
||||
|
||||
function resizeColors(colors: string[], count: number) {
|
||||
if (!count) return [];
|
||||
const seed = colors[0] ?? "#5660ff";
|
||||
return Array.from({ length: count }, (_, index) => colors[index] ?? seed);
|
||||
}
|
||||
|
||||
function validColor(value: string) {
|
||||
return /^#[0-9a-f]{6}$/i.test(value) ? value : "#000000";
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number | null | undefined, maximum: number | null | undefined) {
|
||||
return Math.max(minimum ?? value, Math.min(maximum ?? value, value));
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Fan } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { DeviceState, DeviceZone, RGBColor } from "../api/types";
|
||||
import { colorToHex, hexToColor } from "../lib/utils";
|
||||
import { Badge, Button, Card, Field, Input, Select } from "./ui";
|
||||
|
||||
type ZonePattern = "static" | "rainbow" | "alternating" | "off";
|
||||
|
||||
interface ZoneDraft {
|
||||
pattern: ZonePattern;
|
||||
primary: string;
|
||||
secondary: string;
|
||||
}
|
||||
|
||||
interface RgbZoneControlsProps {
|
||||
targetKey: string;
|
||||
zones: DeviceZone[];
|
||||
state: DeviceState;
|
||||
pendingZone?: number;
|
||||
disabled: boolean;
|
||||
onApply: (zoneIndex: number, state: DeviceState) => void;
|
||||
}
|
||||
|
||||
export function RgbZoneControls({
|
||||
targetKey,
|
||||
zones,
|
||||
state,
|
||||
pendingZone,
|
||||
disabled,
|
||||
onApply,
|
||||
}: RgbZoneControlsProps) {
|
||||
const [drafts, setDrafts] = useState<Record<number, ZoneDraft>>({});
|
||||
|
||||
useEffect(() => {
|
||||
setDrafts(Object.fromEntries(zones.map((zone) => [zone.index, {
|
||||
pattern: "static",
|
||||
primary: zoneColor(state, zone),
|
||||
secondary: "#00c2a8",
|
||||
}])));
|
||||
}, [state, targetKey, zones]);
|
||||
|
||||
const update = (zoneIndex: number, patch: Partial<ZoneDraft>) => {
|
||||
setDrafts((current) => ({
|
||||
...current,
|
||||
[zoneIndex]: {
|
||||
...(current[zoneIndex] ?? { pattern: "static", primary: "#5660ff", secondary: "#00c2a8" }),
|
||||
...patch,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
return <div className="argb-zone-list">
|
||||
{zones.map((zone) => {
|
||||
const draft = drafts[zone.index] ?? { pattern: "static", primary: "#5660ff", secondary: "#00c2a8" };
|
||||
const automaticLedCount = zone.leds_min === zone.leds_max ? zone.led_count : zone.leds_max;
|
||||
const colors = patternColors(draft, automaticLedCount);
|
||||
return <Card className="argb-zone-card" data-testid={`argb-zone-${zone.index}`} key={zone.index}>
|
||||
<div className="argb-zone-card__heading">
|
||||
<span><Fan size={19} /></span>
|
||||
<div><h3>{zone.name}</h3><p>Individuele Aura-zone</p></div>
|
||||
<Badge tone="success">Alle {automaticLedCount} leds</Badge>
|
||||
</div>
|
||||
<Field label={`Modus voor ${zone.name}`}>
|
||||
<Select aria-label={`Modus voor ${zone.name}`} value={draft.pattern} onChange={(event) => update(zone.index, { pattern: event.target.value as ZonePattern })}>
|
||||
<option value="static">Vaste kleur</option>
|
||||
<option value="rainbow">Regenboogpatroon</option>
|
||||
<option value="alternating">Afwisselende kleuren</option>
|
||||
<option value="off">Uit</option>
|
||||
</Select>
|
||||
</Field>
|
||||
{draft.pattern !== "off" && draft.pattern !== "rainbow" ? <Field label="Hoofdkleur">
|
||||
<div className="large-color">
|
||||
<input aria-label={`Hoofdkleur voor ${zone.name} kiezen`} type="color" value={validColor(draft.primary)} onChange={(event) => update(zone.index, { primary: event.target.value })} />
|
||||
<Input aria-label={`Hoofdkleur voor ${zone.name}`} value={draft.primary.toUpperCase()} onChange={(event) => update(zone.index, { primary: event.target.value })} />
|
||||
</div>
|
||||
</Field> : null}
|
||||
{draft.pattern === "alternating" ? <Field label="Tweede kleur">
|
||||
<div className="large-color">
|
||||
<input aria-label={`Tweede kleur voor ${zone.name} kiezen`} type="color" value={validColor(draft.secondary)} onChange={(event) => update(zone.index, { secondary: event.target.value })} />
|
||||
<Input aria-label={`Tweede kleur voor ${zone.name}`} value={draft.secondary.toUpperCase()} onChange={(event) => update(zone.index, { secondary: event.target.value })} />
|
||||
</div>
|
||||
</Field> : null}
|
||||
<p className="mode-hint">De volledige header wordt automatisch gebruikt; een LED-aantal ingeven is niet nodig.</p>
|
||||
<Button className="full-width" busy={pendingZone === zone.index} disabled={disabled || pendingZone != null} onClick={() => onApply(zone.index, { power: true, colors })}>
|
||||
Alleen {zone.name} toepassen
|
||||
</Button>
|
||||
</Card>;
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function zoneColor(state: DeviceState, zone: DeviceZone) {
|
||||
return colorToHex(state.colors?.[zone.start_index ?? 0] ?? state.colors?.[0]);
|
||||
}
|
||||
|
||||
function patternColors(draft: ZoneDraft, count: number): RGBColor[] {
|
||||
if (draft.pattern === "off") return [{ red: 0, green: 0, blue: 0 }];
|
||||
if (draft.pattern === "static") return [hexToColor(validColor(draft.primary))];
|
||||
if (draft.pattern === "alternating") {
|
||||
const colors = [hexToColor(validColor(draft.primary)), hexToColor(validColor(draft.secondary))];
|
||||
return Array.from({ length: count }, (_, index) => colors[index % colors.length]!);
|
||||
}
|
||||
return Array.from({ length: count }, (_, index) => hslToRgb(index / Math.max(1, count)));
|
||||
}
|
||||
|
||||
function hslToRgb(hue: number): RGBColor {
|
||||
const channel = (offset: number) => {
|
||||
const value = (offset + hue * 6) % 6;
|
||||
return Math.round(255 * (1 - Math.max(0, Math.min(value, 4 - value, 1))));
|
||||
};
|
||||
return { red: channel(5), green: channel(3), blue: channel(1) };
|
||||
}
|
||||
|
||||
function validColor(value: string) {
|
||||
return /^#[0-9a-f]{6}$/i.test(value) ? value : "#000000";
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Plus, Save, Trash2, X } from "lucide-react";
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { api } from "../api/client";
|
||||
import type { Device, DeviceState, Group, Page, Scene, SceneItem } from "../api/types";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { formValue, hexToColor } from "../lib/utils";
|
||||
import { useToast } from "./Toast";
|
||||
import { Button, Card, CardHeader, ErrorPanel, Field, Input, LoadingGrid, Select } from "./ui";
|
||||
|
||||
interface SceneEditorProps {
|
||||
sceneId: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type EditableItem = Omit<SceneItem, "id"> & { id?: string };
|
||||
|
||||
export function SceneEditor({ sceneId, onClose }: SceneEditorProps) {
|
||||
const { text } = useI18n();
|
||||
const { notify } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const scene = useQuery({ queryKey: ["scene", sceneId], queryFn: () => api<Scene>(`/api/v1/scenes/${sceneId}`) });
|
||||
const devices = useQuery({ queryKey: ["devices"], queryFn: () => api<Page<Device>>("/api/v1/devices?limit=500") });
|
||||
const groups = useQuery({ queryKey: ["groups"], queryFn: () => api<Group[]>("/api/v1/groups") });
|
||||
const [items, setItems] = useState<EditableItem[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scene.data?.items) setItems(scene.data.items);
|
||||
}, [scene.data]);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (payload: Record<string, unknown>) => api<Scene>(`/api/v1/scenes/${sceneId}`, { method: "PUT", body: JSON.stringify(payload) }),
|
||||
onSuccess: () => {
|
||||
notify(text("Scène opgeslagen.", "Scene saved."));
|
||||
void queryClient.invalidateQueries({ queryKey: ["scenes"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["scene", sceneId] });
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => notify(error instanceof Error ? error.message : text("Opslaan mislukt.", "Save failed."), "danger"),
|
||||
});
|
||||
|
||||
if (scene.isLoading || devices.isLoading || groups.isLoading) return <LoadingGrid count={2} />;
|
||||
if (scene.error || devices.error || groups.error || !scene.data) {
|
||||
return <ErrorPanel error={scene.error ?? devices.error ?? groups.error} retry={() => void Promise.all([scene.refetch(), devices.refetch(), groups.refetch()])} />;
|
||||
}
|
||||
|
||||
const targetName = (item: EditableItem) => {
|
||||
if (item.target_type === "device") {
|
||||
const device = devices.data?.items.find((candidate) => candidate.id === item.target_id);
|
||||
return device?.alias || device?.name || item.target_id;
|
||||
}
|
||||
return groups.data?.find((candidate) => candidate.id === item.target_id)?.name || item.target_id;
|
||||
};
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
save.mutate({
|
||||
name: data.get("name"),
|
||||
description: data.get("description") || null,
|
||||
favorite: data.get("favorite") === "on",
|
||||
items: items.map((item, index) => ({ ...item, sort_order: index })),
|
||||
});
|
||||
};
|
||||
const addItem = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
const target = formValue(data, "target");
|
||||
if (!target.includes(":")) return;
|
||||
const [targetType, targetId] = target.split(":", 2) as ["device" | "group", string];
|
||||
const state: DeviceState = {};
|
||||
const power = data.get("power");
|
||||
if (power === "unchanged" && data.get("include_color") !== "on" && data.get("include_brightness") !== "on") {
|
||||
notify(text("Selecteer minstens één eigenschap voor dit doel.", "Select at least one property for this target."), "danger");
|
||||
return;
|
||||
}
|
||||
if (power === "on") state.power = true;
|
||||
if (power === "off") state.power = false;
|
||||
if (data.get("include_color") === "on") state.colors = [hexToColor(formValue(data, "color"))];
|
||||
if (data.get("include_brightness") === "on") state.brightness = Number(data.get("brightness"));
|
||||
setItems((current) => [
|
||||
...current.filter((item) => !(item.target_type === targetType && item.target_id === targetId)),
|
||||
{ target_type: targetType, target_id: targetId, state, required: true, sort_order: current.length },
|
||||
]);
|
||||
event.currentTarget.reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="resource-editor">
|
||||
<CardHeader
|
||||
title={text("Scène bewerken", "Edit scene")}
|
||||
description={text("Stel doelen en alleen de gewenste eigenschappen in. Niet aangevinkte eigenschappen blijven ongemoeid.", "Configure targets and only the desired properties. Unchecked properties remain unchanged.")}
|
||||
action={<Button type="button" variant="ghost" onClick={onClose}><X size={16} /> {text("Sluiten", "Close")}</Button>}
|
||||
/>
|
||||
<form id="scene-metadata" className="form-stack" onSubmit={submit}>
|
||||
<div className="form-grid form-grid--two">
|
||||
<Field label={text("Naam", "Name")}><Input name="name" required defaultValue={scene.data.name} /></Field>
|
||||
<Field label={text("Omschrijving", "Description")}><Input name="description" defaultValue={scene.data.description ?? ""} /></Field>
|
||||
</div>
|
||||
<label className="checkbox-row"><input name="favorite" type="checkbox" defaultChecked={scene.data.favorite} /> {text("Toon als favoriete scène", "Show as favorite scene")}</label>
|
||||
</form>
|
||||
<div className="resource-editor__section">
|
||||
<h3>{text("Doeltoestanden", "Target states")}</h3>
|
||||
{!items.length ? <p className="muted">{text("Deze scène bevat nog geen doelen.", "This scene has no targets yet.")}</p> : (
|
||||
<div className="scene-item-list">
|
||||
{items.map((item) => (
|
||||
<div className="scene-item" key={`${item.target_type}:${item.target_id}`}>
|
||||
<div><strong>{targetName(item)}</strong><span>{item.target_type === "group" ? text("Groep", "Group") : text("Apparaat", "Device")} · {describeState(item.state, text)}</span></div>
|
||||
<Button type="button" variant="ghost" title={text("Verwijderen", "Remove")} aria-label={text(`Doel ${targetName(item)} verwijderen`, `Remove target ${targetName(item)}`)} onClick={() => setItems((current) => current.filter((candidate) => candidate !== item))}><Trash2 size={16} /></Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<form className="scene-target-form" onSubmit={addItem}>
|
||||
<Field label={text("Doel", "Target")}>
|
||||
<Select name="target" required defaultValue="">
|
||||
<option value="" disabled>{text("Selecteer apparaat of groep", "Select device or group")}</option>
|
||||
<optgroup label={text("Apparaten", "Devices")}>{devices.data?.items.map((device) => <option key={device.id} value={`device:${device.id}`}>{device.alias || device.name}</option>)}</optgroup>
|
||||
<optgroup label={text("Groepen", "Groups")}>{groups.data?.map((group) => <option key={group.id} value={`group:${group.id}`}>{group.name}</option>)}</optgroup>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label={text("Voeding", "Power")}><Select name="power" defaultValue="unchanged"><option value="unchanged">{text("Niet wijzigen", "Unchanged")}</option><option value="on">{text("Aan", "On")}</option><option value="off">{text("Uit", "Off")}</option></Select></Field>
|
||||
<Field label={text("Kleur", "Color")}><div className="option-control"><input name="include_color" type="checkbox" aria-label={text("Kleur opnemen", "Include color")} /><input name="color" type="color" defaultValue="#5660ff" /></div></Field>
|
||||
<Field label={text("Helderheid", "Brightness")}><div className="option-control"><input name="include_brightness" type="checkbox" aria-label={text("Helderheid opnemen", "Include brightness")} /><Input name="brightness" type="number" min="0" max="100" defaultValue="100" /></div></Field>
|
||||
<Button type="submit" variant="secondary"><Plus size={16} /> {text("Doel toevoegen", "Add target")}</Button>
|
||||
</form>
|
||||
<div className="resource-editor__actions"><Button type="button" variant="ghost" onClick={onClose}>{text("Annuleren", "Cancel")}</Button><Button type="submit" form="scene-metadata" busy={save.isPending}><Save size={16} /> {text("Scène opslaan", "Save scene")}</Button></div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function describeState(state: DeviceState, text: (dutch: string, english: string) => string): string {
|
||||
const values: string[] = [];
|
||||
if (state.power !== undefined && state.power !== null) values.push(state.power ? text("aan", "on") : text("uit", "off"));
|
||||
const color = state.colors?.[0];
|
||||
if (color) values.push(`rgb(${color.red}, ${color.green}, ${color.blue})`);
|
||||
if (state.brightness !== undefined && state.brightness !== null) values.push(`${state.brightness}%`);
|
||||
return values.join(" · ") || text("geen eigenschappen", "no properties");
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
import { CheckCircle2, X, XCircle } from "lucide-react";
|
||||
import { IconButton } from "./ui";
|
||||
|
||||
interface ToastItem { id: string; message: string; tone: "success" | "danger" }
|
||||
interface ToastValue { notify: (message: string, tone?: ToastItem["tone"]) => void }
|
||||
const ToastContext = createContext<ToastValue | null>(null);
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [items, setItems] = useState<ToastItem[]>([]);
|
||||
const dismiss = useCallback((id: string) => setItems((current) => current.filter((item) => item.id !== id)), []);
|
||||
const value = useMemo<ToastValue>(() => ({ notify: (message, tone = "success") => {
|
||||
const id = crypto.randomUUID();
|
||||
setItems((current) => [...current.slice(-3), { id, message, tone }]);
|
||||
window.setTimeout(() => dismiss(id), 4500);
|
||||
} }), [dismiss]);
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div className="toasts" aria-live="polite">
|
||||
{items.map((item) => <div key={item.id} className={`toast toast--${item.tone}`}>
|
||||
{item.tone === "success" ? <CheckCircle2 size={18} /> : <XCircle size={18} />}
|
||||
<span>{item.message}</span>
|
||||
<IconButton label="Sluiten" onClick={() => dismiss(item.id)}><X size={15} /></IconButton>
|
||||
</div>)}
|
||||
</div>
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): ToastValue {
|
||||
const value = useContext(ToastContext);
|
||||
if (!value) throw new Error("useToast moet binnen ToastProvider gebruikt worden");
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { StatusBadge } from "./ui";
|
||||
import { renderApp } from "../test/render";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
|
||||
function LanguageFixture() {
|
||||
const { setLanguage } = useI18n();
|
||||
return <><StatusBadge status="degraded" /><button onClick={() => setLanguage("en")}>English</button></>;
|
||||
}
|
||||
|
||||
describe("local component layer", () => {
|
||||
it("renders status accessibly and switches language", async () => {
|
||||
renderApp(<LanguageFixture />);
|
||||
expect(screen.getByText("Beperkt")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "English" }));
|
||||
expect(screen.getByText("Degraded")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
CircleHelp,
|
||||
Info,
|
||||
LoaderCircle,
|
||||
RefreshCw,
|
||||
XCircle,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
ButtonHTMLAttributes,
|
||||
HTMLAttributes,
|
||||
InputHTMLAttributes,
|
||||
ReactNode,
|
||||
SelectHTMLAttributes,
|
||||
} from "react";
|
||||
import type { HealthStatus } from "../api/types";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
|
||||
|
||||
export function Button({
|
||||
variant = "primary",
|
||||
className,
|
||||
children,
|
||||
busy,
|
||||
...props
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & { variant?: ButtonVariant; busy?: boolean }) {
|
||||
return (
|
||||
<button className={cn("button", `button--${variant}`, className)} disabled={busy || props.disabled} {...props}>
|
||||
{busy ? <LoaderCircle className="spin" size={16} aria-hidden /> : null}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconButton({ label, children, ...props }: ButtonHTMLAttributes<HTMLButtonElement> & { label: string }) {
|
||||
return (
|
||||
<button className="icon-button" aria-label={label} title={label} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div className={cn("card", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="card__header">
|
||||
<div>
|
||||
<h2>{title}</h2>
|
||||
{description ? <p>{description}</p> : null}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
tone = "neutral",
|
||||
children,
|
||||
}: {
|
||||
tone?: "neutral" | "success" | "warning" | "danger" | "accent";
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return <span className={cn("badge", `badge--${tone}`)}>{children}</span>;
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: HealthStatus }) {
|
||||
const { t } = useI18n();
|
||||
const mapping: Record<HealthStatus, { icon: LucideIcon; tone: "success" | "warning" | "danger" | "neutral" }> = {
|
||||
healthy: { icon: CheckCircle2, tone: "success" },
|
||||
degraded: { icon: AlertTriangle, tone: "warning" },
|
||||
unhealthy: { icon: XCircle, tone: "danger" },
|
||||
unknown: { icon: CircleHelp, tone: "neutral" },
|
||||
};
|
||||
const item = mapping[status];
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Badge tone={item.tone}>
|
||||
<Icon size={13} aria-hidden /> {t(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function Input(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input className={cn("input", props.className)} {...props} />;
|
||||
}
|
||||
|
||||
export function Select(props: SelectHTMLAttributes<HTMLSelectElement>) {
|
||||
return <select className={cn("input", props.className)} {...props} />;
|
||||
}
|
||||
|
||||
export function Field({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="field">
|
||||
<span className="field__label">{label}</span>
|
||||
{children}
|
||||
{hint ? <span className="field__hint">{hint}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<header className="page-header">
|
||||
<div>
|
||||
{eyebrow ? <span className="eyebrow">{eyebrow}</span> : null}
|
||||
<h1>{title}</h1>
|
||||
{description ? <p>{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="page-header__actions">{actions}</div> : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
export function Skeleton({ className }: { className?: string }) {
|
||||
return <div className={cn("skeleton", className)} aria-hidden />;
|
||||
}
|
||||
|
||||
export function LoadingGrid({ count = 4 }: { count?: number }) {
|
||||
return (
|
||||
<div className="grid grid--cards" aria-label="Laden">
|
||||
{Array.from({ length: count }, (_, index) => (
|
||||
<Card key={index} className="skeleton-card">
|
||||
<Skeleton className="skeleton--short" />
|
||||
<Skeleton />
|
||||
<Skeleton className="skeleton--medium" />
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
icon: Icon = Info,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
action?: ReactNode;
|
||||
icon?: LucideIcon;
|
||||
}) {
|
||||
return (
|
||||
<div className="empty-state">
|
||||
<span className="empty-state__icon"><Icon size={24} aria-hidden /></span>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorPanel({ error, retry }: { error: unknown; retry?: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const message = error instanceof Error ? error.message : "Onbekende fout";
|
||||
return (
|
||||
<div className="alert alert--danger" role="alert">
|
||||
<XCircle size={20} aria-hidden />
|
||||
<div>
|
||||
<strong>Deze gegevens konden niet worden geladen.</strong>
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
{retry ? (
|
||||
<Button variant="secondary" onClick={retry}>
|
||||
<RefreshCw size={16} aria-hidden /> {t("retry")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Stat({ label, value, detail, icon: Icon }: { label: string; value: ReactNode; detail?: string; icon: LucideIcon }) {
|
||||
return (
|
||||
<Card className="stat">
|
||||
<span className="stat__icon"><Icon size={20} aria-hidden /></span>
|
||||
<div><span className="stat__label">{label}</span><strong>{value}</strong>{detail ? <small>{detail}</small> : null}</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { createContext, useContext, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
type Language = "nl" | "en";
|
||||
|
||||
const messages = {
|
||||
nl: {
|
||||
overview: "Overzicht",
|
||||
devices: "Apparaten",
|
||||
spaces: "Kamers & groepen",
|
||||
scenes: "Scènes",
|
||||
automations: "Automations",
|
||||
network: "Netwerkapparaten",
|
||||
connectors: "Connectoren",
|
||||
discovery: "Discovery",
|
||||
activity: "Activiteit",
|
||||
audit: "Auditlog",
|
||||
diagnostics: "Diagnostiek",
|
||||
backups: "Back-up & herstel",
|
||||
settings: "Instellingen",
|
||||
about: "Over LumaOps",
|
||||
online: "Online",
|
||||
offline: "Offline",
|
||||
healthy: "Gezond",
|
||||
degraded: "Beperkt",
|
||||
unhealthy: "Ongezond",
|
||||
unknown: "Onbekend",
|
||||
loading: "Laden…",
|
||||
retry: "Opnieuw proberen",
|
||||
apply: "Toepassen",
|
||||
cancel: "Annuleren",
|
||||
save: "Opslaan",
|
||||
create: "Aanmaken",
|
||||
search: "Zoeken",
|
||||
noResults: "Geen resultaten gevonden",
|
||||
allOff: "Alles uit",
|
||||
emergency: "Noodstop",
|
||||
rescan: "Opnieuw scannen",
|
||||
mockWarning: "Testmodus actief — opdrachten bereiken geen echte hardware.",
|
||||
openMenu: "Navigatie openen",
|
||||
closeMenu: "Navigatie sluiten",
|
||||
},
|
||||
en: {
|
||||
overview: "Overview",
|
||||
devices: "Devices",
|
||||
spaces: "Rooms & groups",
|
||||
scenes: "Scenes",
|
||||
automations: "Automations",
|
||||
network: "Network devices",
|
||||
connectors: "Connectors",
|
||||
discovery: "Discovery",
|
||||
activity: "Activity",
|
||||
audit: "Audit log",
|
||||
diagnostics: "Diagnostics",
|
||||
backups: "Backup & restore",
|
||||
settings: "Settings",
|
||||
about: "About LumaOps",
|
||||
online: "Online",
|
||||
offline: "Offline",
|
||||
healthy: "Healthy",
|
||||
degraded: "Degraded",
|
||||
unhealthy: "Unhealthy",
|
||||
unknown: "Unknown",
|
||||
loading: "Loading…",
|
||||
retry: "Try again",
|
||||
apply: "Apply",
|
||||
cancel: "Cancel",
|
||||
save: "Save",
|
||||
create: "Create",
|
||||
search: "Search",
|
||||
noResults: "No results found",
|
||||
allOff: "All off",
|
||||
emergency: "Emergency stop",
|
||||
rescan: "Rescan",
|
||||
mockWarning: "Test mode is active — commands do not reach real hardware.",
|
||||
openMenu: "Open navigation",
|
||||
closeMenu: "Close navigation",
|
||||
},
|
||||
} as const;
|
||||
|
||||
type MessageKey = keyof (typeof messages)["nl"];
|
||||
|
||||
interface I18nValue {
|
||||
language: Language;
|
||||
setLanguage: (language: Language) => void;
|
||||
t: (key: MessageKey) => string;
|
||||
text: (dutch: string, english: string) => string;
|
||||
}
|
||||
|
||||
const I18nContext = createContext<I18nValue | null>(null);
|
||||
|
||||
export function I18nProvider({ children }: { children: ReactNode }) {
|
||||
const [language, setLanguageState] = useState<Language>(() => {
|
||||
const saved = localStorage.getItem("lumaops.language");
|
||||
return saved === "en" ? "en" : "nl";
|
||||
});
|
||||
const value = useMemo<I18nValue>(
|
||||
() => ({
|
||||
language,
|
||||
setLanguage: (next) => {
|
||||
localStorage.setItem("lumaops.language", next);
|
||||
document.documentElement.lang = next;
|
||||
setLanguageState(next);
|
||||
},
|
||||
t: (key) => messages[language][key],
|
||||
text: (dutch, english) => (language === "nl" ? dutch : english),
|
||||
}),
|
||||
[language],
|
||||
);
|
||||
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
|
||||
}
|
||||
|
||||
export function useI18n(): I18nValue {
|
||||
const value = useContext(I18nContext);
|
||||
if (!value) throw new Error("useI18n moet binnen I18nProvider gebruikt worden");
|
||||
return value;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
|
||||
export type Theme = "light" | "dark" | "system";
|
||||
|
||||
interface ThemeValue {
|
||||
theme: Theme;
|
||||
resolved: "light" | "dark";
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeValue | null>(null);
|
||||
|
||||
function resolve(theme: Theme): "light" | "dark" {
|
||||
if (theme !== "system") return theme;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(() => {
|
||||
const saved = localStorage.getItem("lumaops.theme");
|
||||
return saved === "light" || saved === "dark" ? saved : "system";
|
||||
});
|
||||
const [resolved, setResolved] = useState(() => resolve(theme));
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const update = () => setResolved(resolve(theme));
|
||||
update();
|
||||
media.addEventListener("change", update);
|
||||
return () => media.removeEventListener("change", update);
|
||||
}, [theme]);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.theme = resolved;
|
||||
document.documentElement.style.colorScheme = resolved;
|
||||
}, [resolved]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
theme,
|
||||
resolved,
|
||||
setTheme: (next: Theme) => {
|
||||
localStorage.setItem("lumaops.theme", next);
|
||||
setThemeState(next);
|
||||
},
|
||||
}),
|
||||
[resolved, theme],
|
||||
);
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeValue {
|
||||
const value = useContext(ThemeContext);
|
||||
if (!value) throw new Error("useTheme moet binnen ThemeProvider gebruikt worden");
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
export function cn(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
export function formatDate(value?: string | null): string {
|
||||
if (!value) return "—";
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(
|
||||
new Date(value),
|
||||
);
|
||||
}
|
||||
|
||||
export function colorToHex(color?: { red: number; green: number; blue: number }): string {
|
||||
if (!color) return "#5660ff";
|
||||
return `#${[color.red, color.green, color.blue]
|
||||
.map((part) => part.toString(16).padStart(2, "0"))
|
||||
.join("")}`;
|
||||
}
|
||||
|
||||
export function hexToColor(hex: string): { red: number; green: number; blue: number } {
|
||||
const value = hex.replace("#", "");
|
||||
return {
|
||||
red: Number.parseInt(value.slice(0, 2), 16),
|
||||
green: Number.parseInt(value.slice(2, 4), 16),
|
||||
blue: Number.parseInt(value.slice(4, 6), 16),
|
||||
};
|
||||
}
|
||||
|
||||
export function formValue(data: FormData, key: string): string {
|
||||
const value = data.get(key);
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { App } from "./App";
|
||||
import { ToastProvider } from "./components/Toast";
|
||||
import { I18nProvider } from "./lib/i18n";
|
||||
import { ThemeProvider } from "./lib/theme";
|
||||
import "./styles.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: 10_000, retry: 1, refetchOnWindowFocus: false },
|
||||
mutations: { retry: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<I18nProvider>
|
||||
<ToastProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ToastProvider>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BookOpen, Box, Code2, GitBranch, Github, Lightbulb, ShieldCheck } from "lucide-react";
|
||||
import { Card, CardHeader, PageHeader } from "../components/ui";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
|
||||
export function AboutPage() {
|
||||
const { text } = useI18n();
|
||||
return <>
|
||||
<PageHeader eyebrow={text("Lokaal · open source · hardware-first", "Local · open source · hardware-first")} title={text("Over LumaOps", "About LumaOps")} description={text("Een persoonlijk beheerplatform dat OpenRGB als echte hardware-engine behoudt.", "A personal control platform that keeps OpenRGB as its real hardware engine.")} />
|
||||
<div className="about-hero"><span><Lightbulb size={34} /></span><div><h2>LumaOps 0.1.0</h2><p>Gebouwd rond OpenRGB 1.0rc3, SDK protocol 5 en Plugin API 4.</p></div></div>
|
||||
<div className="grid grid--cards"><Card><CardHeader title="Architectuur" /><ul className="icon-list"><li><Box size={16} /> Eén productiecontainer</li><li><Code2 size={16} /> FastAPI + React + TypeScript</li><li><GitBranch size={16} /> OpenRGB-bron als onderhoudbare upstream</li></ul></Card><Card><CardHeader title="Licentie" /><ul className="icon-list"><li><ShieldCheck size={16} /> GPL-2.0-or-later gecombineerd werk</li><li><Github size={16} /> Bron en buildinstructies mee distribueren</li><li><BookOpen size={16} /> Bestaande copyright- en SPDX-headers behouden</li></ul></Card></div>
|
||||
<Card className="credits"><CardHeader title="Dank aan OpenRGB" description="LumaOps vervangt OpenRGB niet. De volwassen controller- en detectorlaag blijft verantwoordelijk voor hardwarecommunicatie." /><p>De LumaOps-servicelaag spreekt uitsluitend via de lokale SDK-v5-grens, met aanvullende identiteit-, capability- en veiligheidscontroles.</p></Card>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Activity, ClipboardCheck, Search } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import type { Activity as ActivityRecord, Page } from "../api/types";
|
||||
import { Badge, Card, EmptyState, Input, PageHeader } from "../components/ui";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { formatDate } from "../lib/utils";
|
||||
|
||||
interface AuditRecord { id: string; actor: string; action: string; resource_type: string; resource_id: string | null; outcome: string; created_at: string; request_id: string }
|
||||
|
||||
export function ActivityPage({ mode }: { mode: "activity" | "audit" }) {
|
||||
const { text } = useI18n();
|
||||
const [search, setSearch] = useState("");
|
||||
const query = useQuery({ queryKey: [mode], queryFn: () => api<Page<ActivityRecord | AuditRecord>>(`/api/v1/${mode}?limit=300`) });
|
||||
const filtered = query.data?.items.filter((record) => JSON.stringify(record).toLowerCase().includes(search.toLowerCase())) ?? [];
|
||||
const isAudit = mode === "audit";
|
||||
return <>
|
||||
<PageHeader eyebrow={isAudit ? text("Onveranderbare handelingenhistoriek", "Immutable action history") : text("Systeemgebeurtenissen", "System events")} title={isAudit ? text("Auditlog", "Audit log") : text("Activiteit", "Activity")} description={isAudit ? text("Wie wijzigde wat, op welk doel en met welk resultaat.", "Who changed what, on which target, and with which result.") : text("Recente opdrachten, discovery, automations en systeemmeldingen.", "Recent commands, discovery, automations and system messages.")} />
|
||||
<div className="toolbar"><div className="search"><Search size={17} /><Input aria-label="Log doorzoeken" placeholder="Doorzoek gebeurtenissen…" value={search} onChange={(event) => setSearch(event.target.value)} /></div></div>
|
||||
<Card>{filtered.length ? <div className="event-list">{filtered.map((record) => isAudit ? <AuditRow key={record.id} record={record as AuditRecord} /> : <ActivityRow key={record.id} record={record as ActivityRecord} />)}</div> : <EmptyState icon={isAudit ? ClipboardCheck : Activity} title="Geen gebeurtenissen" description="Nieuwe acties verschijnen hier automatisch." />}</Card>
|
||||
</>;
|
||||
}
|
||||
|
||||
function ActivityRow({ record }: { record: ActivityRecord }) { return <div className="event-row"><span className={`event-row__icon event-row__icon--${record.severity}`}><Activity size={16} /></span><div><strong>{record.title}</strong><p>{record.message}</p></div><div><Badge>{record.category}</Badge><span>{formatDate(record.created_at)}</span></div></div>; }
|
||||
function AuditRow({ record }: { record: AuditRecord }) { return <div className="event-row"><span className={`event-row__icon event-row__icon--${record.outcome}`}><ClipboardCheck size={16} /></span><div><strong>{record.action}</strong><p>{record.actor} · {record.resource_type} {record.resource_id?.slice(0, 8) ?? ""}</p></div><div><Badge tone={record.outcome === "succeeded" ? "success" : "danger"}>{record.outcome}</Badge><span>{formatDate(record.created_at)}</span><small title={record.request_id}>req {record.request_id.slice(0, 8)}</small></div></div>; }
|
||||
@@ -0,0 +1,48 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { jsonResponse, renderApp, requestJson, requestUrl } from "../test/render";
|
||||
import { AutomationsPage } from "./AutomationsPage";
|
||||
|
||||
const automation = {
|
||||
id: "automation-1",
|
||||
name: "Avond",
|
||||
description: "Rustige verlichting",
|
||||
enabled: true,
|
||||
trigger: { type: "time", at: "20:00", weekdays: [0, 1, 2, 3, 4] },
|
||||
actions: [{ type: "scene", scene_id: "scene-1" }],
|
||||
timezone: "Europe/Brussels",
|
||||
cooldown_seconds: 60,
|
||||
conflict_key: "woonkamer",
|
||||
last_run_at: null,
|
||||
next_run_at: "2026-07-15T18:00:00Z",
|
||||
last_error: null,
|
||||
};
|
||||
|
||||
describe("AutomationsPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("/runs")) return Promise.resolve(jsonResponse({ items: [{ id: "run-1", automation_id: automation.id, status: "succeeded", trigger: { type: "manual" }, result: {}, error: null, started_at: "2026-07-15T12:00:00Z", finished_at: "2026-07-15T12:00:01Z" }], total: 1, limit: 25, offset: 0 }));
|
||||
if (url.endsWith(`/automations/${automation.id}`) && init?.method === "PUT") return Promise.resolve(jsonResponse({ ...automation, enabled: false }));
|
||||
if (url.includes("/automations")) return Promise.resolve(jsonResponse({ items: [automation], total: 1, limit: 200, offset: 0 }));
|
||||
if (url.includes("/scenes")) return Promise.resolve(jsonResponse({ items: [{ id: "scene-1", name: "Avondrust" }], total: 1, limit: 200, offset: 0 }));
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
}));
|
||||
});
|
||||
|
||||
it("toggles an automation and exposes its run history", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderApp(<AutomationsPage />, "/automations");
|
||||
expect(await screen.findByText("Avond")).toBeInTheDocument();
|
||||
await user.click(screen.getByTitle("Uitschakelen"));
|
||||
await waitFor(() => {
|
||||
const update = vi.mocked(fetch).mock.calls.find(([input, init]) => requestUrl(input).endsWith(`/automations/${automation.id}`) && init?.method === "PUT");
|
||||
expect(update).toBeDefined();
|
||||
expect(requestJson(update?.[1])).toMatchObject({ enabled: false, conflict_key: "woonkamer" });
|
||||
});
|
||||
await user.click(screen.getByTitle("Historiek"));
|
||||
expect(await screen.findByText("succeeded")).toBeInTheDocument();
|
||||
expect(screen.getByText("manual")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CalendarClock, History, Pause, Pencil, Play, Plus, Timer, Trash2, X, Zap } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import type { Automation, AutomationRun, Page, Scene } from "../api/types";
|
||||
import { AutomationEditor, type AutomationPayload } from "../components/AutomationEditor";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { Badge, Button, Card, CardHeader, EmptyState, ErrorPanel, LoadingGrid, PageHeader } from "../components/ui";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { formatDate } from "../lib/utils";
|
||||
|
||||
export function AutomationsPage() {
|
||||
const { text } = useI18n();
|
||||
const queryClient = useQueryClient();
|
||||
const { notify } = useToast();
|
||||
const [editor, setEditor] = useState<Automation | "new" | null>(null);
|
||||
const [deleteAutomation, setDeleteAutomation] = useState<Automation | null>(null);
|
||||
const [historyAutomation, setHistoryAutomation] = useState<Automation | null>(null);
|
||||
const automations = useQuery({ queryKey: ["automations"], queryFn: () => api<Page<Automation>>("/api/v1/automations?limit=200") });
|
||||
const scenes = useQuery({ queryKey: ["scenes"], queryFn: () => api<Page<Scene>>("/api/v1/scenes?limit=200") });
|
||||
const history = useQuery({ queryKey: ["automation-runs", historyAutomation?.id], queryFn: () => api<Page<AutomationRun>>(`/api/v1/automations/${historyAutomation?.id}/runs?limit=25`), enabled: Boolean(historyAutomation) });
|
||||
const refresh = () => void queryClient.invalidateQueries({ queryKey: ["automations"] });
|
||||
const save = useMutation({
|
||||
mutationFn: ({ id, payload }: { id?: string; payload: AutomationPayload }) => api<Automation>(`/api/v1/automations${id ? `/${id}` : ""}`, { method: id ? "PUT" : "POST", body: JSON.stringify(payload) }),
|
||||
onSuccess: (_, variables) => { notify(variables.id ? text("Automation bijgewerkt.", "Automation updated.") : text("Automation aangemaakt.", "Automation created.")); setEditor(null); refresh(); },
|
||||
onError: (error) => notify(error instanceof Error ? error.message : text("Opslaan mislukt.", "Save failed."), "danger"),
|
||||
});
|
||||
const run = useMutation({
|
||||
mutationFn: (id: string) => api<{ status: string }>(`/api/v1/automations/${id}/run`, { method: "POST" }),
|
||||
onSuccess: (result) => { notify(result.status === "succeeded" ? text("Automation uitgevoerd.", "Automation executed.") : text(`Uitvoering: ${result.status}`, `Run: ${result.status}`), result.status === "failed" ? "danger" : "success"); refresh(); void queryClient.invalidateQueries({ queryKey: ["automation-runs"] }); },
|
||||
onError: (error) => notify(error instanceof Error ? error.message : text("Uitvoering mislukt.", "Run failed."), "danger"),
|
||||
});
|
||||
const toggle = useMutation({
|
||||
mutationFn: (automation: Automation) => api<Automation>(`/api/v1/automations/${automation.id}`, { method: "PUT", body: JSON.stringify(toPayload(automation, !automation.enabled)) }),
|
||||
onSuccess: (automation) => { notify(automation.enabled ? text("Automation ingeschakeld.", "Automation enabled.") : text("Automation uitgeschakeld.", "Automation disabled.")); refresh(); },
|
||||
onError: (error) => notify(error instanceof Error ? error.message : text("Status wijzigen mislukt.", "Status update failed."), "danger"),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => api(`/api/v1/automations/${id}`, { method: "DELETE" }),
|
||||
onSuccess: () => { notify(text("Automation verwijderd.", "Automation deleted.")); setDeleteAutomation(null); refresh(); },
|
||||
onError: (error) => notify(error instanceof Error ? error.message : text("Verwijderen mislukt.", "Delete failed."), "danger"),
|
||||
});
|
||||
const error = automations.error ?? scenes.error;
|
||||
|
||||
return <>
|
||||
<PageHeader eyebrow={text("Regelengine", "Rules engine")} title="Automations" description={text("Plan scènes per weekdag met cooldown, conflictpreventie en volledige uitvoeringshistoriek.", "Schedule scenes by weekday with cooldown, conflict prevention, and complete run history.")} actions={<Button onClick={() => setEditor("new")}><Plus size={16} /> {text("Nieuwe automation", "New automation")}</Button>} />
|
||||
{editor ? <AutomationEditor key={editor === "new" ? "new" : editor.id} automation={editor === "new" ? undefined : editor} scenes={scenes.data} busy={save.isPending} onClose={() => setEditor(null)} onSubmit={(payload) => save.mutate({ id: editor === "new" ? undefined : editor.id, payload })} /> : null}
|
||||
{historyAutomation ? <Card className="resource-editor"><CardHeader title={text(`Uitvoeringshistoriek · ${historyAutomation.name}`, `Run history · ${historyAutomation.name}`)} description={text("De 25 meest recente handmatige en geplande uitvoeringen.", "The 25 most recent manual and scheduled runs.")} action={<Button variant="ghost" onClick={() => setHistoryAutomation(null)}><X size={16} /> {text("Sluiten", "Close")}</Button>} />{history.isLoading ? <LoadingGrid count={2} /> : history.error ? <ErrorPanel error={history.error} retry={() => void history.refetch()} /> : !history.data?.items.length ? <EmptyState icon={History} title={text("Nog geen uitvoeringen", "No runs yet")} description={text("Test de automation om de eerste uitvoering vast te leggen.", "Test the automation to record its first run.")} /> : <div className="run-list">{history.data.items.map((item) => <div className="run-row" key={item.id}><Badge tone={item.status === "succeeded" ? "success" : item.status === "failed" ? "danger" : "neutral"}>{item.status}</Badge><span>{formatDate(item.started_at)}</span><span>{typeof item.trigger.type === "string" ? item.trigger.type : "manual"}</span><span className="run-row__error">{item.error || "—"}</span></div>)}</div>}</Card> : null}
|
||||
{error ? <ErrorPanel error={error} retry={() => void Promise.all([automations.refetch(), scenes.refetch()])} /> : automations.isLoading ? <LoadingGrid /> : !automations.data?.items.length ? <EmptyState icon={CalendarClock} title={text("Nog geen automations", "No automations yet")} description={text("Plan een scène op een tijdstip en geselecteerde weekdagen.", "Schedule a scene at a time on selected weekdays.")} action={<Button onClick={() => setEditor("new")}>{text("Eerste automation maken", "Create first automation")}</Button>} /> : <div className="automation-list">{automations.data.items.map((automation) => <Card key={automation.id} className="automation-row"><span className="automation-row__icon"><Zap size={20} /></span><div className="automation-row__main"><div><h2>{automation.name}</h2><p><Timer size={14} /> {typeof automation.trigger.at === "string" ? automation.trigger.at : text("handmatig", "manual")} · {weekdaySummary(automation.trigger.weekdays, text)}</p></div><div className="automation-row__meta"><Badge tone={automation.enabled ? "success" : "neutral"}>{automation.enabled ? text("Actief", "Enabled") : text("Uitgeschakeld", "Disabled")}</Badge><span>{text("Volgende", "Next")}: {formatDate(automation.next_run_at)}</span><span>{text("Laatste", "Last")}: {formatDate(automation.last_run_at)}</span>{automation.last_error ? <Badge tone="danger">{automation.last_error}</Badge> : null}</div></div><div className="automation-row__actions"><Button variant="secondary" onClick={() => run.mutate(automation.id)} busy={run.isPending}><Play size={15} /> {text("Test", "Test")}</Button><Button variant="ghost" title={automation.enabled ? text("Uitschakelen", "Disable") : text("Inschakelen", "Enable")} onClick={() => toggle.mutate(automation)} busy={toggle.isPending}>{automation.enabled ? <Pause size={15} /> : <Play size={15} />}</Button><Button variant="ghost" title={text("Historiek", "History")} onClick={() => setHistoryAutomation(automation)}><History size={15} /></Button><Button variant="ghost" title={text("Bewerken", "Edit")} onClick={() => setEditor(automation)}><Pencil size={15} /></Button><Button variant="ghost" title={text("Verwijderen", "Delete")} onClick={() => setDeleteAutomation(automation)}><Trash2 size={15} /></Button></div></Card>)}</div>}
|
||||
<ConfirmDialog open={Boolean(deleteAutomation)} title={text("Automation verwijderen?", "Delete automation?")} description={text(`“${deleteAutomation?.name ?? ""}” wordt uitgeschakeld en verwijderd. Bestaande uitvoeringshistoriek blijft bewaard.`, `“${deleteAutomation?.name ?? ""}” will be disabled and deleted. Existing run history is preserved.`)} confirmLabel={text("Verwijderen", "Delete")} danger busy={remove.isPending} onClose={() => setDeleteAutomation(null)} onConfirm={() => deleteAutomation && remove.mutate(deleteAutomation.id)} />
|
||||
</>;
|
||||
}
|
||||
|
||||
function toPayload(automation: Automation, enabled: boolean): AutomationPayload {
|
||||
const sceneId = automation.actions[0]?.scene_id;
|
||||
return {
|
||||
name: automation.name,
|
||||
description: automation.description,
|
||||
enabled,
|
||||
trigger: {
|
||||
type: "time",
|
||||
at: typeof automation.trigger.at === "string" ? automation.trigger.at : "20:00",
|
||||
weekdays: Array.isArray(automation.trigger.weekdays) ? automation.trigger.weekdays.map(Number) : [0, 1, 2, 3, 4, 5, 6],
|
||||
},
|
||||
actions: [{ type: "scene", scene_id: typeof sceneId === "string" ? sceneId : "" }],
|
||||
timezone: automation.timezone,
|
||||
cooldown_seconds: automation.cooldown_seconds,
|
||||
conflict_key: automation.conflict_key,
|
||||
};
|
||||
}
|
||||
|
||||
function weekdaySummary(value: unknown, text: (dutch: string, english: string) => string): string {
|
||||
if (!Array.isArray(value) || value.length === 7) return text("elke dag", "every day");
|
||||
const labels = text("ma,di,wo,do,vr,za,zo", "Mon,Tue,Wed,Thu,Fri,Sat,Sun").split(",");
|
||||
return value.map(Number).map((day) => labels[day]).filter(Boolean).join(", ");
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Archive, ArchiveRestore, DatabaseBackup, Plus } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { Badge, Button, Card, CardHeader, EmptyState, PageHeader } from "../components/ui";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
|
||||
interface Backup { name: string; size: number; modified_at: number }
|
||||
|
||||
export function BackupsPage() {
|
||||
const { text } = useI18n();
|
||||
const queryClient = useQueryClient();
|
||||
const { notify } = useToast();
|
||||
const [restore, setRestore] = useState<string | null>(null);
|
||||
const query = useQuery({ queryKey: ["backups"], queryFn: () => api<Backup[]>("/api/v1/backups") });
|
||||
const create = useMutation({ mutationFn: () => api<Backup>("/api/v1/backups", { method: "POST" }), onSuccess: () => { notify("Databaseback-up aangemaakt."); void queryClient.invalidateQueries({ queryKey: ["backups"] }); } });
|
||||
const restoreMutation = useMutation({ mutationFn: (name: string) => api<{ safety_backup: string }>("/api/v1/backups/restore", { method: "POST", body: JSON.stringify({ name }) }), onSuccess: (result) => { notify(`Hersteld. Veiligheidskopie: ${result.safety_backup}`); setRestore(null); void queryClient.invalidateQueries(); }, onError: (error) => notify(error instanceof Error ? error.message : "Herstel mislukt.", "danger") });
|
||||
return <>
|
||||
<PageHeader eyebrow={text("Appdata-bescherming", "Appdata protection")} title={text("Back-up & herstel", "Backup & restore")} description={text("SQLite-back-ups zijn atomair; bewaar ook /config/openrgb en secret.key buiten de server.", "SQLite backups are atomic; also store /config/openrgb and secret.key outside the server.")} actions={<Button onClick={() => create.mutate()} busy={create.isPending}><Plus size={16} /> {text("Nieuwe back-up", "New backup")}</Button>} />
|
||||
<div className="alert alert--info"><DatabaseBackup size={20} /><div><strong>Een volledige back-up bestaat uit drie delen</strong><p>Database, /config/openrgb en /config/lumaops/secret.key. Zonder de sleutel zijn connectorsecrets niet herstelbaar.</p></div></div>
|
||||
<Card><CardHeader title="Beheerde databaseback-ups" description="Voor destructieve migraties en restores wordt automatisch een extra veiligheidskopie gemaakt." />{query.data?.length ? <div className="backup-list">{query.data.map((backup) => <div className="backup-row" key={backup.name}><span><Archive size={19} /></span><div><strong>{backup.name}</strong><p>{new Intl.NumberFormat(undefined, { style: "unit", unit: "megabyte", maximumFractionDigits: 2 }).format(backup.size / 1_048_576)} · {new Date(backup.modified_at * 1000).toLocaleString()}</p></div><Badge>SQLite</Badge><Button variant="secondary" onClick={() => setRestore(backup.name)}><ArchiveRestore size={15} /> Herstellen</Button></div>)}</div> : <EmptyState icon={Archive} title="Nog geen back-ups" description="Maak een eerste herstelpunt voordat je hardware en scènes configureert." />}</Card>
|
||||
<ConfirmDialog open={restore !== null} title="Database herstellen?" description={`Alle huidige LumaOps-data wordt vervangen door ${restore ?? "deze back-up"}. Eerst wordt automatisch een veiligheidskopie gemaakt.`} confirmLabel="Herstellen" danger onClose={() => setRestore(null)} onConfirm={() => restore && restoreMutation.mutate(restore)} />
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { Cable, Check, PlugZap, RefreshCw, ShieldAlert } from "lucide-react";
|
||||
import { api } from "../api/client";
|
||||
import type { ComponentHealth } from "../api/types";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { Badge, Button, Card, CardHeader, LoadingGrid, PageHeader, StatusBadge } from "../components/ui";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
|
||||
interface Connector { id: string; kind: string; configuration_schema: { warning?: string }; health: ComponentHealth }
|
||||
|
||||
export function ConnectorsPage() {
|
||||
const { text } = useI18n();
|
||||
const { notify } = useToast();
|
||||
const query = useQuery({ queryKey: ["connectors"], queryFn: () => api<Connector[]>("/api/v1/connectors") });
|
||||
const test = useMutation({ mutationFn: (id: string) => api<ComponentHealth>(`/api/v1/connectors/${id}/test`, { method: "POST" }), onSuccess: (health) => notify(health.connected ? "Verbindingstest geslaagd." : health.message, health.connected ? "success" : "danger") });
|
||||
return <>
|
||||
<PageHeader eyebrow={text("Adapterlaag", "Adapter layer")} title={text("Connectoren", "Connectors")} description={text("Eén capabilitymodel voor OpenRGB en toekomstige WLED-, Home Assistant- en MQTT-integraties.", "One capability model for OpenRGB and future WLED, Home Assistant and MQTT integrations.")} />
|
||||
{query.isLoading ? <LoadingGrid count={2} /> : <div className="grid grid--connectors">{query.data?.map((connector) => <Card key={connector.id} className="connector-card"><CardHeader title={connector.id === "openrgb-local" ? "OpenRGB Core" : connector.kind} description={connector.kind === "openrgb" ? "Primaire hardware-engine · SDK protocol 5" : "Expliciete testadapter"} action={<StatusBadge status={connector.health.status} />} /><div className="connector-hero"><span><PlugZap size={27} /></span><div><strong>{connector.health.connected ? "Verbonden" : "Niet verbonden"}</strong><p>{connector.health.message}</p></div></div><div className="connector-meta"><div><span>Type</span><strong>{connector.kind}</strong></div><div><span>Authenticatie</span><strong>{connector.kind === "openrgb" ? "Loopback" : "Niet vereist"}</strong></div></div>{connector.configuration_schema.warning ? <div className="mini-warning"><ShieldAlert size={15} />{connector.configuration_schema.warning}</div> : null}<Button variant="secondary" onClick={() => test.mutate(connector.id)} busy={test.isPending}><RefreshCw size={15} /> Verbinding testen</Button></Card>)}</div>}
|
||||
<section className="section-block"><div className="section-heading"><div><h2>Uitbreidingsfase</h2><p>Connectorcontract is voorbereid; native integraties blijven standaard uit.</p></div></div><div className="grid grid--cards">{[["WLED", "JSON API, segmenten, effecten en presets"], ["Home Assistant", "REST, WebSocket en geselecteerde light-entiteiten"], ["MQTT", "Generieke toekomstige event- en statebridge"]].map(([name, description]) => <Card key={name} className="planned-connector"><span><Cable size={19} /></span><div><h3>{name}</h3><p>{description}</p></div><Badge><Check size={12} /> Architectuur klaar</Badge></Card>)}</div></section>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { focusManager } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import { DeviceDetailPage } from "./DeviceDetailPage";
|
||||
import { jsonResponse, renderApp, requestJson, requestUrl } from "../test/render";
|
||||
|
||||
const device = {
|
||||
id: "device-1", connector_id: "openrgb", external_id: "asus", fingerprint: "asus", source: "openrgb", device_type: "motherboard", owner: "openrgb",
|
||||
name: "ASUS Aura Mainboard", alias: null, vendor: "ASUS", model: "Aura", serial: null, location: "HID: /dev/hidraw0",
|
||||
ip_address: null, firmware_version: null, controller_index: 0,
|
||||
capabilities: { power: true, restore: true, rgb: true, brightness: false, color_temperature: false, effect: true, speed: false,
|
||||
direction: false, multiple_colors: true, per_zone: true, per_segment: false, per_led: true, profiles: false, readable_state: true,
|
||||
max_leds: 126, min_brightness: 0, max_brightness: 100, min_speed: null, max_speed: null },
|
||||
state: { power: true, colors: [{ red: 32, green: 64, blue: 128 }], mode: "Direct", mode_index: 0 },
|
||||
zones: [
|
||||
{ index: 0, name: "Aura Mainboard", type: 1, led_count: 6, leds_min: 6, leds_max: 6 },
|
||||
{ index: 1, name: "Aura Addressable 1", type: 1, led_count: 0, leds_min: 0, leds_max: 120 },
|
||||
],
|
||||
modes: [
|
||||
{ index: 0, name: "Direct", flags: 32, speed_min: null, speed_max: null, brightness: false, colors_min: 0, colors_max: 0 },
|
||||
{ index: 1, name: "Off", flags: 256, speed_min: null, speed_max: null, brightness: false, colors_min: 0, colors_max: 0 },
|
||||
{ index: 2, name: "Static", flags: 320, speed_min: null, speed_max: null, brightness: false, colors_min: 1, colors_max: 1 },
|
||||
], metadata: {}, led_count: 6, online: true, hidden: false, favorite: false, exclude_global: false,
|
||||
read_only: false, blocked: false, experimental: false, room_id: null, tags: [], error_status: null,
|
||||
last_detected_at: "2026-07-15T02:00:00Z", last_command_at: null,
|
||||
};
|
||||
|
||||
const ramDevice = {
|
||||
...device,
|
||||
id: "ram-1",
|
||||
device_type: "dram",
|
||||
external_id: "corsair-ram",
|
||||
fingerprint: "corsair-ram",
|
||||
name: "Corsair Vengeance RGB Pro SL DDR4",
|
||||
vendor: "Corsair",
|
||||
model: "Corsair DRAM RGB Device",
|
||||
location: "I2C: /dev/i2c-0, address 0x58",
|
||||
capabilities: { ...device.capabilities, brightness: true, speed: true, direction: true, max_leds: 10 },
|
||||
state: { power: false, brightness: null, colors: Array.from({ length: 10 }, () => ({ red: 0, green: 0, blue: 0 })), mode: "Direct", mode_index: 0 },
|
||||
zones: [{ index: 0, name: "Corsair DRAM", type: 1, led_count: 10, leds_min: 10, leds_max: 10 }],
|
||||
modes: [
|
||||
{ index: 0, name: "Direct", flags: 32, speed_min: null, speed_max: null, brightness: false, colors_min: 0, colors_max: 0 },
|
||||
{ index: 1, name: "Custom", flags: 544, speed_min: null, speed_max: null, brightness: false, colors_min: 0, colors_max: 0 },
|
||||
{ index: 2, name: "Color Pulse", flags: 721, speed_min: 0, speed_max: 2, brightness: true, colors_min: 2, colors_max: 2 },
|
||||
{ index: 5, name: "Color Wave", flags: 727, speed_min: 0, speed_max: 2, brightness: true, colors_min: 2, colors_max: 2 },
|
||||
{ index: 9, name: "Rainbow", flags: 513, speed_min: 0, speed_max: 2, brightness: false, colors_min: 0, colors_max: 0 },
|
||||
],
|
||||
led_count: 10,
|
||||
};
|
||||
|
||||
describe("DeviceDetailPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(window.matchMedia).mockImplementation((query) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (requestUrl(input).endsWith("/zones/1/state") && init?.method === "POST") {
|
||||
return Promise.resolve(jsonResponse({ id: "zone-command", status: "succeeded", zone_index: 1, led_count: 120 }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(device));
|
||||
}));
|
||||
});
|
||||
|
||||
it("uses every addressable LED without asking for a count", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Routes><Route path="/devices/:deviceId" element={<DeviceDetailPage />} /></Routes>, "/devices/device-1");
|
||||
const input = await screen.findByRole("textbox", { name: "Hoofdkleur voor Aura Addressable 1" });
|
||||
await user.clear(input);
|
||||
await user.type(input, "#336699");
|
||||
expect(screen.queryByText(/Aantal leds voor/)).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Alle 120 leds")).toBeInTheDocument();
|
||||
await user.click(screen.getByRole("button", { name: "Alleen Aura Addressable 1 toepassen" }));
|
||||
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/v1/devices/device-1/zones/1/state",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
));
|
||||
const zoneCall = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/zones/1/state"));
|
||||
expect(requestJson(zoneCall?.[1])).toEqual({
|
||||
state: { power: true, colors: [{ red: 51, green: 102, blue: 153 }] },
|
||||
});
|
||||
});
|
||||
|
||||
it("applies an individual rainbow pattern to an addressable fan header", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Routes><Route path="/devices/:deviceId" element={<DeviceDetailPage />} /></Routes>, "/devices/device-1");
|
||||
|
||||
await user.selectOptions(await screen.findByRole("combobox", { name: "Modus voor Aura Addressable 1" }), "rainbow");
|
||||
await user.click(screen.getByRole("button", { name: "Alleen Aura Addressable 1 toepassen" }));
|
||||
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/v1/devices/device-1/zones/1/state",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
));
|
||||
const zoneCall = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/zones/1/state"));
|
||||
const body = requestJson(zoneCall?.[1]) as { state: { colors: unknown[] } };
|
||||
expect(body.state.colors).toHaveLength(120);
|
||||
expect(new Set(body.state.colors.map((color) => JSON.stringify(color))).size).toBeGreaterThan(6);
|
||||
});
|
||||
|
||||
it("keeps Direct as a hardware mode when no color is changed", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Routes><Route path="/devices/:deviceId" element={<DeviceDetailPage />} /></Routes>, "/devices/device-1");
|
||||
|
||||
await screen.findByRole("combobox", { name: "Effectmodus" });
|
||||
await user.click(screen.getByRole("button", { name: "Instellingen toepassen" }));
|
||||
|
||||
const stateCall = await waitFor(() => {
|
||||
const call = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/state"));
|
||||
expect(call).toBeDefined();
|
||||
return call;
|
||||
});
|
||||
expect(requestJson(stateCall?.[1])).toEqual({
|
||||
state: { power: true, mode_index: 0, colors: [{ red: 32, green: 64, blue: 128 }] },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a RAM color stable and omits unsupported brightness", async () => {
|
||||
let currentRam: unknown = ramDevice;
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (requestUrl(input).endsWith("/state") && init?.method === "POST") {
|
||||
currentRam = {
|
||||
...ramDevice,
|
||||
last_command_at: "2026-07-16T00:00:01Z",
|
||||
state: {
|
||||
power: true,
|
||||
brightness: null,
|
||||
colors: Array.from({ length: 10 }, () => ({ red: 136, green: 68, blue: 204 })),
|
||||
mode: "Custom",
|
||||
mode_index: 1,
|
||||
},
|
||||
};
|
||||
return Promise.resolve(jsonResponse({ id: "command-1", status: "succeeded" }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(currentRam));
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Routes><Route path="/devices/:deviceId" element={<DeviceDetailPage />} /></Routes>, "/devices/ram-1");
|
||||
|
||||
const colorInput = await screen.findByRole("textbox", { name: "Kleur 1" });
|
||||
expect(screen.queryByText(/Helderheid/)).not.toBeInTheDocument();
|
||||
await user.clear(colorInput);
|
||||
await user.type(colorInput, "#8844cc");
|
||||
expect(screen.getByRole("combobox", { name: "Effectmodus" })).toHaveValue("1");
|
||||
|
||||
const readsBeforeRefetch = vi.mocked(fetch).mock.calls.filter(
|
||||
([input, init]) => requestUrl(input).endsWith("/ram-1") && !init?.method,
|
||||
).length;
|
||||
currentRam = { ...ramDevice, last_command_at: "2026-07-16T00:00:00Z" };
|
||||
focusManager.setFocused(false);
|
||||
focusManager.setFocused(true);
|
||||
await waitFor(() => expect(vi.mocked(fetch).mock.calls.filter(
|
||||
([input, init]) => requestUrl(input).endsWith("/ram-1") && !init?.method,
|
||||
).length).toBeGreaterThan(readsBeforeRefetch));
|
||||
expect(colorInput).toHaveValue("#8844CC");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Instellingen toepassen" }));
|
||||
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/v1/devices/ram-1/state",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
));
|
||||
const stateCall = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/state"));
|
||||
expect(requestJson(stateCall?.[1])).toEqual({
|
||||
state: { power: true, colors: [{ red: 136, green: 68, blue: 204 }], mode_index: 1 },
|
||||
});
|
||||
await waitFor(() => expect(colorInput).toHaveValue("#8844CC"));
|
||||
focusManager.setFocused(undefined);
|
||||
});
|
||||
|
||||
it("sends every parameter required by a two-color RAM effect", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(ramDevice)));
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Routes><Route path="/devices/:deviceId" element={<DeviceDetailPage />} /></Routes>, "/devices/ram-1");
|
||||
|
||||
await user.selectOptions(await screen.findByRole("combobox", { name: "Effectmodus" }), "5");
|
||||
const firstColor = screen.getByRole("textbox", { name: "Kleur 1" });
|
||||
const secondColor = screen.getByRole("textbox", { name: "Kleur 2" });
|
||||
await user.clear(firstColor);
|
||||
await user.type(firstColor, "#112233");
|
||||
await user.clear(secondColor);
|
||||
await user.type(secondColor, "#aabbcc");
|
||||
fireEvent.change(screen.getByLabelText("Helderheid"), { target: { value: "75" } });
|
||||
fireEvent.change(screen.getByLabelText("Snelheid"), { target: { value: "2" } });
|
||||
await user.selectOptions(screen.getByRole("combobox", { name: "Richting" }), "1");
|
||||
await user.click(screen.getByRole("button", { name: "Instellingen toepassen" }));
|
||||
|
||||
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/v1/devices/ram-1/state",
|
||||
expect.objectContaining({ method: "POST" }),
|
||||
));
|
||||
const stateCall = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/state"));
|
||||
expect(requestJson(stateCall?.[1])).toEqual({
|
||||
state: {
|
||||
power: true,
|
||||
mode_index: 5,
|
||||
colors: [{ red: 17, green: 34, blue: 51 }, { red: 170, green: 187, blue: 204 }],
|
||||
brightness: 75,
|
||||
speed: 2,
|
||||
direction: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not overwrite a self-colored hardware effect with a static color", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse(ramDevice)));
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Routes><Route path="/devices/:deviceId" element={<DeviceDetailPage />} /></Routes>, "/devices/ram-1");
|
||||
|
||||
await user.selectOptions(await screen.findByRole("combobox", { name: "Effectmodus" }), "9");
|
||||
expect(screen.queryByRole("textbox", { name: "Kleur 1" })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("Deze hardwaremodus genereert zijn kleuren automatisch.")).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("Snelheid"), { target: { value: "2" } });
|
||||
await user.click(screen.getByRole("button", { name: "Instellingen toepassen" }));
|
||||
|
||||
const stateCall = await waitFor(() => {
|
||||
const call = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/state"));
|
||||
expect(call).toBeDefined();
|
||||
return call;
|
||||
});
|
||||
expect(requestJson(stateCall?.[1])).toEqual({ state: { power: true, mode_index: 9, speed: 2 } });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowLeft, Eye, Lightbulb, MapPin, ScanLine, Shield, Star, Tag, Wifi } from "lucide-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api, mutationId } from "../api/client";
|
||||
import type { Device, DeviceState } from "../api/types";
|
||||
import { RgbControlPanel } from "../components/RgbControlPanel";
|
||||
import { RgbZoneControls } from "../components/RgbZoneControls";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { Badge, Button, Card, CardHeader, ErrorPanel, LoadingGrid, PageHeader } from "../components/ui";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { formatDate } from "../lib/utils";
|
||||
|
||||
export function DeviceDetailPage() {
|
||||
const { text } = useI18n();
|
||||
const { deviceId = "" } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { notify } = useToast();
|
||||
const query = useQuery({
|
||||
queryKey: ["device", deviceId],
|
||||
queryFn: () => api<Device>(`/api/v1/devices/${deviceId}`),
|
||||
});
|
||||
|
||||
const command = useMutation({
|
||||
mutationFn: (state: DeviceState) => api(`/api/v1/devices/${deviceId}/state`, {
|
||||
method: "POST",
|
||||
headers: { "Idempotency-Key": mutationId() },
|
||||
body: JSON.stringify({ state }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
notify("Apparaat bijgewerkt.");
|
||||
void queryClient.invalidateQueries({ queryKey: ["device", deviceId] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["device-groups"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["system"] });
|
||||
},
|
||||
onError: (error) => notify(error instanceof Error ? error.message : "Opdracht mislukt.", "danger"),
|
||||
});
|
||||
const patch = useMutation({
|
||||
mutationFn: (value: Record<string, unknown>) => api<Device>(`/api/v1/devices/${deviceId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(value),
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(["device", deviceId], data);
|
||||
notify("Apparaatinstelling opgeslagen.");
|
||||
},
|
||||
});
|
||||
const identify = useMutation({
|
||||
mutationFn: () => api(`/api/v1/devices/${deviceId}/identify`, { method: "POST" }),
|
||||
onSuccess: () => notify("Identificatie afgerond."),
|
||||
});
|
||||
const zoneCommand = useMutation({
|
||||
mutationFn: ({ zoneIndex: targetZone, state }: { zoneIndex: number; state: DeviceState }) =>
|
||||
api(`/api/v1/devices/${deviceId}/zones/${targetZone}/state`, {
|
||||
method: "POST",
|
||||
headers: { "Idempotency-Key": mutationId() },
|
||||
body: JSON.stringify({ state }),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["device", deviceId] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["device-groups"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["system"] });
|
||||
notify("Aura-zone bijgewerkt.");
|
||||
},
|
||||
onError: (error) => notify(error instanceof Error ? error.message : "Aura-zone kon niet worden bijgewerkt.", "danger"),
|
||||
});
|
||||
|
||||
if (query.isLoading) return <LoadingGrid count={3} />;
|
||||
if (query.error || !query.data) return <ErrorPanel error={query.error} retry={() => void query.refetch()} />;
|
||||
const device = query.data;
|
||||
|
||||
return <>
|
||||
<Link className="back-link" to="/devices"><ArrowLeft size={16} /> Terug naar apparaten</Link>
|
||||
<PageHeader
|
||||
eyebrow={device.source}
|
||||
title={device.alias || device.name}
|
||||
description={[device.vendor, device.model, device.location].filter(Boolean).join(" · ")}
|
||||
actions={<>
|
||||
<Button variant="secondary" onClick={() => identify.mutate()} busy={identify.isPending}><ScanLine size={16} /> {text("Identificeren", "Identify")}</Button>
|
||||
<Button variant="ghost" onClick={() => patch.mutate({ favorite: !device.favorite })}><Star size={16} fill={device.favorite ? "currentColor" : "none"} /> {text("Favoriet", "Favorite")}</Button>
|
||||
</>}
|
||||
/>
|
||||
<div className="detail-status">
|
||||
<Badge tone={device.online ? "success" : "danger"}><Wifi size={13} /> {device.online ? "Online" : "Offline"}</Badge>
|
||||
{device.read_only ? <Badge tone="warning">Alleen lezen</Badge> : null}
|
||||
{device.experimental ? <Badge tone="warning">Experimenteel</Badge> : null}
|
||||
<span>Laatst gezien {formatDate(device.last_detected_at)}</span>
|
||||
</div>
|
||||
<div className="detail-grid">
|
||||
<RgbControlPanel
|
||||
targetKey={device.id}
|
||||
capabilities={device.capabilities}
|
||||
state={device.state}
|
||||
modes={device.modes}
|
||||
pending={command.isPending}
|
||||
disabled={!device.online || device.read_only || device.blocked}
|
||||
onApply={(state) => command.mutate(state)}
|
||||
/>
|
||||
<div className="detail-side">
|
||||
<Card><CardHeader title="Eigenschappen" /><dl className="properties"><div><dt><Shield size={15} /> Beheerder</dt><dd>{device.owner}</dd></div><div><dt><MapPin size={15} /> Locatie</dt><dd>{device.location || "Niet bekend"}</dd></div><div><dt><Lightbulb size={15} /> Leds</dt><dd>{device.led_count}</dd></div><div><dt><Tag size={15} /> Serienummer</dt><dd>{device.serial || "Niet gemeld"}</dd></div></dl></Card>
|
||||
<Card><CardHeader title="Beheerbeleid" description="Voorkom onbedoelde globale of wijzigende acties." /><div className="switch-list"><Toggle label="Favoriet" checked={device.favorite} onChange={(value) => patch.mutate({ favorite: value })} /><Toggle label="Verbergen" checked={device.hidden} onChange={(value) => patch.mutate({ hidden: value })} /><Toggle label="Alleen lezen" checked={device.read_only} onChange={(value) => patch.mutate({ read_only: value })} /><Toggle label="Blokkeren" checked={device.blocked} onChange={(value) => patch.mutate({ blocked: value })} danger /></div></Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{device.capabilities.per_zone && device.zones.length ? <section className="section-block">
|
||||
<div className="section-heading"><div><h2>Moederbord en Aura-addressables</h2><p>Bedien elke zone afzonderlijk. LumaOps gebruikt automatisch de volledige header; hardware-effecten in de algemene bediening blijven controllerbreed.</p></div></div>
|
||||
<RgbZoneControls
|
||||
targetKey={device.id}
|
||||
zones={device.zones}
|
||||
state={device.state}
|
||||
pendingZone={zoneCommand.isPending ? zoneCommand.variables?.zoneIndex : undefined}
|
||||
disabled={!device.online || device.read_only || device.blocked}
|
||||
onApply={(zoneIndex, state) => zoneCommand.mutate({ zoneIndex, state })}
|
||||
/>
|
||||
</section> : null}
|
||||
|
||||
<section className="section-block"><div className="section-heading"><div><h2>Zones en capabilities</h2><p>OpenRGB SDK-v5 inventory zonder write-side effects.</p></div></div><div className="grid grid--cards">{device.zones.map((zone) => <Card key={zone.index}><div className="zone-card"><span><Eye size={18} /></span><div><h3>{zone.name}</h3><p>{zone.led_count} leds · type {zone.type}</p></div></div></Card>)}{!device.zones.length ? <Card><p className="muted">Dit apparaat rapporteert geen zones.</p></Card> : null}</div></section>
|
||||
</>;
|
||||
}
|
||||
|
||||
function Toggle({ label, checked, onChange, danger = false }: { label: string; checked: boolean; onChange: (value: boolean) => void; danger?: boolean }) {
|
||||
return <label className={`switch-row ${danger ? "switch-row--danger" : ""}`}><span>{label}</span><input type="checkbox" role="switch" checked={checked} onChange={(event) => onChange(event.target.checked)} /><i /></label>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { Route, Routes } from "react-router-dom";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { DeviceGroupPage } from "./DeviceGroupPage";
|
||||
import { jsonResponse, renderApp, requestJson, requestUrl } from "../test/render";
|
||||
|
||||
const capabilities = {
|
||||
power: true, restore: true, rgb: true, brightness: true, color_temperature: false, effect: true, speed: true,
|
||||
direction: true, multiple_colors: true, per_zone: true, per_segment: false, per_led: true, profiles: true,
|
||||
readable_state: true, max_leds: 40, min_brightness: 0, max_brightness: 100, min_speed: 0, max_speed: 2,
|
||||
};
|
||||
const modes = [
|
||||
{ index: 0, name: "Direct", flags: 32, speed_min: null, speed_max: null, brightness: false, colors_min: 0, colors_max: 0 },
|
||||
{ index: 1, name: "Custom", flags: 544, speed_min: null, speed_max: null, brightness: false, colors_min: 0, colors_max: 0 },
|
||||
{ index: 9, name: "Rainbow", flags: 513, speed_min: 0, speed_max: 2, brightness: false, colors_min: 0, colors_max: 0 },
|
||||
];
|
||||
const modules = [0, 1, 2, 3].map((index) => ({
|
||||
id: `ram-${index}`, connector_id: "openrgb-local", external_id: `ram-${index}`, fingerprint: `ram-${index}`,
|
||||
source: "openrgb", device_type: "dram", owner: "openrgb", name: "Corsair Vengeance RGB Pro SL DDR4", alias: null,
|
||||
vendor: "Corsair", model: "Corsair DRAM RGB Device", serial: null, location: `I2C: SMBus, address 0x5${8 + index}`,
|
||||
ip_address: null, firmware_version: null, controller_index: index, capabilities: { ...capabilities, max_leds: 10 },
|
||||
state: { power: true, colors: Array.from({ length: 10 }, () => ({ red: 86, green: 96, blue: 255 })), mode: "Custom", mode_index: 1 },
|
||||
zones: [], modes, metadata: {}, led_count: 10, online: true, hidden: false, favorite: false, exclude_global: false,
|
||||
read_only: false, blocked: false, experimental: false, room_id: null, tags: [], error_status: null,
|
||||
last_detected_at: "2026-07-16T00:00:00Z", last_command_at: null,
|
||||
}));
|
||||
const group = {
|
||||
id: "dram-family", kind: "device-family", device_type: "dram", connector_id: "openrgb-local",
|
||||
name: "Corsair Vengeance RGB Pro SL DDR4", vendor: "Corsair", model: "Corsair DRAM RGB Device",
|
||||
online: true, online_count: 4, module_count: 4, led_count: 40, capabilities,
|
||||
state: { power: true, colors: [{ red: 86, green: 96, blue: 255 }], mode: "Custom", mode_index: 1 },
|
||||
modes, mixed: false, devices: modules,
|
||||
};
|
||||
|
||||
describe("DeviceGroupPage", () => {
|
||||
it("controls the RAM family and links every physical module", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (requestUrl(input).endsWith("/state") && init?.method === "POST") {
|
||||
return Promise.resolve(jsonResponse({ status: "succeeded", results: modules.map(() => ({ status: "succeeded" })) }));
|
||||
}
|
||||
return Promise.resolve(jsonResponse(group));
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
renderApp(<Routes><Route path="/device-groups/:groupId" element={<DeviceGroupPage />} /></Routes>, "/device-groups/dram-family");
|
||||
|
||||
expect(await screen.findByText("4 fysieke modules", { exact: false })).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/RAM-module [1-4]/)).toHaveLength(4);
|
||||
const color = screen.getByRole("textbox", { name: "Kleur 1" });
|
||||
await user.clear(color);
|
||||
await user.type(color, "#2244aa");
|
||||
await user.click(screen.getByRole("button", { name: "Instellingen toepassen" }));
|
||||
|
||||
const stateCall = await waitFor(() => {
|
||||
const call = vi.mocked(fetch).mock.calls.find(([input]) => requestUrl(input).endsWith("/state"));
|
||||
expect(call).toBeDefined();
|
||||
return call;
|
||||
});
|
||||
expect(requestJson(stateCall?.[1])).toEqual({
|
||||
state: { power: true, mode_index: 1, colors: [{ red: 34, green: 68, blue: 170 }] },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { ArrowLeft, Layers3, MemoryStick, Wifi } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import type { DeviceGroup, DeviceState } from "../api/types";
|
||||
import { DeviceCard } from "../components/DeviceCard";
|
||||
import { RgbControlPanel } from "../components/RgbControlPanel";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { Badge, ErrorPanel, LoadingGrid, PageHeader } from "../components/ui";
|
||||
|
||||
export function DeviceGroupPage() {
|
||||
const { groupId = "" } = useParams();
|
||||
const queryClient = useQueryClient();
|
||||
const { notify } = useToast();
|
||||
const query = useQuery({
|
||||
queryKey: ["device-group", groupId],
|
||||
queryFn: () => api<DeviceGroup>(`/api/v1/device-groups/${groupId}`),
|
||||
});
|
||||
const command = useMutation({
|
||||
mutationFn: (state: DeviceState) => api<{ status: string; results: Array<{ status: string }> }>(`/api/v1/device-groups/${groupId}/state`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ state }),
|
||||
}),
|
||||
onSuccess: (result) => {
|
||||
const failed = result.results.filter((item) => item.status === "failed").length;
|
||||
notify(failed ? `${failed} RAM-modules konden niet worden bijgewerkt.` : "RAM-groep bijgewerkt.", failed ? "danger" : "success");
|
||||
void queryClient.invalidateQueries({ queryKey: ["device-group", groupId] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["device-groups"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["system"] });
|
||||
},
|
||||
onError: (error) => notify(error instanceof Error ? error.message : "RAM-groepsopdracht mislukt.", "danger"),
|
||||
});
|
||||
|
||||
if (query.isLoading) return <LoadingGrid count={3} />;
|
||||
if (query.error || !query.data) return <ErrorPanel error={query.error} retry={() => void query.refetch()} />;
|
||||
const group = query.data;
|
||||
const disabled = group.online_count === 0 || group.devices.every((device) => device.read_only || device.blocked);
|
||||
|
||||
return <>
|
||||
<Link className="back-link" to="/devices"><ArrowLeft size={16} /> Terug naar apparaten</Link>
|
||||
<PageHeader
|
||||
eyebrow="Gegroepeerd apparaat"
|
||||
title={group.name}
|
||||
description={`${group.vendor ?? "RAM"} · ${group.module_count} fysieke modules · ${group.led_count} leds`}
|
||||
/>
|
||||
<div className="detail-status">
|
||||
<Badge tone={group.online ? "success" : "warning"}><Wifi size={13} /> {group.online_count}/{group.module_count} online</Badge>
|
||||
<Badge tone="accent"><Layers3 size={13} /> Als groep bedienbaar</Badge>
|
||||
{group.mixed ? <Badge tone="warning">Gemengde toestand</Badge> : null}
|
||||
</div>
|
||||
<div className="detail-grid device-group-overview">
|
||||
<RgbControlPanel
|
||||
targetKey={group.id}
|
||||
capabilities={group.capabilities}
|
||||
state={group.state}
|
||||
modes={group.modes}
|
||||
pending={command.isPending}
|
||||
disabled={disabled}
|
||||
onApply={(state) => command.mutate(state)}
|
||||
/>
|
||||
<div className="group-summary">
|
||||
<MemoryStick size={34} />
|
||||
<strong>{group.module_count} modules</strong>
|
||||
<span>Groepsopdrachten worden afzonderlijk bevestigd en blijvend opgeslagen per fysieke module.</span>
|
||||
</div>
|
||||
</div>
|
||||
<section className="section-block">
|
||||
<div className="section-heading"><div><h2>Individuele RAM-modules</h2><p>Open een module om kleur, effect en overige mogelijkheden uitsluitend daarop toe te passen.</p></div></div>
|
||||
<div className="grid grid--devices">
|
||||
{group.devices.map((device, index) => <DeviceCard device={device} moduleLabel={`RAM-module ${index + 1} · ${moduleAddress(device.location)}`} key={device.id} />)}
|
||||
</div>
|
||||
</section>
|
||||
</>;
|
||||
}
|
||||
|
||||
function moduleAddress(location: string | null) {
|
||||
const address = location?.match(/address (0x[0-9a-f]+)/i)?.[1];
|
||||
return address?.toUpperCase() ?? "onbekend adres";
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { DevicesPage } from "./DevicesPage";
|
||||
import { jsonResponse, renderApp, requestUrl } from "../test/render";
|
||||
|
||||
const devices = {
|
||||
items: [
|
||||
{
|
||||
id: "device-1", connector_id: "openrgb-mock", external_id: "one", fingerprint: "one", source: "mock", owner: "openrgb",
|
||||
name: "Aurora Mainboard", alias: null, vendor: "LumaOps Lab", model: "Virtual RGB", serial: "MOCK-1", location: "mock://one",
|
||||
ip_address: null, firmware_version: "1.0", controller_index: 0, capabilities: { power: true, restore: true, rgb: true, brightness: true,
|
||||
color_temperature: false, effect: true, speed: true, direction: false, multiple_colors: true, per_zone: true, per_segment: false,
|
||||
per_led: true, profiles: true, readable_state: true, max_leds: 12, min_brightness: 0, max_brightness: 100, min_speed: 1, max_speed: 10 },
|
||||
state: { power: true, brightness: 70, colors: [{ red: 86, green: 96, blue: 255 }], mode: "Static" }, zones: [], modes: [], led_count: 12,
|
||||
online: true, hidden: false, favorite: true, exclude_global: false, read_only: false, blocked: false, experimental: false, room_id: null,
|
||||
error_status: null, last_detected_at: "2026-07-14T12:00:00Z", last_command_at: null,
|
||||
},
|
||||
], total: 1, limit: 500, offset: 0,
|
||||
};
|
||||
|
||||
describe("DevicesPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("matchMedia", vi.fn().mockImplementation(() => ({
|
||||
matches: false,
|
||||
media: "",
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})));
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL) => Promise.resolve(
|
||||
jsonResponse(requestUrl(input).endsWith("/device-groups") ? [] : devices),
|
||||
)));
|
||||
});
|
||||
it("shows and filters normalized inventory", async () => {
|
||||
renderApp(<DevicesPage />, "/devices");
|
||||
expect(await screen.findByText("Aurora Mainboard")).toBeInTheDocument();
|
||||
await userEvent.type(screen.getByLabelText("Apparaten zoeken"), "niet-bestaand");
|
||||
expect(screen.getByText("Geen apparaten in deze selectie")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows identical RAM modules as one grouped device", async () => {
|
||||
const module = { ...devices.items[0], device_type: "dram", name: "Corsair Vengeance RGB Pro SL DDR4", vendor: "Corsair", model: "Corsair DRAM RGB Device", metadata: {}, favorite: false, led_count: 10 };
|
||||
const modules = [0, 1, 2, 3].map((index) => ({ ...module, id: `ram-${index}`, external_id: `ram-${index}`, fingerprint: `ram-${index}`, controller_index: index }));
|
||||
const group = {
|
||||
id: "dram-family", kind: "device-family", device_type: "dram", connector_id: "openrgb-local",
|
||||
name: module.name, vendor: module.vendor, model: module.model, online: true, online_count: 4, module_count: 4,
|
||||
led_count: 40, capabilities: module.capabilities, state: module.state, modes: [], mixed: false, devices: modules,
|
||||
};
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL) => Promise.resolve(
|
||||
jsonResponse(requestUrl(input).endsWith("/device-groups") ? [group] : { items: modules, total: 4, limit: 500, offset: 0 }),
|
||||
)));
|
||||
|
||||
renderApp(<DevicesPage />, "/devices");
|
||||
expect(await screen.findByText("Corsair Vengeance RGB Pro SL DDR4")).toBeInTheDocument();
|
||||
expect(screen.getByText("4 modules · 40 leds")).toBeInTheDocument();
|
||||
expect(screen.getByText("gegroepeerd RAM-apparaat", { exact: false })).toBeInTheDocument();
|
||||
expect(screen.queryByText("RAM-module 1", { exact: false })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Cpu, Grid2X2, List, RefreshCw, Search } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import type { Device, DeviceGroup, Page } from "../api/types";
|
||||
import { DeviceCard } from "../components/DeviceCard";
|
||||
import { DeviceGroupCard } from "../components/DeviceGroupCard";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { Button, EmptyState, ErrorPanel, Input, LoadingGrid, PageHeader } from "../components/ui";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
|
||||
type InventoryEntry = { kind: "device"; device: Device } | { kind: "group"; group: DeviceGroup };
|
||||
|
||||
export function DevicesPage() {
|
||||
const { text } = useI18n();
|
||||
const queryClient = useQueryClient();
|
||||
const { notify } = useToast();
|
||||
const [search, setSearch] = useState("");
|
||||
const [status, setStatus] = useState<"all" | "online" | "offline">("all");
|
||||
const [view, setView] = useState<"grid" | "list">("grid");
|
||||
const query = useQuery({ queryKey: ["devices"], queryFn: () => api<Page<Device>>("/api/v1/devices?limit=500") });
|
||||
const groups = useQuery({ queryKey: ["device-groups"], queryFn: () => api<DeviceGroup[]>("/api/v1/device-groups") });
|
||||
const rescan = useMutation({ mutationFn: () => api("/api/v1/devices/rescan", { method: "POST" }), onSuccess: () => { notify("Apparaatscan afgerond."); void queryClient.invalidateQueries({ queryKey: ["devices"] }); void queryClient.invalidateQueries({ queryKey: ["device-groups"] }); }, onError: (error) => notify(error instanceof Error ? error.message : "Scan mislukt.", "danger") });
|
||||
const filtered = useMemo(() => {
|
||||
const groupedIds = new Set((groups.data ?? []).flatMap((group) => group.devices.map((device) => device.id)));
|
||||
const entries: InventoryEntry[] = [
|
||||
...(groups.data ?? []).map((group): InventoryEntry => ({ kind: "group", group })),
|
||||
...(query.data?.items ?? []).filter((device) => !groupedIds.has(device.id)).map((device): InventoryEntry => ({ kind: "device", device })),
|
||||
];
|
||||
const searchTerm = search.toLocaleLowerCase();
|
||||
return entries.filter((entry) => {
|
||||
const item = entry.kind === "device" ? entry.device : entry.group;
|
||||
const searchable = `${entry.kind === "device" ? entry.device.alias ?? "" : "RAM geheugen modules"} ${item.name} ${item.vendor ?? ""} ${item.model ?? ""}`.toLocaleLowerCase();
|
||||
return searchable.includes(searchTerm) && (status === "all" || item.online === (status === "online"));
|
||||
});
|
||||
}, [groups.data, query.data, search, status]);
|
||||
const error = query.error ?? groups.error;
|
||||
|
||||
return <>
|
||||
<PageHeader eyebrow={text("Uniforme inventaris", "Unified inventory")} title={text("Apparaten", "Devices")} description={text("Lokale OpenRGB-controllers, netwerklichten en toekomstige agents met één stabiele identiteit.", "Local OpenRGB controllers, network lights and future agents with one stable identity.")} actions={<Button onClick={() => rescan.mutate()} busy={rescan.isPending}><RefreshCw size={16} /> {text("Opnieuw scannen", "Rescan")}</Button>} />
|
||||
<div className="toolbar"><div className="search"><Search size={17} /><Input aria-label="Apparaten zoeken" placeholder="Zoek op naam, merk of model…" value={search} onChange={(event) => setSearch(event.target.value)} /></div><div className="segmented" aria-label="Statusfilter">{(["all", "online", "offline"] as const).map((item) => <button className={status === item ? "active" : ""} key={item} onClick={() => setStatus(item)}>{item === "all" ? "Alle" : item === "online" ? "Online" : "Offline"}</button>)}</div><div className="view-toggle"><button className={view === "grid" ? "active" : ""} aria-label="Rasterweergave" onClick={() => setView("grid")}><Grid2X2 size={17} /></button><button className={view === "list" ? "active" : ""} aria-label="Lijstweergave" onClick={() => setView("list")}><List size={17} /></button></div></div>
|
||||
{query.isLoading || groups.isLoading ? <LoadingGrid count={6} /> : error ? <ErrorPanel error={error} retry={() => void Promise.all([query.refetch(), groups.refetch()])} /> : !filtered.length ? <EmptyState icon={Cpu} title="Geen apparaten in deze selectie" description="Voer een nieuwe scan uit of pas de filters aan." action={<Button onClick={() => rescan.mutate()}>Scannen</Button>} /> : <div className={view === "grid" ? "grid grid--devices" : "device-list"}>{filtered.map((entry) => entry.kind === "group" ? <DeviceGroupCard group={entry.group} list={view === "list"} key={entry.group.id} /> : <DeviceCard device={entry.device} list={view === "list"} key={entry.device.id} />)}</div>}
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { CheckCircle2, Download, FlaskConical, RotateCcw, ShieldAlert, TerminalSquare } from "lucide-react";
|
||||
import { api } from "../api/client";
|
||||
import type { ComponentHealth, HealthStatus } from "../api/types";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { Badge, Card, CardHeader, PageHeader, StatusBadge } from "../components/ui";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
|
||||
interface Health { status: HealthStatus; components: Record<string, ComponentHealth>; checked_at: string }
|
||||
|
||||
export function DiagnosticsPage() {
|
||||
const { text } = useI18n();
|
||||
const queryClient = useQueryClient();
|
||||
const { notify } = useToast();
|
||||
const health = useQuery({ queryKey: ["health"], queryFn: () => api<Health>("/api/v1/health"), refetchInterval: 15_000 });
|
||||
const clearStop = useMutation({ mutationFn: () => api("/api/v1/commands/emergency-stop", { method: "DELETE" }), onSuccess: () => { notify("Noodstop vrijgegeven."); void queryClient.invalidateQueries(); } });
|
||||
return <>
|
||||
<PageHeader eyebrow={text("Herstel zonder giswerk", "Recovery without guesswork")} title={text("Diagnostiek", "Diagnostics")} description={text("Proces-, SDK-, database- en opslagstatus met geredigeerde export voor probleemonderzoek.", "Process, SDK, database and storage status with a redacted troubleshooting export.")} actions={<a className="button button--secondary" href="/api/v1/diagnostics/export"><Download size={16} /> {text("Diagnostiekspakket", "Diagnostics bundle")}</a>} />
|
||||
<div className="grid grid--health">{Object.entries(health.data?.components ?? {}).map(([name, component]) => <Card key={name} className="health-card"><div className="health-card__top"><span><TerminalSquare size={19} /></span><StatusBadge status={component.status} /></div><h2>{componentName(name)}</h2><p>{component.message}</p>{component.connected !== undefined ? <Badge tone={component.connected ? "success" : "warning"}>{component.connected ? "Verbonden" : "Niet verbonden"}</Badge> : null}</Card>)}</div>
|
||||
<div className="dashboard-grid"><Card><CardHeader title="Veilige herstelacties" description="Geen van deze acties wijzigt hostdrivers of firmware." /><div className="action-list"><button onClick={() => void health.refetch()}><span><RotateCcw size={18} /></span><div><strong>Health opnieuw controleren</strong><p>Ververs processen, SDK en opslagstatus.</p></div></button><button onClick={() => clearStop.mutate()}><span><ShieldAlert size={18} /></span><div><strong>Noodstop vrijgeven</strong><p>Sta nieuwe gevalideerde hardwareopdrachten toe.</p></div></button><a href="/api/v1/diagnostics/export"><span><FlaskConical size={18} /></span><div><strong>Geredigeerde bundle exporteren</strong><p>Zonder tokens, secrets of volledige environment dump.</p></div></a></div></Card>
|
||||
<Card><CardHeader title="Veiligheidsgrenzen" /><ul className="check-list"><li><CheckCircle2 size={16} /> SDK uitsluitend op 127.0.0.1:6742</li><li><CheckCircle2 size={16} /> Per-device locks en rate limiting</li><li><CheckCircle2 size={16} /> Identiteitscontrole vóór iedere write</li><li><CheckCircle2 size={16} /> Secrets uit logs en bundles verwijderd</li><li><CheckCircle2 size={16} /> Inventory zonder schrijfopdrachten</li></ul></Card></div>
|
||||
</>;
|
||||
}
|
||||
|
||||
function componentName(name: string): string { return ({ database: "SQLite-database", storage: "Persistente opslag", openrgb_process: "OpenRGB-proces", "openrgb-local": "OpenRGB SDK", "openrgb-mock": "Mockadapter" } as Record<string, string>)[name] ?? name; }
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Compass, Radar, RefreshCw, Usb } from "lucide-react";
|
||||
import { api } from "../api/client";
|
||||
import type { Page } from "../api/types";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { Badge, Button, Card, CardHeader, EmptyState, PageHeader } from "../components/ui";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { formatDate } from "../lib/utils";
|
||||
|
||||
interface DiscoveryRun { id: string; status: string; found_count: number; inaccessible_count: number; started_at: string; finished_at: string | null }
|
||||
|
||||
export function DiscoveryPage() {
|
||||
const { text } = useI18n();
|
||||
const queryClient = useQueryClient();
|
||||
const { notify } = useToast();
|
||||
const history = useQuery({ queryKey: ["discovery"], queryFn: () => api<Page<DiscoveryRun>>("/api/v1/discovery?limit=50") });
|
||||
const discover = useMutation({ mutationFn: () => api<{ found_count: number }>("/api/v1/discovery", { method: "POST" }), onSuccess: (result) => { notify(`${result.found_count} apparaten gesynchroniseerd.`); void queryClient.invalidateQueries({ queryKey: ["discovery"] }); void queryClient.invalidateQueries({ queryKey: ["devices"] }); }, onError: (error) => notify(error instanceof Error ? error.message : "Discovery mislukt.", "danger") });
|
||||
return <>
|
||||
<PageHeader eyebrow={text("Alleen-lezen-inventaris", "Read-only inventory")} title="Discovery" description={text("Zoek apparaten zonder impliciete schrijfopdrachten of agressieve SMBus-probes.", "Find devices without implicit writes or aggressive SMBus probes.")} actions={<Button onClick={() => discover.mutate()} busy={discover.isPending}><Radar size={16} /> {text("Discovery starten", "Start discovery")}</Button>} />
|
||||
<div className="grid grid--stats"><Card className="discovery-mode"><span><Usb size={21} /></span><div><strong>USB & hidraw</strong><p>Via expliciete device mapping</p></div></Card><Card className="discovery-mode"><span><Compass size={21} /></span><div><strong>Netwerk</strong><p>Broadcast/multicast indien ingeschakeld</p></div></Card><Card className="discovery-mode"><span><RefreshCw size={21} /></span><div><strong>SDK rescan</strong><p>OpenRGB protocol 5</p></div></Card></div>
|
||||
<Card><CardHeader title="Scanhistoriek" description="Resultaten en niet-toegankelijke doelen blijven controleerbaar." />{history.data?.items.length ? <div className="table-wrap"><table><thead><tr><th>Gestart</th><th>Status</th><th>Gevonden</th><th>Niet toegankelijk</th><th>Duur</th></tr></thead><tbody>{history.data.items.map((run) => <tr key={run.id}><td>{formatDate(run.started_at)}</td><td><Badge tone={run.status === "completed" ? "success" : run.status === "failed" ? "danger" : "warning"}>{run.status}</Badge></td><td>{run.found_count}</td><td>{run.inaccessible_count}</td><td>{run.finished_at ? `${Math.max(0, (Date.parse(run.finished_at) - Date.parse(run.started_at)) / 1000).toFixed(1)} s` : "—"}</td></tr>)}</tbody></table></div> : <EmptyState icon={Radar} title="Nog geen scans" description="Start discovery om de lokale OpenRGB-inventory te synchroniseren." />}</Card>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Globe2, Network, RadioTower, ShieldCheck } from "lucide-react";
|
||||
import { api } from "../api/client";
|
||||
import type { Device, Page } from "../api/types";
|
||||
import { Badge, Card, CardHeader, EmptyState, PageHeader } from "../components/ui";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
|
||||
const protocols = [
|
||||
["DDP", "UDP 4048", "Handmatig"], ["E1.31 / sACN", "UDP 5568", "Unicast / multicast"],
|
||||
["Philips Hue", "HTTP + entertainment", "Bridge discovery"], ["Philips WiZ", "UDP 38899", "LAN discovery"],
|
||||
["Nanoleaf", "HTTP + UDP", "Discovery / token"], ["LIFX", "UDP 56700", "LAN discovery"],
|
||||
["Govee", "UDP 4001–4003", "Multicast"], ["Kasa", "TCP 9999", "LAN"],
|
||||
["Yeelight", "TCP 55443", "LAN discovery"], ["Espurna", "HTTP/TCP", "Handmatig + API-key"],
|
||||
["Elgato", "HTTP 9123", "mDNS / LAN"],
|
||||
];
|
||||
|
||||
export function NetworkPage() {
|
||||
const { text } = useI18n();
|
||||
const devices = useQuery({ queryKey: ["devices"], queryFn: () => api<Page<Device>>("/api/v1/devices?limit=500") });
|
||||
const networkDevices = devices.data?.items.filter((device) => device.ip_address || device.source !== "openrgb") ?? [];
|
||||
return <>
|
||||
<PageHeader eyebrow={text("LAN-verlichting", "LAN lighting")} title={text("Netwerkapparaten", "Network devices")} description={text("OpenRGB-netwerkcontrollers en toekomstige native connectorapparaten met expliciet eigenaarschap.", "OpenRGB network controllers and future native connector devices with explicit ownership.")} />
|
||||
<div className="alert alert--info"><ShieldCheck size={20} /><div><strong>Dubbele aansturing wordt voorkomen</strong><p>Kies per fysiek apparaat één eigenaar: OpenRGB, native connector, Home Assistant of onbeheerd.</p></div></div>
|
||||
<section className="section-block"><div className="section-heading"><div><h2>Gevonden netwerkapparaten</h2><p>Apparaten met een IP-adres of externe connectorbron.</p></div></div>{networkDevices.length ? <div className="grid grid--cards">{networkDevices.map((device) => <Card key={device.id} className="network-device"><span><Globe2 size={20} /></span><div><h3>{device.alias || device.name}</h3><p>{device.ip_address || device.source}</p></div><Badge tone={device.online ? "success" : "neutral"}>{device.owner}</Badge></Card>)}</div> : <EmptyState icon={Network} title="Nog geen netwerkapparaten" description="Activeer discovery of voeg een ondersteund doel handmatig toe via OpenRGB." />}</section>
|
||||
<Card><CardHeader title="Ondersteund door OpenRGB 1.0rc3" description="Discovery kan host networking nodig hebben; de SDK blijft altijd loopback-only." /><div className="table-wrap"><table><thead><tr><th>Familie</th><th>Transport</th><th>Toevoegen</th></tr></thead><tbody>{protocols.map(([name, transport, discovery]) => <tr key={name}><td><RadioTower size={15} /> {name}</td><td>{transport}</td><td>{discovery}</td></tr>)}</tbody></table></div></Card>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { OverviewPage } from "./OverviewPage";
|
||||
import { jsonResponse, renderApp } from "../test/render";
|
||||
|
||||
describe("OverviewPage command flow", () => {
|
||||
it("sends a quick color through the versioned API", async () => {
|
||||
const stateBodies: string[] = [];
|
||||
const fetchMock = vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input instanceof Request ? input.url : typeof input === "string" ? input : input.toString();
|
||||
if (url.includes("/api/v1/system")) return Promise.resolve(jsonResponse({ name: "LumaOps", version: "0.1.0", openrgb_version: "1.0rc3", sdk_protocol: 5,
|
||||
environment: "test", mock_mode: true, health: { status: "healthy", checked_at: "2026-07-14T12:00:00Z", components: { "openrgb-mock": { status: "healthy", message: "ok", connected: true } } },
|
||||
devices: { total: 1, online: 1, offline: 0 }, active_scene: null, recent_commands: [], warnings: [], emergency_stop: false }));
|
||||
if (url.includes("/api/v1/devices") && (init?.method ?? "GET") === "GET") return Promise.resolve(jsonResponse({ items: [{ id: "device-1", capabilities: { rgb: true }, read_only: false, blocked: false }], total: 1, limit: 500, offset: 0 }));
|
||||
if (url.includes("/api/v1/scenes")) return Promise.resolve(jsonResponse({ items: [], total: 0, limit: 6, offset: 0 }));
|
||||
if (url.includes("/state")) { if (typeof init?.body === "string") stateBodies.push(init.body); return Promise.resolve(jsonResponse({ id: "command-1", status: "succeeded" })); }
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
renderApp(<OverviewPage />);
|
||||
await screen.findByText("Snelle bediening");
|
||||
await userEvent.click(screen.getByRole("button", { name: /Kleur toepassen/i }));
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining("/api/v1/devices/device-1/state"), expect.objectContaining({ method: "POST" })));
|
||||
expect(stateBodies[0]).toContain('"colors"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Activity, Cable, CirclePower, Cpu, Lightbulb, ShieldAlert, Sparkles, Wifi } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api, mutationId } from "../api/client";
|
||||
import type { Device, Page, Scene, SystemStatus } from "../api/types";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { Badge, Button, Card, CardHeader, ErrorPanel, LoadingGrid, PageHeader, Stat, StatusBadge } from "../components/ui";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { formatDate, hexToColor } from "../lib/utils";
|
||||
|
||||
export function OverviewPage() {
|
||||
const { text } = useI18n();
|
||||
const queryClient = useQueryClient();
|
||||
const { notify } = useToast();
|
||||
const [color, setColor] = useState("#5660ff");
|
||||
const [confirmEmergency, setConfirmEmergency] = useState(false);
|
||||
const system = useQuery({ queryKey: ["system"], queryFn: () => api<SystemStatus>("/api/v1/system") });
|
||||
const devices = useQuery({ queryKey: ["devices", "online"], queryFn: () => api<Page<Device>>("/api/v1/devices?online=true&limit=500") });
|
||||
const scenes = useQuery({ queryKey: ["scenes"], queryFn: () => api<Page<Scene>>("/api/v1/scenes?limit=6") });
|
||||
const quickColor = useMutation({
|
||||
mutationFn: async () => {
|
||||
const targets = devices.data?.items.filter((device) => device.capabilities.rgb && !device.read_only && !device.blocked) ?? [];
|
||||
return Promise.allSettled(targets.map((device) => api(`/api/v1/devices/${device.id}/state`, {
|
||||
method: "POST",
|
||||
headers: { "Idempotency-Key": mutationId() },
|
||||
body: JSON.stringify({ state: { power: true, colors: [hexToColor(color)] } }),
|
||||
})));
|
||||
},
|
||||
onSuccess: (results) => {
|
||||
const failed = results.filter((result) => result.status === "rejected").length;
|
||||
notify(failed ? `${results.length - failed} apparaten bijgewerkt; ${failed} mislukt.` : "Kleur toegepast op alle geschikte apparaten.", failed ? "danger" : "success");
|
||||
void queryClient.invalidateQueries({ queryKey: ["system"] });
|
||||
},
|
||||
});
|
||||
const allOff = useMutation({
|
||||
mutationFn: (emergency: boolean) => api(`/api/v1/commands/${emergency ? "emergency-stop" : "all-off"}`, { method: "POST" }),
|
||||
onSuccess: (_, emergency) => { notify(emergency ? "Noodstop geactiveerd." : "Alles-uitopdracht voltooid."); void queryClient.invalidateQueries(); },
|
||||
});
|
||||
const applyScene = useMutation({
|
||||
mutationFn: (id: string) => api(`/api/v1/scenes/${id}/apply`, { method: "POST", body: JSON.stringify({ rollback_on_failure: true }) }),
|
||||
onSuccess: () => { notify("Scène toegepast."); void queryClient.invalidateQueries(); },
|
||||
onError: (error) => notify(error instanceof Error ? error.message : "Scène mislukt.", "danger"),
|
||||
});
|
||||
|
||||
if (system.isLoading) return <><PageHeader title={text("Overzicht", "Overview")} /><LoadingGrid /></>;
|
||||
if (system.error || !system.data) return <ErrorPanel error={system.error} retry={() => void system.refetch()} />;
|
||||
const status = system.data;
|
||||
const openrgb = status.health.components["openrgb-local"] ?? status.health.components["openrgb-mock"];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader eyebrow={text("Lokaal controlecentrum", "Local control center")} title={text("Goedenavond, Jens", "Good evening, Jens")} description={text("Je verlichting en OpenRGB-hardware in één rustig overzicht.", "Your lighting and OpenRGB hardware in one calm overview.")} actions={<><Button variant="secondary" onClick={() => allOff.mutate(false)} busy={allOff.isPending}><CirclePower size={16} /> {text("Alles uit", "All off")}</Button><Button variant="danger" onClick={() => setConfirmEmergency(true)}><ShieldAlert size={16} /> {text("Noodstop", "Emergency stop")}</Button></>} />
|
||||
{status.emergency_stop ? <div className="alert alert--danger"><ShieldAlert size={20} /><div><strong>Globale noodstop actief</strong><p>Nieuwe hardwareopdrachten worden geblokkeerd totdat je de noodstop in Diagnostiek vrijgeeft.</p></div></div> : null}
|
||||
<section className="grid grid--stats" aria-label="Systeemstatistieken">
|
||||
<Stat label="Systeemstatus" value={<StatusBadge status={status.health.status} />} detail={`Gecontroleerd ${formatDate(status.health.checked_at)}`} icon={Activity} />
|
||||
<Stat label="Apparaten online" value={`${status.devices.online} / ${status.devices.total}`} detail={`${status.devices.offline} offline`} icon={Cpu} />
|
||||
<Stat label="OpenRGB Core" value={openrgb ? <StatusBadge status={openrgb.status} /> : "—"} detail={`1.0rc3 · SDK ${status.sdk_protocol}`} icon={Lightbulb} />
|
||||
<Stat label="Connectoren" value={Object.keys(status.health.components).filter((key) => key.startsWith("openrgb")).length} detail="Actieve beheerkanalen" icon={Cable} />
|
||||
</section>
|
||||
<section className="dashboard-grid">
|
||||
<Card className="quick-control">
|
||||
<CardHeader title="Snelle bediening" description="Veilige statische kleur op alle geschikte apparaten." />
|
||||
<div className="color-control">
|
||||
<input aria-label="Snelle kleur" type="color" value={color} onChange={(event) => setColor(event.target.value)} />
|
||||
<div><strong>{color.toUpperCase()}</strong><span>{devices.data?.items.length ?? 0} online doelen</span></div>
|
||||
<Button onClick={() => quickColor.mutate()} busy={quickColor.isPending}><Sparkles size={16} /> Kleur toepassen</Button>
|
||||
</div>
|
||||
<div className="color-swatches" aria-label="Kleursuggesties">
|
||||
{["#5660ff", "#00c2a8", "#ff7a59", "#f5c451", "#f7f7ff"].map((item) => <button key={item} aria-label={`Kies ${item}`} className={color === item ? "selected" : ""} style={{ background: item }} onClick={() => setColor(item)} />)}
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader title="Actieve scène" description="De laatst succesvol toegepaste LumaOps-scène." />
|
||||
{status.active_scene ? <div className="active-scene"><span className="scene-orb" /><div><strong>{status.active_scene.name}</strong><span>{formatDate(status.active_scene.last_applied_at)}</span></div><Badge tone="accent">Actief</Badge></div> : <p className="muted">Nog geen scène toegepast.</p>}
|
||||
</Card>
|
||||
</section>
|
||||
<section className="section-block">
|
||||
<div className="section-heading"><div><h2>Laatst gebruikte scènes</h2><p>Start een sfeer zonder de pagina te verlaten.</p></div><Link className="text-link" to="/scenes">Alle scènes</Link></div>
|
||||
<div className="grid grid--cards">
|
||||
{scenes.data?.items.map((scene, index) => <Card key={scene.id} className="scene-card"><div className={`scene-gradient scene-gradient--${index % 4}`}><Sparkles size={20} /></div><div className="scene-card__body"><div><h3>{scene.name}</h3><p>{scene.item_count ?? 0} onderdelen · v{scene.version}</p></div><Button variant="secondary" onClick={() => applyScene.mutate(scene.id)} busy={applyScene.isPending}>Start</Button></div></Card>)}
|
||||
{!scenes.isLoading && !scenes.data?.items.length ? <Card><p className="muted">Leg je huidige toestand vast als eerste scène.</p></Card> : null}
|
||||
</div>
|
||||
</section>
|
||||
<section className="dashboard-grid">
|
||||
<Card><CardHeader title="Recente opdrachten" description="Laatste wijzigingen via LumaOps." action={<Link to="/activity" className="text-link">Alles bekijken</Link>} />
|
||||
<div className="timeline">{status.recent_commands.slice(0, 5).map((command) => <div className="timeline__item" key={command.id}><span className={`timeline__dot timeline__dot--${command.status}`} /><div><strong>{command.action === "set_state" ? "Apparaattoestand gewijzigd" : command.action}</strong><span>{formatDate(command.created_at)}</span></div><Badge tone={command.status === "succeeded" ? "success" : command.status === "failed" ? "danger" : "neutral"}>{command.status}</Badge></div>)}{!status.recent_commands.length ? <p className="muted">Nog geen opdrachten uitgevoerd.</p> : null}</div>
|
||||
</Card>
|
||||
<Card><CardHeader title="Waarschuwingen" description="Aandachtspunten met concrete herstelacties." action={<Wifi size={18} />} />
|
||||
{status.warnings.length ? status.warnings.map((warning) => <div className="warning-row" key={warning.id}><span /><div><strong>{warning.title}</strong><p>{warning.message}</p></div></div>) : <div className="all-clear"><span><Lightbulb size={20} /></span><div><strong>Alles rustig</strong><p>Er zijn geen open waarschuwingen.</p></div></div>}
|
||||
</Card>
|
||||
</section>
|
||||
<ConfirmDialog open={confirmEmergency} title="Globale noodstop activeren?" description="LumaOps probeert alle geschikte apparaten één keer uit te schakelen en blokkeert daarna nieuwe hardwareopdrachten." confirmLabel="Noodstop activeren" danger onClose={() => setConfirmEmergency(false)} onConfirm={() => { setConfirmEmergency(false); allOff.mutate(true); }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Camera, Copy, Download, Eye, Pencil, Play, Plus, Sparkles, Trash2, Upload } from "lucide-react";
|
||||
import { useRef, useState, type ChangeEvent, type FormEvent } from "react";
|
||||
import { api } from "../api/client";
|
||||
import type { Page, Scene, SceneApplyResult } from "../api/types";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { SceneEditor } from "../components/SceneEditor";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { Badge, Button, Card, EmptyState, ErrorPanel, Field, Input, LoadingGrid, PageHeader } from "../components/ui";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { formatDate } from "../lib/utils";
|
||||
|
||||
interface ScenePreview {
|
||||
scene: Scene;
|
||||
deviceCount: number;
|
||||
}
|
||||
|
||||
export function ScenesPage() {
|
||||
const { text } = useI18n();
|
||||
const queryClient = useQueryClient();
|
||||
const { notify } = useToast();
|
||||
const [capture, setCapture] = useState(false);
|
||||
const [createEmpty, setCreateEmpty] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [deleteScene, setDeleteScene] = useState<Scene | null>(null);
|
||||
const [preview, setPreview] = useState<ScenePreview | null>(null);
|
||||
const importInput = useRef<HTMLInputElement>(null);
|
||||
const query = useQuery({ queryKey: ["scenes"], queryFn: () => api<Page<Scene>>("/api/v1/scenes?limit=200") });
|
||||
const refresh = () => void queryClient.invalidateQueries({ queryKey: ["scenes"] });
|
||||
const captureMutation = useMutation({ mutationFn: (name: string) => api<Scene>("/api/v1/scenes/capture", { method: "POST", body: JSON.stringify({ name }) }), onSuccess: () => { notify(text("Huidige toestand als scène vastgelegd.", "Current state captured as a scene.")); setCapture(false); refresh(); }, onError: (error) => notify(error instanceof Error ? error.message : text("Vastleggen mislukt.", "Capture failed."), "danger") });
|
||||
const createMutation = useMutation({ mutationFn: (name: string) => api<Scene>("/api/v1/scenes", { method: "POST", body: JSON.stringify({ name, items: [] }) }), onSuccess: (scene) => { notify(text("Lege scène gemaakt.", "Empty scene created.")); setCreateEmpty(false); setEditingId(scene.id); refresh(); }, onError: (error) => notify(error instanceof Error ? error.message : text("Aanmaken mislukt.", "Create failed."), "danger") });
|
||||
const duplicate = useMutation({ mutationFn: (id: string) => api<Scene>(`/api/v1/scenes/${id}/duplicate`, { method: "POST", body: JSON.stringify({}) }), onSuccess: () => { notify(text("Scène gedupliceerd.", "Scene duplicated.")); refresh(); }, onError: (error) => notify(error instanceof Error ? error.message : text("Dupliceren mislukt.", "Duplicate failed."), "danger") });
|
||||
const importScene = useMutation({ mutationFn: (payload: unknown) => api<Scene>("/api/v1/scenes/import", { method: "POST", body: JSON.stringify(payload) }), onSuccess: () => { notify(text("Scène geïmporteerd.", "Scene imported.")); refresh(); }, onError: (error) => notify(error instanceof Error ? error.message : text("Importeren mislukt.", "Import failed."), "danger") });
|
||||
const apply = useMutation({
|
||||
mutationFn: (id: string) => api<SceneApplyResult>(`/api/v1/scenes/${id}/apply`, { method: "POST", body: JSON.stringify({ rollback_on_failure: true }) }),
|
||||
onSuccess: (result) => {
|
||||
const summary = text(`${result.applied.length} toegepast, ${result.skipped.length} overgeslagen, ${result.failed.length} mislukt.`, `${result.applied.length} applied, ${result.skipped.length} skipped, ${result.failed.length} failed.`);
|
||||
notify(summary, result.status === "succeeded" ? "success" : "danger");
|
||||
setPreview(null);
|
||||
void queryClient.invalidateQueries();
|
||||
},
|
||||
onError: (error) => notify(error instanceof Error ? error.message : text("Scène mislukt.", "Scene failed."), "danger"),
|
||||
});
|
||||
const inspect = useMutation({
|
||||
mutationFn: async (scene: Scene) => ({ scene, result: await api<{ device_count: number }>(`/api/v1/scenes/${scene.id}/preview`) }),
|
||||
onSuccess: ({ scene, result }) => setPreview({ scene, deviceCount: result.device_count }),
|
||||
onError: (error) => notify(error instanceof Error ? error.message : text("Preview mislukt.", "Preview failed."), "danger"),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: string) => api(`/api/v1/scenes/${id}`, { method: "DELETE" }),
|
||||
onSuccess: () => { notify(text("Scène verwijderd.", "Scene deleted.")); setDeleteScene(null); refresh(); },
|
||||
onError: (error) => notify(error instanceof Error ? error.message : text("Verwijderen mislukt.", "Delete failed."), "danger"),
|
||||
});
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); const name = new FormData(event.currentTarget).get("name"); if (typeof name === "string") captureMutation.mutate(name); };
|
||||
const submitEmpty = (event: FormEvent<HTMLFormElement>) => { event.preventDefault(); const name = new FormData(event.currentTarget).get("name"); if (typeof name === "string") createMutation.mutate(name); };
|
||||
const onImport = async (event: ChangeEvent<HTMLInputElement>) => { const file = event.target.files?.[0]; event.target.value = ""; if (!file) return; try { importScene.mutate(JSON.parse(await file.text()) as unknown); } catch { notify(text("Dit bestand bevat geen geldige JSON.", "This file does not contain valid JSON."), "danger"); } };
|
||||
|
||||
return <>
|
||||
<PageHeader eyebrow={text("LumaOps-scènelaag", "LumaOps scene layer")} title={text("Scènes", "Scenes")} description={text("Leg toestanden over apparaten en groepen vast met preview, rapportage en best-effort rollback.", "Capture state across devices and groups with preview, reporting, and best-effort rollback.")} actions={<><input ref={importInput} className="visually-hidden" type="file" accept="application/json,.json" onChange={(event) => void onImport(event)} aria-label={text("Scènebestand importeren", "Import scene file")} /><Button variant="ghost" onClick={() => importInput.current?.click()} busy={importScene.isPending}><Upload size={16} /> {text("Importeren", "Import")}</Button><Button variant="secondary" onClick={() => setCreateEmpty(true)}><Plus size={16} /> {text("Lege scène", "Empty scene")}</Button><Button onClick={() => setCapture(true)}><Camera size={16} /> {text("Huidige toestand vastleggen", "Capture current state")}</Button></>} />
|
||||
{editingId ? <SceneEditor sceneId={editingId} onClose={() => setEditingId(null)} /> : null}
|
||||
{capture ? <Card className="inline-form"><form onSubmit={submit}><Field label={text("Naam voor de scène", "Scene name")}><Input required name="name" autoFocus placeholder={text("Bijvoorbeeld Avondrust", "For example Evening calm")} /></Field><div className="form-actions"><Button type="button" variant="ghost" onClick={() => setCapture(false)}>{text("Annuleren", "Cancel")}</Button><Button type="submit" busy={captureMutation.isPending}>{text("Vastleggen", "Capture")}</Button></div></form></Card> : null}
|
||||
{createEmpty ? <Card className="inline-form"><form onSubmit={submitEmpty}><Field label={text("Naam voor de lege scène", "Empty scene name")}><Input required name="name" autoFocus placeholder={text("Bijvoorbeeld Filmavond", "For example Movie night")} /></Field><div className="form-actions"><Button type="button" variant="ghost" onClick={() => setCreateEmpty(false)}>{text("Annuleren", "Cancel")}</Button><Button type="submit" busy={createMutation.isPending}>{text("Maken", "Create")}</Button></div></form></Card> : null}
|
||||
{query.isLoading ? <LoadingGrid /> : query.error ? <ErrorPanel error={query.error} retry={() => void query.refetch()} /> : !query.data?.items.length ? <EmptyState icon={Sparkles} title={text("Nog geen scènes", "No scenes yet")} description={text("Leg de huidige toestand van je online apparaten vast als eerste scène.", "Capture the current state of your online devices as your first scene.")} action={<Button onClick={() => setCapture(true)}>{text("Toestand vastleggen", "Capture state")}</Button>} /> : <div className="grid grid--scenes">{query.data.items.map((scene, index) => <Card key={scene.id} className="scene-tile"><div className={`scene-tile__visual scene-gradient--${index % 4}`}><Sparkles size={28} /><Badge tone="accent">v{scene.version}</Badge></div><div className="scene-tile__content"><div><h2>{scene.name}</h2><p>{scene.description || text(`${scene.item_count ?? 0} doeltoestanden`, `${scene.item_count ?? 0} target states`)}</p></div><dl><div><dt>{text("Laatst toegepast", "Last applied")}</dt><dd>{formatDate(scene.last_applied_at)}</dd></div><div><dt>{text("Rollback", "Rollback")}</dt><dd>{text("Ingeschakeld", "Enabled")}</dd></div></dl><div className="scene-tile__actions"><Button onClick={() => apply.mutate(scene.id)} busy={apply.isPending}><Play size={15} /> {text("Start", "Run")}</Button><Button variant="ghost" title={text("Preview", "Preview")} aria-label={text(`Preview van ${scene.name}`, `Preview ${scene.name}`)} busy={inspect.isPending} onClick={() => inspect.mutate(scene)}><Eye size={16} /></Button><Button variant="ghost" title={text("Bewerken", "Edit")} aria-label={text(`${scene.name} bewerken`, `Edit ${scene.name}`)} onClick={() => setEditingId(scene.id)}><Pencil size={16} /></Button><Button variant="ghost" title={text("Dupliceren", "Duplicate")} aria-label={text(`${scene.name} dupliceren`, `Duplicate ${scene.name}`)} onClick={() => duplicate.mutate(scene.id)} busy={duplicate.isPending}><Copy size={16} /></Button><a className="button button--ghost" href={`/api/v1/scenes/${scene.id}/export`} title={text("Exporteren", "Export")} aria-label={text("Scène exporteren", "Export scene")}><Download size={16} /></a><Button variant="ghost" title={text("Verwijderen", "Delete")} aria-label={text(`${scene.name} verwijderen`, `Delete ${scene.name}`)} onClick={() => setDeleteScene(scene)}><Trash2 size={16} /></Button></div></div></Card>)}</div>}
|
||||
<ConfirmDialog open={Boolean(deleteScene)} title={text("Scène verwijderen?", "Delete scene?")} description={text(`“${deleteScene?.name ?? ""}” en alle doeltoestanden worden verwijderd.`, `“${deleteScene?.name ?? ""}” and all target states will be deleted.`)} confirmLabel={text("Verwijderen", "Delete")} danger busy={remove.isPending} onClose={() => setDeleteScene(null)} onConfirm={() => deleteScene && remove.mutate(deleteScene.id)} />
|
||||
<ConfirmDialog open={Boolean(preview)} title={text("Scènepreview", "Scene preview")} description={text(`“${preview?.scene.name ?? ""}” stuurt opdrachten naar ${preview?.deviceCount ?? 0} unieke apparaten. Bij een vereiste fout worden reeds toegepaste opdrachten teruggedraaid.`, `“${preview?.scene.name ?? ""}” sends commands to ${preview?.deviceCount ?? 0} unique devices. On a required failure, applied commands are rolled back.`)} confirmLabel={text("Nu toepassen", "Apply now")} busy={apply.isPending} onClose={() => setPreview(null)} onConfirm={() => preview && apply.mutate(preview.scene.id)} />
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Languages, LockKeyhole, Moon, Network, Save, Sun, SunMoon } from "lucide-react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { api } from "../api/client";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { Button, Card, CardHeader, Field, PageHeader, Select } from "../components/ui";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { useTheme, type Theme } from "../lib/theme";
|
||||
|
||||
export function SettingsPage() {
|
||||
const { language, setLanguage, text } = useI18n();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { notify } = useToast();
|
||||
const save = useMutation({ mutationFn: ({ key, value }: { key: string; value: unknown }) => api(`/api/v1/settings/${key}`, { method: "PUT", body: JSON.stringify({ value }) }), onSuccess: () => notify("Instelling opgeslagen.") });
|
||||
return <>
|
||||
<PageHeader eyebrow={text("Persoonlijke voorkeuren", "Personal preferences")} title={text("Instellingen", "Settings")} description={text("Interfacevoorkeuren worden lokaal toegepast; deploymentkritische waarden blijven in de containeromgeving.", "Interface preferences apply locally; deployment-critical values remain in the container environment.")} actions={<Button onClick={() => { save.mutate({ key: "language", value: language }); save.mutate({ key: "theme", value: theme }); }} busy={save.isPending}><Save size={16} /> {text("Opslaan", "Save")}</Button>} />
|
||||
<div className="settings-grid"><Card><CardHeader title="Weergave" description="Taal en thema worden ook in deze browser onthouden." /><div className="form-stack"><Field label="Taal"><div className="setting-select"><Languages size={18} /><Select value={language} onChange={(event) => setLanguage(event.target.value === "en" ? "en" : "nl")}><option value="nl">Nederlands</option><option value="en">English</option></Select></div></Field><Field label="Thema"><div className="theme-options">{(["light", "dark", "system"] as Theme[]).map((item) => <button className={theme === item ? "active" : ""} onClick={() => setTheme(item)} key={item}>{item === "light" ? <Sun size={18} /> : item === "dark" ? <Moon size={18} /> : <SunMoon size={18} />}<span>{item === "light" ? "Licht" : item === "dark" ? "Donker" : "Systeem"}</span></button>)}</div></Field></div></Card>
|
||||
<Card><CardHeader title="Netwerk & beveiliging" description="Alleen informatief; wijzig deze invarianten via Unraid-containerinstellingen." /><dl className="properties"><div><dt><Network size={15} /> Webinterface</dt><dd>0.0.0.0 · configureerbare APP_PORT</dd></div><div><dt><LockKeyhole size={15} /> OpenRGB SDK</dt><dd>127.0.0.1:6742 · niet gepubliceerd</dd></div><div><dt><LockKeyhole size={15} /> Externe toegang</dt><dd>Standaard uit</dd></div></dl><p className="mini-warning">Forwarded headers worden alleen van expliciet vertrouwde proxy-adressen geaccepteerd.</p></Card></div>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { jsonResponse, renderApp } from "../test/render";
|
||||
import { SetupPage } from "./SetupPage";
|
||||
|
||||
describe("SetupPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(jsonResponse({
|
||||
completed: false,
|
||||
current_step: "welcome",
|
||||
report: {},
|
||||
})));
|
||||
});
|
||||
|
||||
it("handles a fresh empty persisted report before the first inspection", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderApp(<SetupPage />, "/setup");
|
||||
await user.click(await screen.findByRole("button", { name: "Configuratie starten" }));
|
||||
expect(await screen.findByRole("heading", { name: "Opslagrechten" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Voer de systeemcontrole uit om mountrechten te testen.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, ChevronRight, CircleCheck, Cpu, Database, HardDrive, Lightbulb, Network, ScanSearch, Server, ShieldCheck, Usb, Wrench } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api } from "../api/client";
|
||||
import type { ComponentHealth, SetupState } from "../api/types";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { Badge, Button, EmptyState, ErrorPanel, StatusBadge } from "../components/ui";
|
||||
|
||||
interface PathStatus { path: string; exists: boolean; readable: boolean; writable: boolean }
|
||||
interface NodeStatus { path: string; readable: boolean; writable: boolean }
|
||||
interface Recovery { area: string; message: string }
|
||||
interface SetupReport {
|
||||
storage: PathStatus[];
|
||||
openrgb: ComponentHealth | null;
|
||||
usb_devices: NodeStatus[];
|
||||
i2c_devices: NodeStatus[];
|
||||
serial_devices: NodeStatus[];
|
||||
recovery: Recovery[];
|
||||
checked_at: string;
|
||||
openrgb_inventory?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const steps = [
|
||||
["Welkom", Lightbulb], ["Opslagrechten", HardDrive], ["OpenRGB-proces", Server], ["SDK-verbinding", Network],
|
||||
["USB-inventory", Usb], ["I²C-inventory", Cpu], ["OpenRGB-detectie", ScanSearch], ["Toegankelijkheid", ShieldCheck],
|
||||
["Netwerkdiscovery", Network], ["Appdata & back-up", Database], ["Systeemrapport", CircleCheck],
|
||||
] as const;
|
||||
|
||||
export function SetupPage() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const { notify } = useToast();
|
||||
const [active, setActive] = useState(0);
|
||||
const [networkDiscovery, setNetworkDiscovery] = useState(false);
|
||||
const [appdata, setAppdata] = useState(false);
|
||||
const [backup, setBackup] = useState(false);
|
||||
const state = useQuery({ queryKey: ["setup"], queryFn: () => api<SetupState>("/api/v1/setup") });
|
||||
const inspect = useMutation({ mutationFn: () => api<SetupReport>(`/api/v1/setup/inspect?include_network=${networkDiscovery}`, { method: "POST" }), onSuccess: () => { notify("Systeemcontrole afgerond."); setActive(10); }, onError: (error) => notify(error instanceof Error ? error.message : "Controle mislukt.", "danger") });
|
||||
const complete = useMutation({ mutationFn: () => api<SetupState>("/api/v1/setup/complete", { method: "POST", body: JSON.stringify({ appdata_confirmed: appdata, backup_location_confirmed: backup }) }), onSuccess: (result) => { queryClient.setQueryData(["setup"], result); notify("LumaOps is klaar voor gebruik."); void navigate("/", { replace: true }); }, onError: (error) => notify(error instanceof Error ? error.message : "Setup afronden mislukt.", "danger") });
|
||||
const report = normalizeSetupReport(inspect.data ?? state.data?.report);
|
||||
if (state.error) return <ErrorPanel error={state.error} retry={() => void state.refetch()} />;
|
||||
return <div className="setup-shell">
|
||||
<aside className="setup-sidebar"><div className="brand"><span className="brand__mark"><Lightbulb size={20} /></span><div className="brand__text"><strong>LumaOps</strong><span>Eerste configuratie</span></div></div><ol>{steps.map(([label, Icon], index) => <li key={label} className={active === index ? "active" : index < active ? "complete" : ""}><button onClick={() => setActive(index)}><span>{index < active ? <Check size={15} /> : <Icon size={16} />}</span><div><small>Stap {index + 1}</small><strong>{label}</strong></div></button></li>)}</ol></aside>
|
||||
<main className="setup-main">
|
||||
<div className="setup-progress"><span>Stap {active + 1} van {steps.length}</span><div><i style={{ width: `${((active + 1) / steps.length) * 100}%` }} /></div></div>
|
||||
{active === 0 ? <Welcome onNext={() => setActive(1)} /> : null}
|
||||
{active >= 1 && active <= 8 ? <CheckStep index={active} report={report} networkDiscovery={networkDiscovery} setNetworkDiscovery={setNetworkDiscovery} busy={inspect.isPending} onInspect={() => inspect.mutate()} onPrevious={() => setActive((value) => Math.max(0, value - 1))} onNext={() => setActive((value) => Math.min(10, value + 1))} /> : null}
|
||||
{active === 9 ? <StorageConfirm appdata={appdata} backup={backup} onAppdata={setAppdata} onBackup={setBackup} onPrevious={() => setActive(8)} onNext={() => setActive(10)} /> : null}
|
||||
{active === 10 ? <Report report={report} appdata={appdata} backup={backup} busy={complete.isPending} onInspect={() => inspect.mutate()} onComplete={() => complete.mutate()} /> : null}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
function normalizeSetupReport(value: unknown): SetupReport | undefined {
|
||||
if (!value || typeof value !== "object") return undefined;
|
||||
const candidate = value as Partial<SetupReport>;
|
||||
return {
|
||||
storage: Array.isArray(candidate.storage) ? candidate.storage : [],
|
||||
openrgb: candidate.openrgb ?? null,
|
||||
usb_devices: Array.isArray(candidate.usb_devices) ? candidate.usb_devices : [],
|
||||
i2c_devices: Array.isArray(candidate.i2c_devices) ? candidate.i2c_devices : [],
|
||||
serial_devices: Array.isArray(candidate.serial_devices) ? candidate.serial_devices : [],
|
||||
recovery: Array.isArray(candidate.recovery) ? candidate.recovery : [],
|
||||
checked_at: typeof candidate.checked_at === "string" ? candidate.checked_at : "",
|
||||
openrgb_inventory: candidate.openrgb_inventory,
|
||||
};
|
||||
}
|
||||
|
||||
function Welcome({ onNext }: { onNext: () => void }) { return <div className="setup-panel setup-welcome"><span className="setup-hero-icon"><Lightbulb size={34} /></span><Badge tone="accent">OpenRGB 1.0rc3 · SDK 5</Badge><h1>Welkom bij LumaOps</h1><p>We controleren eerst veilig je container, opslag en OpenRGB-verbinding. De wizard voert geen wijzigende hardwareopdrachten uit.</p><div className="setup-promises"><div><ShieldCheck size={19} /><span><strong>Loopback-only SDK</strong>Poort 6742 blijft intern.</span></div><div><Wrench size={19} /><span><strong>Concrete herstelstappen</strong>Geen vage foutmeldingen.</span></div><div><Database size={19} /><span><strong>Persistente appdata</strong>Back-up vóór risicovolle wijzigingen.</span></div></div><Button onClick={onNext}>Configuratie starten <ChevronRight size={16} /></Button></div>; }
|
||||
|
||||
function CheckStep({ index, report, networkDiscovery, setNetworkDiscovery, busy, onInspect, onPrevious, onNext }: { index: number; report?: SetupReport; networkDiscovery: boolean; setNetworkDiscovery: (value: boolean) => void; busy: boolean; onInspect: () => void; onPrevious: () => void; onNext: () => void }) {
|
||||
const [title, Icon] = steps[index]!;
|
||||
const contents = index === 1 ? <StatusList items={report?.storage.map((item) => ({ name: item.path, ok: item.writable, detail: item.writable ? "Schrijfbaar" : "Niet schrijfbaar" })) ?? []} empty="Voer de systeemcontrole uit om mountrechten te testen." /> : index === 2 || index === 3 ? report?.openrgb ? <div className="setup-health"><StatusBadge status={report.openrgb.status} /><strong>{report.openrgb.message}</strong><p>De SDK moet exact protocol 5 rapporteren en op loopback bereikbaar zijn.</p></div> : <EmptyState icon={Server} title="Nog niet gecontroleerd" description="Start de veilige systeemcontrole." /> : index === 4 ? <NodeList title="USB-nodes" nodes={report?.usb_devices ?? []} /> : index === 5 ? <NodeList title="I²C-nodes" nodes={report?.i2c_devices ?? []} /> : index === 6 ? <StatusList items={report?.openrgb_inventory ? Object.entries(report.openrgb_inventory).map(([name, value]) => ({ name, ok: (value as { status?: string }).status === "completed", detail: JSON.stringify(value) })) : []} empty="Na de SDK-test wordt de OpenRGB-inventory read-only gesynchroniseerd." /> : index === 7 ? <div>{report?.recovery.length ? report.recovery.map((item) => <div className="recovery" key={`${item.area}-${item.message}`}><Wrench size={17} /><div><strong>{item.area}</strong><p>{item.message}</p></div></div>) : <EmptyState icon={ShieldCheck} title="Geen herstelacties nodig" description="Alle uitgevoerde controles zijn geslaagd." />}</div> : <label className="choice-card"><input type="checkbox" checked={networkDiscovery} onChange={(event) => setNetworkDiscovery(event.target.checked)} /><span><Network size={20} /></span><div><strong>Optionele netwerkdiscovery</strong><p>Broadcast, multicast en mDNS kunnen host networking vereisen. Bridge blijft de veilige standaard.</p></div></label>;
|
||||
return <div className="setup-panel"><span className="eyebrow">Stap {index + 1}</span><div className="setup-title"><span><Icon size={24} /></span><div><h1>{title}</h1><p>Deze controle is read-only en verandert geen apparaattoestand.</p></div></div><div className="setup-content">{contents}</div><div className="setup-actions"><Button variant="ghost" onClick={onPrevious}>Vorige</Button><Button variant="secondary" onClick={onInspect} busy={busy}><ScanSearch size={16} /> Systeemcontrole</Button><Button onClick={onNext}>Volgende <ChevronRight size={16} /></Button></div></div>;
|
||||
}
|
||||
|
||||
function StorageConfirm({ appdata, backup, onAppdata, onBackup, onPrevious, onNext }: { appdata: boolean; backup: boolean; onAppdata: (value: boolean) => void; onBackup: (value: boolean) => void; onPrevious: () => void; onNext: () => void }) { return <div className="setup-panel"><span className="eyebrow">Stap 10</span><div className="setup-title"><span><Database size={24} /></span><div><h1>Appdata & back-up</h1><p>Bevestig dat de persistentie buiten de container is gekoppeld.</p></div></div><div className="setup-content"><label className="choice-card"><input type="checkbox" checked={appdata} onChange={(event) => onAppdata(event.target.checked)} /><span><HardDrive size={20} /></span><div><strong>Appdata is persistent</strong><p>/config/openrgb, /config/lumaops, /data en /logs zijn aan Unraid gekoppeld.</p></div></label><label className="choice-card"><input type="checkbox" checked={backup} onChange={(event) => onBackup(event.target.checked)} /><span><Database size={20} /></span><div><strong>Back-uplocatie is bevestigd</strong><p>Database, OpenRGB-config en secret.key worden samen extern geback-upt.</p></div></label></div><div className="setup-actions"><Button variant="ghost" onClick={onPrevious}>Vorige</Button><Button onClick={onNext} disabled={!appdata || !backup}>Rapport bekijken <ChevronRight size={16} /></Button></div></div>; }
|
||||
|
||||
function Report({ report, appdata, backup, busy, onInspect, onComplete }: { report?: SetupReport; appdata: boolean; backup: boolean; busy: boolean; onInspect: () => void; onComplete: () => void }) { const ready = Boolean(report?.openrgb?.connected && report.storage.every((item) => item.writable) && appdata && backup); return <div className="setup-panel"><span className="setup-hero-icon"><CircleCheck size={34} /></span><Badge tone={ready ? "success" : "warning"}>{ready ? "Klaar voor gebruik" : "Aandacht vereist"}</Badge><h1>Systeemstatusrapport</h1><p>{ready ? "De container, opslag en SDK-verbinding voldoen aan de veilige MVP-voorwaarden." : "LumaOps kan starten, maar los onderstaande herstelpunten op voor volledige hardwarebediening."}</p><div className="report-summary"><div><strong>{report?.storage.filter((item) => item.writable).length ?? 0}/{report?.storage.length ?? 4}</strong><span>opslagpaden</span></div><div><strong>{report?.usb_devices.length ?? 0}</strong><span>USB-nodes</span></div><div><strong>{report?.i2c_devices.length ?? 0}</strong><span>I²C-nodes</span></div><div><strong>{report?.openrgb?.connected ? "SDK 5" : "Offline"}</strong><span>OpenRGB</span></div></div>{report?.recovery.map((item) => <div className="recovery" key={`${item.area}-${item.message}`}><Wrench size={17} /><div><strong>{item.area}</strong><p>{item.message}</p></div></div>)}<div className="setup-actions"><Button variant="secondary" onClick={onInspect}>Opnieuw controleren</Button><Button onClick={onComplete} busy={busy} disabled={!appdata || !backup}>LumaOps openen <ChevronRight size={16} /></Button></div></div>; }
|
||||
|
||||
function StatusList({ items, empty }: { items: Array<{ name: string; ok: boolean; detail: string }>; empty: string }) { if (!items.length) return <p className="muted">{empty}</p>; return <div className="status-list">{items.map((item) => <div key={item.name}><span className={item.ok ? "ok" : "bad"}>{item.ok ? <Check size={14} /> : "!"}</span><div><strong>{item.name}</strong><p>{item.detail}</p></div></div>)}</div>; }
|
||||
function NodeList({ title, nodes }: { title: string; nodes: NodeStatus[] }) { return nodes.length ? <div><h3>{title}</h3><StatusList items={nodes.map((node) => ({ name: node.path, ok: node.readable && node.writable, detail: node.writable ? "Lees- en schrijfbaar" : node.readable ? "Alleen leesbaar" : "Niet toegankelijk" }))} empty="" /></div> : <EmptyState icon={Usb} title={`Geen ${title.toLowerCase()}`} description="Dit is normaal als de betreffende hardware niet is doorgemapt." />; }
|
||||
@@ -0,0 +1,46 @@
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { jsonResponse, renderApp, requestJson, requestUrl } from "../test/render";
|
||||
import { SpacesPage } from "./SpacesPage";
|
||||
|
||||
const device = {
|
||||
id: "device-1",
|
||||
alias: null,
|
||||
name: "Bureaulamp",
|
||||
};
|
||||
|
||||
describe("SpacesPage", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = requestUrl(input);
|
||||
if (url.includes("/rooms")) return Promise.resolve(jsonResponse([]));
|
||||
if (url.includes("/devices")) return Promise.resolve(jsonResponse({ items: [device], total: 1, limit: 500, offset: 0 }));
|
||||
if (url.includes("/groups") && (init?.method ?? "GET") === "POST") return Promise.resolve(jsonResponse({ id: "group-1", name: "Bureau", device_count: 1 }, 201));
|
||||
if (url.includes("/groups")) return Promise.resolve(jsonResponse([]));
|
||||
return Promise.resolve(jsonResponse({}));
|
||||
}));
|
||||
});
|
||||
|
||||
it("creates a static and dynamic group from the management form", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderApp(<SpacesPage />, "/spaces");
|
||||
await user.click(await screen.findByRole("button", { name: "Nieuwe groep" }));
|
||||
await screen.findByText("Bureaulamp");
|
||||
await user.type(screen.getByLabelText("Naam"), "Bureau");
|
||||
await user.type(screen.getByLabelText("Dynamische tags (optioneel)"), "bureau, rgb");
|
||||
await user.click(screen.getByRole("checkbox", { name: "Bureaulamp" }));
|
||||
await user.click(screen.getByRole("button", { name: "Aanmaken" }));
|
||||
|
||||
await waitFor(() => {
|
||||
const calls = vi.mocked(fetch).mock.calls;
|
||||
const create = calls.find(([input, init]) => requestUrl(input).endsWith("/api/v1/groups") && init?.method === "POST");
|
||||
expect(create).toBeDefined();
|
||||
expect(requestJson(create?.[1])).toMatchObject({
|
||||
name: "Bureau",
|
||||
device_ids: ["device-1"],
|
||||
dynamic_query: { tags: ["bureau", "rgb"], match: "all" },
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Boxes, DoorOpen, Palette, Pencil, Plus, Power, Trash2, Users } from "lucide-react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { api } from "../api/client";
|
||||
import type { Device, Group, Page, Room } from "../api/types";
|
||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||
import { useToast } from "../components/Toast";
|
||||
import { Button, Card, CardHeader, EmptyState, ErrorPanel, Field, Input, LoadingGrid, PageHeader } from "../components/ui";
|
||||
import { useI18n } from "../lib/i18n";
|
||||
import { formValue, hexToColor } from "../lib/utils";
|
||||
|
||||
type Editor = { kind: "room"; item?: Room } | { kind: "group"; item?: Group };
|
||||
type DeleteTarget = { kind: "room" | "group"; id: string; name: string };
|
||||
|
||||
export function SpacesPage() {
|
||||
const { text } = useI18n();
|
||||
const queryClient = useQueryClient();
|
||||
const { notify } = useToast();
|
||||
const [editor, setEditor] = useState<Editor | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<DeleteTarget | null>(null);
|
||||
const [controlGroup, setControlGroup] = useState<Group | null>(null);
|
||||
const [groupColor, setGroupColor] = useState("#5660ff");
|
||||
const [groupBrightness, setGroupBrightness] = useState(100);
|
||||
|
||||
const rooms = useQuery({ queryKey: ["rooms"], queryFn: () => api<Room[]>("/api/v1/rooms") });
|
||||
const groups = useQuery({ queryKey: ["groups"], queryFn: () => api<Group[]>("/api/v1/groups") });
|
||||
const devices = useQuery({ queryKey: ["devices"], queryFn: () => api<Page<Device>>("/api/v1/devices?limit=500") });
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["rooms"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["groups"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||
};
|
||||
const save = useMutation({
|
||||
mutationFn: ({ kind, id, payload }: { kind: "room" | "group"; id?: string; payload: Record<string, unknown> }) => api(
|
||||
`/api/v1/${kind === "room" ? "rooms" : "groups"}${id ? `/${id}` : ""}`,
|
||||
{ method: id ? "PUT" : "POST", body: JSON.stringify(payload) },
|
||||
),
|
||||
onSuccess: (_, variables) => {
|
||||
notify(variables.id ? text("Indeling bijgewerkt.", "Organization updated.") : text("Indeling aangemaakt.", "Organization created."));
|
||||
setEditor(null);
|
||||
invalidate();
|
||||
},
|
||||
onError: (error) => notify(error instanceof Error ? error.message : text("Opslaan mislukt.", "Save failed."), "danger"),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: (target: DeleteTarget) => api(`/api/v1/${target.kind === "room" ? "rooms" : "groups"}/${target.id}`, { method: "DELETE" }),
|
||||
onSuccess: () => {
|
||||
notify(text("Indeling verwijderd.", "Organization removed."));
|
||||
setDeleteTarget(null);
|
||||
invalidate();
|
||||
},
|
||||
onError: (error) => notify(error instanceof Error ? error.message : text("Verwijderen mislukt.", "Delete failed."), "danger"),
|
||||
});
|
||||
const loadGroup = useMutation({
|
||||
mutationFn: (id: string) => api<Group>(`/api/v1/groups/${id}`),
|
||||
onSuccess: (item) => setEditor({ kind: "group", item }),
|
||||
onError: (error) => notify(error instanceof Error ? error.message : text("Groep laden mislukt.", "Failed to load group."), "danger"),
|
||||
});
|
||||
const groupState = useMutation({
|
||||
mutationFn: ({ id, state }: { id: string; state: Record<string, unknown> }) => api<{ results: Array<{ status: string }> }>(`/api/v1/groups/${id}/state`, { method: "POST", body: JSON.stringify({ state }) }),
|
||||
onSuccess: (result) => {
|
||||
const failed = result.results.filter((item) => item.status === "failed").length;
|
||||
notify(failed ? text(`${failed} apparaten konden niet worden bijgewerkt.`, `${failed} devices could not be updated.`) : text("Groep bijgewerkt.", "Group updated."), failed ? "danger" : "success");
|
||||
setControlGroup(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ["devices"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["system"] });
|
||||
},
|
||||
onError: (error) => notify(error instanceof Error ? error.message : text("Groepsopdracht mislukt.", "Group command failed."), "danger"),
|
||||
});
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!editor) return;
|
||||
const data = new FormData(event.currentTarget);
|
||||
const name = formValue(data, "name").trim();
|
||||
const description = formValue(data, "description").trim() || null;
|
||||
if (!name) return;
|
||||
if (editor.kind === "room") {
|
||||
save.mutate({ kind: "room", id: editor.item?.id, payload: { name, description, sort_order: editor.item?.sort_order ?? 0 } });
|
||||
return;
|
||||
}
|
||||
const tags = formValue(data, "tags").split(",").map((tag) => tag.trim()).filter(Boolean);
|
||||
save.mutate({
|
||||
kind: "group",
|
||||
id: editor.item?.id,
|
||||
payload: {
|
||||
name,
|
||||
description,
|
||||
sort_order: editor.item?.sort_order ?? 0,
|
||||
device_ids: data.getAll("devices").map(String),
|
||||
dynamic_query: tags.length ? { tags, match: data.get("tag_match") === "any" ? "any" : "all" } : null,
|
||||
},
|
||||
});
|
||||
};
|
||||
const error = rooms.error ?? groups.error ?? devices.error;
|
||||
|
||||
return <>
|
||||
<PageHeader
|
||||
eyebrow={text("Logische indeling", "Logical organization")}
|
||||
title={text("Kamers & groepen", "Rooms & groups")}
|
||||
description={text("Een apparaat kan in één kamer en in meerdere statische of dynamische groepen staan.", "A device can belong to one room and multiple static or dynamic groups.")}
|
||||
actions={<><Button variant="secondary" onClick={() => setEditor({ kind: "room" })}><DoorOpen size={16} /> {text("Nieuwe kamer", "New room")}</Button><Button onClick={() => setEditor({ kind: "group" })}><Plus size={16} /> {text("Nieuwe groep", "New group")}</Button></>}
|
||||
/>
|
||||
{editor ? (
|
||||
<Card className="inline-form">
|
||||
<CardHeader title={editor.item ? text("Indeling bewerken", "Edit organization") : editor.kind === "room" ? text("Kamer aanmaken", "Create room") : text("Groep aanmaken", "Create group")} description={editor.kind === "room" ? text("Gebruik een herkenbare fysieke locatie.", "Use a recognizable physical location.") : text("Selecteer apparaten en/of match dynamisch op tags.", "Select devices and/or match dynamically by tags.")} />
|
||||
<form onSubmit={submit}>
|
||||
<div className="form-grid form-grid--two"><Field label={text("Naam", "Name")}><Input name="name" required autoFocus defaultValue={editor.item?.name ?? ""} placeholder={editor.kind === "room" ? text("Bijvoorbeeld Werkplek", "For example Workspace") : text("Bijvoorbeeld Bureauverlichting", "For example Desk lights")} /></Field><Field label={text("Omschrijving", "Description")}><Input name="description" defaultValue={editor.item?.description ?? ""} /></Field></div>
|
||||
{editor.kind === "group" ? <>
|
||||
<div className="form-grid form-grid--two"><Field label={text("Dynamische tags (optioneel)", "Dynamic tags (optional)")}><Input name="tags" defaultValue={editor.item?.dynamic_query?.tags?.join(", ") ?? ""} placeholder="bureau, rgb" /></Field><Field label={text("Tagmatching", "Tag matching")}><select className="input" name="tag_match" defaultValue={editor.item?.dynamic_query?.match ?? "all"}><option value="all">{text("Alle tags", "All tags")}</option><option value="any">{text("Minstens één tag", "At least one tag")}</option></select></Field></div>
|
||||
<fieldset className="check-grid"><legend>{text("Statische apparaten", "Static devices")}</legend>{devices.data?.items.map((device) => <label key={device.id}><input type="checkbox" name="devices" value={device.id} defaultChecked={editor.item?.devices?.some((item) => item.id === device.id)} /> <span>{device.alias || device.name}</span></label>)}</fieldset>
|
||||
</> : null}
|
||||
<div className="form-actions"><Button variant="ghost" type="button" onClick={() => setEditor(null)}>{text("Annuleren", "Cancel")}</Button><Button type="submit" busy={save.isPending}>{editor.item ? text("Opslaan", "Save") : text("Aanmaken", "Create")}</Button></div>
|
||||
</form>
|
||||
</Card>
|
||||
) : null}
|
||||
{error ? <ErrorPanel error={error} retry={() => void Promise.all([rooms.refetch(), groups.refetch(), devices.refetch()])} /> : null}
|
||||
<section className="section-block">
|
||||
<div className="section-heading"><div><h2>{text("Kamers", "Rooms")}</h2><p>{text("Fysieke locaties voor snelle selectie en overzicht.", "Physical locations for quick selection and overview.")}</p></div></div>
|
||||
{rooms.isLoading ? <LoadingGrid /> : rooms.data?.length ? <div className="grid grid--cards">{rooms.data.map((room, index) => <Card key={room.id} className="space-card"><span className={`space-card__icon space-card__icon--${index % 4}`}><DoorOpen size={21} /></span><div className="space-card__content"><h3>{room.name}</h3><p>{room.description || text(`${room.device_count} apparaten`, `${room.device_count} devices`)}</p></div><div className="card-actions"><Button variant="ghost" title={text("Bewerken", "Edit")} onClick={() => setEditor({ kind: "room", item: room })}><Pencil size={15} /></Button><Button variant="ghost" title={text("Verwijderen", "Delete")} onClick={() => setDeleteTarget({ kind: "room", id: room.id, name: room.name })}><Trash2 size={15} /></Button></div></Card>)}</div> : <EmptyState icon={DoorOpen} title={text("Nog geen kamers", "No rooms yet")} description={text("Maak je eerste fysieke locatie aan.", "Create your first physical location.")} />}
|
||||
</section>
|
||||
<section className="section-block">
|
||||
<div className="section-heading"><div><h2>{text("Logische groepen", "Logical groups")}</h2><p>{text("Combineer apparaten over kamers en connectoren heen.", "Combine devices across rooms and connectors.")}</p></div></div>
|
||||
{groups.isLoading ? <LoadingGrid /> : groups.data?.length ? <div className="grid grid--cards">{groups.data.map((group, index) => <Card key={group.id} className="space-card"><span className={`space-card__icon space-card__icon--${(index + 1) % 4}`}><Users size={21} /></span><div className="space-card__content"><h3>{group.name}</h3><p>{text(`${group.device_count} apparaten`, `${group.device_count} devices`)}{group.dynamic_query?.tags?.length ? ` · ${group.dynamic_query.tags.join(", ")}` : ""}</p></div><div className="card-actions"><Button variant="ghost" title={text("Groep bedienen", "Control group")} onClick={() => setControlGroup(group)}><Palette size={15} /></Button><Button variant="ghost" title={text("Alles uit", "All off")} onClick={() => groupState.mutate({ id: group.id, state: { power: false } })}><Power size={15} /></Button><Button variant="ghost" title={text("Bewerken", "Edit")} busy={loadGroup.isPending} onClick={() => loadGroup.mutate(group.id)}><Pencil size={15} /></Button><Button variant="ghost" title={text("Verwijderen", "Delete")} onClick={() => setDeleteTarget({ kind: "group", id: group.id, name: group.name })}><Trash2 size={15} /></Button></div></Card>)}</div> : <EmptyState icon={Boxes} title={text("Nog geen groepen", "No groups yet")} description={text("Maak een groep om meerdere apparaten tegelijk te bedienen.", "Create a group to control multiple devices together.")} />}
|
||||
</section>
|
||||
<ConfirmDialog open={Boolean(deleteTarget)} title={text("Indeling verwijderen?", "Delete organization?")} description={text(`“${deleteTarget?.name ?? ""}” wordt verwijderd. Apparaten zelf blijven behouden.`, `“${deleteTarget?.name ?? ""}” will be deleted. Devices themselves are preserved.`)} confirmLabel={text("Verwijderen", "Delete")} danger busy={remove.isPending} onClose={() => setDeleteTarget(null)} onConfirm={() => deleteTarget && remove.mutate(deleteTarget)} />
|
||||
<ConfirmDialog open={Boolean(controlGroup)} title={text("Groep bedienen", "Control group")} description={text(`Pas kleur en helderheid toe op alle geschikte apparaten in “${controlGroup?.name ?? ""}”.`, `Apply color and brightness to all capable devices in “${controlGroup?.name ?? ""}”.`)} confirmLabel={text("Toepassen", "Apply")} busy={groupState.isPending} confirmDisabled={!/^#[0-9a-f]{6}$/i.test(groupColor)} onClose={() => setControlGroup(null)} onConfirm={() => controlGroup && groupState.mutate({ id: controlGroup.id, state: { power: true, colors: [hexToColor(groupColor)], brightness: groupBrightness } })}>
|
||||
<div className="dialog-form"><Field label={text("Kleur", "Color")}><div className="large-color"><input type="color" value={groupColor} onChange={(event) => setGroupColor(event.target.value)} /><Input value={groupColor.toUpperCase()} onChange={(event) => setGroupColor(event.target.value)} /></div></Field><Field label={text(`Helderheid · ${groupBrightness}%`, `Brightness · ${groupBrightness}%`)}><input className="range" type="range" min="0" max="100" value={groupBrightness} onChange={(event) => setGroupBrightness(Number(event.target.value))} /></Field></div>
|
||||
</ConfirmDialog>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
:root {
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
--bg: #f4f6fb;
|
||||
--surface: #ffffff;
|
||||
--surface-raised: #ffffff;
|
||||
--surface-muted: #f0f2f8;
|
||||
--border: #dfe3ed;
|
||||
--border-strong: #cbd1df;
|
||||
--text: #182033;
|
||||
--text-muted: #687086;
|
||||
--accent: #5964f3;
|
||||
--accent-hover: #4853df;
|
||||
--accent-soft: #eef0ff;
|
||||
--success: #159a74;
|
||||
--success-soft: #e5f7f0;
|
||||
--warning: #c47a16;
|
||||
--warning-soft: #fff3dc;
|
||||
--danger: #d24754;
|
||||
--danger-soft: #ffeaec;
|
||||
--shadow: 0 10px 30px rgba(24, 32, 51, 0.07);
|
||||
--shadow-lg: 0 22px 60px rgba(18, 25, 45, 0.14);
|
||||
--radius: 16px;
|
||||
--radius-sm: 10px;
|
||||
--sidebar: #101628;
|
||||
--sidebar-muted: #919bb4;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #090d17;
|
||||
--surface: #111725;
|
||||
--surface-raised: #151c2c;
|
||||
--surface-muted: #1a2233;
|
||||
--border: #283248;
|
||||
--border-strong: #39445d;
|
||||
--text: #eef2fc;
|
||||
--text-muted: #939db3;
|
||||
--accent: #7b83ff;
|
||||
--accent-hover: #9298ff;
|
||||
--accent-soft: #252c56;
|
||||
--success: #3fd0a1;
|
||||
--success-soft: #123a32;
|
||||
--warning: #f0af4f;
|
||||
--warning-soft: #3e2d17;
|
||||
--danger: #ff7180;
|
||||
--danger-soft: #421f29;
|
||||
--shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
|
||||
--shadow-lg: 0 22px 60px rgba(0, 0, 0, 0.36);
|
||||
--sidebar: #0c111e;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
.visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0, 0, 0, 0) !important; white-space: nowrap !important; border: 0 !important; }
|
||||
html { min-width: 320px; background: var(--bg); }
|
||||
body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--bg); color: var(--text); }
|
||||
button, input, select, textarea { font: inherit; }
|
||||
button, a { -webkit-tap-highlight-color: transparent; }
|
||||
button { color: inherit; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
h1, h2, h3, p { margin-top: 0; }
|
||||
h1 { margin-bottom: 9px; font-size: clamp(1.8rem, 3vw, 2.55rem); line-height: 1.08; letter-spacing: -0.035em; }
|
||||
h2 { margin-bottom: 6px; font-size: 1rem; letter-spacing: -0.01em; }
|
||||
h3 { margin-bottom: 4px; font-size: .96rem; }
|
||||
p { margin-bottom: 0; line-height: 1.55; color: var(--text-muted); }
|
||||
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible { outline: 3px solid color-mix(in srgb, var(--accent) 45%, transparent); outline-offset: 2px; }
|
||||
|
||||
.app-shell { min-height: 100vh; display: grid; grid-template-columns: 244px minmax(0, 1fr); }
|
||||
.app-main { min-width: 0; grid-column: 2; }
|
||||
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 40; width: 244px; display: flex; flex-direction: column; background: var(--sidebar); color: #f5f7ff; border-right: 1px solid rgba(255,255,255,.06); transition: width .2s ease, transform .2s ease; }
|
||||
.brand { height: 72px; display: flex; align-items: center; gap: 11px; padding: 0 18px; }
|
||||
.brand__mark { width: 36px; height: 36px; flex: 0 0 auto; border-radius: 11px; display: grid; place-items: center; color: white; background: linear-gradient(145deg, #7077ff, #4b57e8); box-shadow: 0 8px 24px rgba(89,100,243,.34); }
|
||||
.brand__text { min-width: 0; display: flex; flex-direction: column; }
|
||||
.brand__text strong { font-size: 1rem; letter-spacing: -.015em; }
|
||||
.brand__text span { margin-top: 1px; font-size: .68rem; color: var(--sidebar-muted); white-space: nowrap; }
|
||||
.sidebar__nav { flex: 1; overflow-y: auto; padding: 8px 12px 20px; scrollbar-width: thin; }
|
||||
.nav-label { display: block; padding: 16px 10px 7px; color: #68748f; font-size: .65rem; font-weight: 700; letter-spacing: .11em; text-transform: uppercase; }
|
||||
.nav-link { height: 40px; display: flex; align-items: center; gap: 11px; margin: 2px 0; padding: 0 11px; border-radius: 9px; color: #9ba5bd; font-size: .82rem; font-weight: 560; transition: .16s ease; }
|
||||
.nav-link:hover { color: #fff; background: rgba(255,255,255,.055); }
|
||||
.nav-link--active { color: #fff; background: linear-gradient(90deg, rgba(111,120,255,.23), rgba(111,120,255,.1)); box-shadow: inset 2px 0 #7981ff; }
|
||||
.nav-link svg { flex: 0 0 auto; }
|
||||
.sidebar__collapse { height: 48px; display: flex; align-items: center; gap: 10px; border: 0; border-top: 1px solid rgba(255,255,255,.06); padding: 0 22px; background: transparent; color: #78839b; cursor: pointer; font-size: .72rem; }
|
||||
.sidebar__collapse:hover { color: #fff; }
|
||||
.sidebar__close { display: none !important; margin-left: auto; color: #9ba5bd; }
|
||||
.app-shell--collapsed { grid-template-columns: 72px minmax(0, 1fr); }
|
||||
.app-shell--collapsed .sidebar { width: 72px; }
|
||||
.app-shell--collapsed .brand { padding: 0 18px; }
|
||||
.app-shell--collapsed .brand__text, .app-shell--collapsed .nav-label, .app-shell--collapsed .nav-link span, .app-shell--collapsed .sidebar__collapse span { display: none; }
|
||||
.app-shell--collapsed .nav-link { justify-content: center; padding: 0; }
|
||||
.app-shell--collapsed .sidebar__collapse { justify-content: center; padding: 0; }
|
||||
.app-shell--collapsed .sidebar__collapse svg { transform: rotate(180deg); }
|
||||
|
||||
.topbar { position: sticky; top: 0; z-index: 25; height: 64px; display: flex; align-items: center; gap: 13px; padding: 0 clamp(18px, 3vw, 42px); background: color-mix(in srgb, var(--bg) 88%, transparent); border-bottom: 1px solid color-mix(in srgb, var(--border) 80%, transparent); backdrop-filter: blur(16px); }
|
||||
.topbar__status { display: flex; align-items: center; gap: 11px; margin-left: auto; }
|
||||
.topbar__device-count { color: var(--text-muted); font-size: .78rem; }
|
||||
.mobile-menu { display: none !important; }
|
||||
.mock-banner { min-height: 36px; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 7px 20px; background: var(--warning-soft); color: var(--warning); border-bottom: 1px solid color-mix(in srgb, var(--warning) 25%, transparent); font-size: .75rem; font-weight: 650; }
|
||||
.page { width: min(1520px, 100%); margin: 0 auto; padding: clamp(25px, 3vw, 44px); }
|
||||
|
||||
.page-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; margin-bottom: 26px; }
|
||||
.page-header > div:first-child { max-width: 760px; }
|
||||
.page-header p { max-width: 710px; font-size: .9rem; }
|
||||
.page-header__actions { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 9px; }
|
||||
.eyebrow { display: block; margin-bottom: 8px; color: var(--accent); font-size: .68rem; font-weight: 750; letter-spacing: .115em; text-transform: uppercase; }
|
||||
.section-block { margin-top: 32px; }
|
||||
.section-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 20px; margin-bottom: 14px; }
|
||||
.section-heading h2 { font-size: 1.13rem; }
|
||||
.section-heading p { font-size: .8rem; }
|
||||
.text-link, .back-link { color: var(--accent); font-size: .78rem; font-weight: 650; }
|
||||
.back-link { display: inline-flex; align-items: center; gap: 6px; margin-bottom: 17px; }
|
||||
.muted { color: var(--text-muted); font-size: .82rem; }
|
||||
|
||||
.button { min-height: 38px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; border: 1px solid transparent; border-radius: 9px; padding: 0 14px; cursor: pointer; font-size: .77rem; font-weight: 660; white-space: nowrap; transition: transform .13s ease, background .13s ease, border .13s ease; }
|
||||
.button:hover:not(:disabled) { transform: translateY(-1px); }
|
||||
.button:disabled { opacity: .48; cursor: not-allowed; }
|
||||
.button--primary { color: #fff; background: var(--accent); box-shadow: 0 6px 16px color-mix(in srgb, var(--accent) 26%, transparent); }
|
||||
.button--primary:hover:not(:disabled) { background: var(--accent-hover); }
|
||||
.button--secondary { color: var(--text); background: var(--surface); border-color: var(--border); }
|
||||
.button--secondary:hover:not(:disabled) { border-color: var(--border-strong); background: var(--surface-muted); }
|
||||
.button--ghost { color: var(--text-muted); background: transparent; }
|
||||
.button--ghost:hover:not(:disabled) { color: var(--text); background: var(--surface-muted); }
|
||||
.button--danger { color: #fff; background: var(--danger); box-shadow: 0 6px 16px color-mix(in srgb, var(--danger) 22%, transparent); }
|
||||
.full-width { width: 100%; }
|
||||
.icon-button { width: 36px; height: 36px; display: inline-grid; place-items: center; border: 1px solid var(--border); border-radius: 9px; background: var(--surface); cursor: pointer; }
|
||||
.icon-button:hover { background: var(--surface-muted); }
|
||||
.spin { animation: spin .8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
.card { min-width: 0; padding: 20px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); }
|
||||
.card__header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 18px; }
|
||||
.card__header h2 { font-size: .96rem; }
|
||||
.card__header p { font-size: .75rem; }
|
||||
.grid { display: grid; gap: 14px; }
|
||||
.grid--stats { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.grid--cards { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.grid--devices { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.grid--scenes { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
||||
.grid--connectors { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||||
.grid--health { grid-template-columns: repeat(4, minmax(0, 1fr)); }
|
||||
.dashboard-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(300px, .75fr); gap: 16px; margin-top: 16px; }
|
||||
|
||||
.badge { width: fit-content; display: inline-flex; align-items: center; gap: 4px; padding: 4px 8px; border-radius: 999px; background: var(--surface-muted); color: var(--text-muted); font-size: .64rem; font-weight: 720; line-height: 1; }
|
||||
.badge--success { background: var(--success-soft); color: var(--success); }
|
||||
.badge--warning { background: var(--warning-soft); color: var(--warning); }
|
||||
.badge--danger { background: var(--danger-soft); color: var(--danger); }
|
||||
.badge--accent { background: var(--accent-soft); color: var(--accent); }
|
||||
.stat { display: flex; align-items: center; gap: 13px; padding: 17px; }
|
||||
.stat__icon { width: 39px; height: 39px; display: grid; place-items: center; flex: 0 0 auto; border-radius: 11px; background: var(--accent-soft); color: var(--accent); }
|
||||
.stat > div { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.stat__label { color: var(--text-muted); font-size: .67rem; font-weight: 600; }
|
||||
.stat strong { font-size: 1.16rem; }
|
||||
.stat small { overflow: hidden; color: var(--text-muted); font-size: .61rem; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.quick-control { background: radial-gradient(circle at 18% 0, color-mix(in srgb, var(--accent) 9%, transparent), transparent 38%), var(--surface); }
|
||||
.color-control { display: grid; grid-template-columns: 54px minmax(0,1fr) auto; align-items: center; gap: 13px; }
|
||||
.color-control input[type="color"] { width: 54px; height: 54px; padding: 0; border: 0; border-radius: 14px; overflow: hidden; background: transparent; cursor: pointer; }
|
||||
.color-control input[type="color"]::-webkit-color-swatch-wrapper { padding: 0; }
|
||||
.color-control input[type="color"]::-webkit-color-swatch { border: 4px solid var(--surface-muted); border-radius: 14px; }
|
||||
.color-control > div { display: flex; flex-direction: column; }
|
||||
.color-control strong { font-size: .92rem; }
|
||||
.color-control span { color: var(--text-muted); font-size: .67rem; }
|
||||
.color-swatches { display: flex; gap: 9px; margin-top: 18px; }
|
||||
.color-swatches button { width: 27px; height: 27px; border: 3px solid var(--surface); border-radius: 50%; box-shadow: 0 0 0 1px var(--border); cursor: pointer; }
|
||||
.color-swatches button.selected { box-shadow: 0 0 0 2px var(--accent); }
|
||||
.active-scene { display: grid; grid-template-columns: auto minmax(0,1fr) auto; align-items: center; gap: 12px; padding: 11px; border-radius: 12px; background: var(--surface-muted); }
|
||||
.active-scene > div { display: flex; flex-direction: column; }
|
||||
.active-scene span:not(.badge):not(.scene-orb) { color: var(--text-muted); font-size: .67rem; }
|
||||
.scene-orb { width: 42px; height: 42px; border-radius: 13px; background: radial-gradient(circle at 30% 25%, #fff, #7881ff 22%, #252d80 75%); box-shadow: 0 7px 18px rgba(89,100,243,.35); }
|
||||
.scene-card { overflow: hidden; padding: 0; }
|
||||
.scene-gradient { height: 76px; display: grid; place-items: center; color: rgba(255,255,255,.82); }
|
||||
.scene-gradient--0 { background: radial-gradient(circle at 30% 20%, #9aa1ff, transparent 42%), linear-gradient(125deg, #303989, #161b49); }
|
||||
.scene-gradient--1 { background: radial-gradient(circle at 70% 20%, #ffca8e, transparent 42%), linear-gradient(125deg, #683d42, #261a2b); }
|
||||
.scene-gradient--2 { background: radial-gradient(circle at 45% 10%, #74f0d0, transparent 45%), linear-gradient(125deg, #16655f, #102f3d); }
|
||||
.scene-gradient--3 { background: radial-gradient(circle at 60% 20%, #ec9fff, transparent 42%), linear-gradient(125deg, #60346d, #242041); }
|
||||
.scene-card__body { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 13px 15px 15px; }
|
||||
.scene-card__body h3 { font-size: .86rem; }
|
||||
.scene-card__body p { font-size: .65rem; }
|
||||
.timeline { display: flex; flex-direction: column; }
|
||||
.timeline__item { display: grid; grid-template-columns: auto minmax(0,1fr) auto; align-items: center; gap: 11px; min-height: 49px; border-bottom: 1px solid var(--border); }
|
||||
.timeline__item:last-child { border: 0; }
|
||||
.timeline__item > div { display: flex; flex-direction: column; }
|
||||
.timeline__item strong { font-size: .75rem; }
|
||||
.timeline__item span { color: var(--text-muted); font-size: .63rem; }
|
||||
.timeline__dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); box-shadow: 0 0 0 4px var(--surface-muted); }
|
||||
.timeline__dot--succeeded { background: var(--success); box-shadow: 0 0 0 4px var(--success-soft); }
|
||||
.timeline__dot--failed { background: var(--danger); box-shadow: 0 0 0 4px var(--danger-soft); }
|
||||
.warning-row, .all-clear { display: flex; align-items: flex-start; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--border); }
|
||||
.warning-row > span { width: 7px; height: 7px; margin-top: 6px; border-radius: 50%; background: var(--warning); }
|
||||
.warning-row strong, .all-clear strong { font-size: .76rem; }
|
||||
.warning-row p, .all-clear p { font-size: .67rem; }
|
||||
.all-clear { align-items: center; border: 0; }
|
||||
.all-clear > span { width: 37px; height: 37px; display: grid; place-items: center; border-radius: 10px; background: var(--success-soft); color: var(--success); }
|
||||
|
||||
.alert { display: flex; align-items: flex-start; gap: 11px; margin: 0 0 18px; padding: 13px 15px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface); }
|
||||
.alert > svg { flex: 0 0 auto; margin-top: 1px; }
|
||||
.alert > div { flex: 1; }
|
||||
.alert strong { font-size: .78rem; }
|
||||
.alert p { font-size: .7rem; }
|
||||
.alert--danger { border-color: color-mix(in srgb, var(--danger) 28%, var(--border)); background: var(--danger-soft); color: var(--danger); }
|
||||
.alert--danger p { color: color-mix(in srgb, var(--danger) 70%, var(--text)); }
|
||||
.alert--info { border-color: color-mix(in srgb, var(--accent) 24%, var(--border)); background: var(--accent-soft); color: var(--accent); }
|
||||
.alert--info p { color: color-mix(in srgb, var(--accent) 60%, var(--text)); }
|
||||
|
||||
.toolbar { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; }
|
||||
.search { min-width: 240px; max-width: 440px; flex: 1; display: flex; align-items: center; gap: 8px; padding: 0 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); color: var(--text-muted); }
|
||||
.search .input { border: 0; padding-left: 0; box-shadow: none; background: transparent; }
|
||||
.input { width: 100%; min-height: 39px; border: 1px solid var(--border); border-radius: 9px; padding: 8px 10px; background: var(--surface); color: var(--text); box-shadow: 0 1px 2px rgba(0,0,0,.02); }
|
||||
.input::placeholder { color: var(--text-muted); }
|
||||
.input:focus { border-color: var(--accent); }
|
||||
.segmented, .view-toggle { display: flex; padding: 3px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); }
|
||||
.segmented button, .view-toggle button { min-height: 31px; border: 0; border-radius: 7px; padding: 0 11px; background: transparent; color: var(--text-muted); cursor: pointer; font-size: .69rem; }
|
||||
.segmented button.active, .view-toggle button.active { background: var(--surface-muted); color: var(--text); box-shadow: 0 1px 3px rgba(0,0,0,.08); }
|
||||
.view-toggle button { width: 32px; padding: 0; display: grid; place-items: center; }
|
||||
|
||||
.device-card, .device-row { display: block; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: var(--shadow); transition: transform .16s ease, border-color .16s ease; }
|
||||
.device-card:hover, .device-row:hover { transform: translateY(-2px); border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); }
|
||||
.device-card__visual { --device-color: #5660ff; position: relative; height: 112px; display: grid; place-items: center; overflow: hidden; background: radial-gradient(circle at center, color-mix(in srgb, var(--device-color) 35%, transparent), transparent 53%), linear-gradient(145deg, var(--surface-muted), var(--surface)); color: var(--device-color); }
|
||||
.device-glow { position: absolute; width: 70px; height: 70px; border-radius: 50%; background: var(--device-color); filter: blur(32px); opacity: .28; }
|
||||
.device-card__visual svg { position: relative; z-index: 1; filter: drop-shadow(0 3px 10px color-mix(in srgb, var(--device-color) 35%, transparent)); }
|
||||
.presence { position: absolute; right: 12px; top: 12px; width: 8px; height: 8px; border: 2px solid var(--surface); border-radius: 50%; background: var(--text-muted); box-sizing: content-box; }
|
||||
.presence--online { background: var(--success); box-shadow: 0 0 10px color-mix(in srgb, var(--success) 75%, transparent); }
|
||||
.device-card__content { padding: 15px; }
|
||||
.device-card__title { display: flex; justify-content: space-between; gap: 10px; }
|
||||
.device-card__title h2 { font-size: .9rem; }
|
||||
.device-card__title p { font-size: .65rem; }
|
||||
.favorite { color: #edb539; }
|
||||
.device-card__meta { min-height: 24px; display: flex; flex-wrap: wrap; gap: 5px; margin-top: 11px; }
|
||||
.device-card__footer { display: flex; justify-content: space-between; gap: 10px; margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); color: var(--text-muted); font-size: .63rem; }
|
||||
.device-card__footer span { display: flex; align-items: center; gap: 6px; }
|
||||
.device-card__footer i { width: 8px; height: 8px; border-radius: 50%; box-shadow: 0 0 6px currentColor; }
|
||||
.device-card--group { border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); }
|
||||
.device-card--group .device-card__visual { background: radial-gradient(circle at center, color-mix(in srgb, var(--device-color) 42%, transparent), transparent 55%), linear-gradient(145deg, var(--accent-soft), var(--surface)); }
|
||||
.device-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.device-row { display: grid; grid-template-columns: 70px minmax(0,1fr); align-items: center; }
|
||||
.device-row .device-card__visual { height: 68px; margin: 6px; border-radius: 11px; }
|
||||
.device-row .device-card__content { display: grid; grid-template-columns: minmax(0,1fr) auto auto; align-items: center; gap: 20px; padding: 11px 16px 11px 8px; }
|
||||
.device-row .device-card__title { min-width: 0; }
|
||||
.device-row .device-card__meta { margin: 0; }
|
||||
.device-row__extra { min-width: 190px; display: flex; justify-content: flex-end; gap: 18px; color: var(--text-muted); font-size: .68rem; }
|
||||
|
||||
.detail-status { display: flex; align-items: center; flex-wrap: wrap; gap: 7px; margin-top: -13px; margin-bottom: 21px; }
|
||||
.detail-status > span:last-child { margin-left: auto; color: var(--text-muted); font-size: .68rem; }
|
||||
.detail-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(300px, .65fr); gap: 16px; }
|
||||
.detail-side { display: flex; flex-direction: column; gap: 16px; }
|
||||
.control-panel { padding: 23px; }
|
||||
.power-actions { display: flex; gap: 8px; margin-bottom: 18px; }
|
||||
.field { display: flex; flex-direction: column; gap: 7px; margin-bottom: 16px; }
|
||||
.field__label { color: var(--text); font-size: .72rem; font-weight: 680; }
|
||||
.field__hint { color: var(--text-muted); font-size: .62rem; }
|
||||
.large-color { display: grid; grid-template-columns: 52px minmax(0,1fr); gap: 8px; }
|
||||
.large-color input[type="color"] { width: 52px; height: 39px; padding: 2px; border: 1px solid var(--border); border-radius: 9px; background: var(--surface); }
|
||||
.effect-colors { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 10px; }
|
||||
.effect-color { display: flex; flex-direction: column; gap: 6px; }
|
||||
.effect-color > span { color: var(--text-muted); font-size: .62rem; font-weight: 650; }
|
||||
.mode-hint { margin: -5px 0 16px; padding: 10px 12px; border-radius: 9px; background: var(--surface-muted); color: var(--text-muted); font-size: .65rem; }
|
||||
.range { width: 100%; accent-color: var(--accent); }
|
||||
.device-group-overview { align-items: stretch; }
|
||||
.group-summary { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; min-height: 230px; padding: 26px; border: 1px solid var(--border); border-radius: var(--radius); background: radial-gradient(circle at 50% 25%, var(--accent-soft), transparent 55%), var(--surface); text-align: center; box-shadow: var(--shadow); }
|
||||
.group-summary svg { color: var(--accent); }
|
||||
.group-summary strong { font-size: 1.05rem; }
|
||||
.group-summary span { max-width: 280px; color: var(--text-muted); font-size: .68rem; line-height: 1.55; }
|
||||
.properties { margin: 0; }
|
||||
.properties > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); }
|
||||
.properties > div:last-child { border: 0; }
|
||||
.properties dt { display: flex; align-items: center; gap: 7px; color: var(--text-muted); font-size: .67rem; }
|
||||
.properties dd { margin: 0; max-width: 58%; text-align: right; overflow-wrap: anywhere; font-size: .68rem; font-weight: 620; }
|
||||
.switch-list { display: flex; flex-direction: column; }
|
||||
.switch-row { display: flex; align-items: center; justify-content: space-between; min-height: 42px; border-bottom: 1px solid var(--border); font-size: .69rem; }
|
||||
.switch-row:last-child { border: 0; }
|
||||
.switch-row--danger { color: var(--danger); }
|
||||
.switch-row input { position: absolute; opacity: 0; }
|
||||
.switch-row i { position: relative; width: 34px; height: 19px; border-radius: 999px; background: var(--border-strong); cursor: pointer; transition: .16s ease; }
|
||||
.switch-row i::after { content: ""; position: absolute; left: 3px; top: 3px; width: 13px; height: 13px; border-radius: 50%; background: white; transition: .16s ease; box-shadow: 0 1px 3px rgba(0,0,0,.2); }
|
||||
.switch-row input:checked + i { background: var(--accent); }
|
||||
.switch-row input:checked + i::after { transform: translateX(15px); }
|
||||
.switch-row input:focus-visible + i { outline: 3px solid color-mix(in srgb, var(--accent) 35%, transparent); }
|
||||
.zone-card { display: flex; align-items: center; gap: 11px; }
|
||||
.zone-card > span { width: 37px; height: 37px; display: grid; place-items: center; border-radius: 10px; color: var(--accent); background: var(--accent-soft); }
|
||||
.zone-card p { font-size: .67rem; }
|
||||
.argb-zone-list { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
|
||||
.argb-zone-card { display: flex; flex-direction: column; gap: 18px; }
|
||||
.argb-zone-card .field { margin-bottom: 0; }
|
||||
.argb-zone-card__heading { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; }
|
||||
.argb-zone-card__heading > span { width: 39px; height: 39px; display: grid; place-items: center; border-radius: 11px; color: var(--accent); background: var(--accent-soft); }
|
||||
.argb-zone-card__heading p { font-size: .65rem; }
|
||||
.zone-size-control { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; }
|
||||
|
||||
.inline-form { margin-bottom: 20px; border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); }
|
||||
.inline-form form { max-width: 780px; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 14px; }
|
||||
.form-grid--two { grid-template-columns: repeat(2, minmax(0,1fr)); }
|
||||
.form-grid--three { grid-template-columns: repeat(3, minmax(0,1fr)); }
|
||||
.form-grid .form-actions { grid-column: 1 / -1; }
|
||||
.form-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; }
|
||||
.form-stack { display: flex; flex-direction: column; gap: 8px; }
|
||||
.check-grid { display: grid; grid-template-columns: repeat(2, minmax(0,1fr)); gap: 8px; margin: 4px 0 16px; padding: 14px; border: 1px solid var(--border); border-radius: 10px; }
|
||||
.check-grid legend { padding: 0 5px; color: var(--text-muted); font-size: .68rem; }
|
||||
.check-grid label { display: flex; align-items: center; gap: 7px; font-size: .7rem; }
|
||||
.check-grid input { accent-color: var(--accent); }
|
||||
.space-card, .network-device, .planned-connector { display: flex; align-items: center; gap: 12px; }
|
||||
.space-card > span, .network-device > span, .planned-connector > span { width: 42px; height: 42px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 12px; background: var(--accent-soft); color: var(--accent); }
|
||||
.space-card__icon--1 { color: var(--success) !important; background: var(--success-soft) !important; }
|
||||
.space-card__icon--2 { color: var(--warning) !important; background: var(--warning-soft) !important; }
|
||||
.space-card__icon--3 { color: #be62d3 !important; background: color-mix(in srgb, #be62d3 14%, var(--surface)) !important; }
|
||||
.space-card p, .network-device p, .planned-connector p { font-size: .67rem; }
|
||||
.space-card__content { flex: 1; min-width: 0; }
|
||||
.card-actions, .automation-row__actions { display: flex; align-items: center; gap: 3px; }
|
||||
.card-actions .button, .automation-row__actions .button { min-width: 34px; padding: 0 9px; }
|
||||
.network-device > div, .planned-connector > div { flex: 1; }
|
||||
|
||||
.scene-tile { overflow: hidden; padding: 0; }
|
||||
.scene-tile__visual { height: 120px; display: flex; align-items: flex-start; justify-content: space-between; padding: 16px; color: #fff; }
|
||||
.scene-tile__content { padding: 16px; }
|
||||
.scene-tile__content h2 { font-size: .94rem; }
|
||||
.scene-tile__content > div:first-child p { min-height: 34px; font-size: .67rem; }
|
||||
.scene-tile dl { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 14px 0; }
|
||||
.scene-tile dl div { display: flex; flex-direction: column; gap: 2px; }
|
||||
.scene-tile dt { color: var(--text-muted); font-size: .58rem; }
|
||||
.scene-tile dd { margin: 0; font-size: .65rem; font-weight: 650; }
|
||||
.scene-tile__actions { display: flex; gap: 6px; }
|
||||
.scene-tile__actions .button:first-child { flex: 1; }
|
||||
.automation-list { display: flex; flex-direction: column; gap: 10px; }
|
||||
.automation-row { display: flex; align-items: center; gap: 14px; padding: 15px; }
|
||||
.automation-row__icon { width: 40px; height: 40px; flex: 0 0 auto; display: grid; place-items: center; border-radius: 11px; color: var(--accent); background: var(--accent-soft); }
|
||||
.automation-row__main { flex: 1; min-width: 0; display: grid; grid-template-columns: minmax(0,1fr) auto; align-items: center; gap: 20px; }
|
||||
.automation-row__main h2 { font-size: .84rem; }
|
||||
.automation-row__main p { display: flex; align-items: center; gap: 5px; font-size: .64rem; }
|
||||
.automation-row__meta { display: flex; align-items: center; gap: 12px; color: var(--text-muted); font-size: .62rem; }
|
||||
.resource-editor { margin-bottom: 20px; border-color: color-mix(in srgb, var(--accent) 30%, var(--border)); }
|
||||
.resource-editor__section { margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border); }
|
||||
.resource-editor__section h3 { margin-bottom: 10px; font-size: .8rem; }
|
||||
.resource-editor__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 18px; padding-top: 16px; border-top: 1px solid var(--border); }
|
||||
.checkbox-row { display: flex; align-items: center; gap: 8px; color: var(--text-muted); font-size: .7rem; }
|
||||
.checkbox-row input, .option-control > input:first-child, .weekday-grid input { accent-color: var(--accent); }
|
||||
.field-error { margin: -4px 0 0; color: var(--danger); font-size: .65rem; }
|
||||
.scene-item-list { display: flex; flex-direction: column; gap: 7px; }
|
||||
.scene-item { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface-muted); }
|
||||
.scene-item > div { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.scene-item strong { font-size: .72rem; }
|
||||
.scene-item span { color: var(--text-muted); font-size: .62rem; }
|
||||
.scene-target-form { display: grid; grid-template-columns: minmax(170px,1.3fr) minmax(110px,.7fr) minmax(110px,.7fr) minmax(130px,.8fr) auto; align-items: end; gap: 10px; margin-top: 14px; padding: 14px; border: 1px dashed var(--border-strong); border-radius: 11px; }
|
||||
.option-control { min-height: 37px; display: flex; align-items: center; gap: 8px; }
|
||||
.option-control input[type="color"] { width: 44px; height: 34px; padding: 2px; border: 1px solid var(--border); border-radius: 7px; background: var(--surface); }
|
||||
.option-control .input { min-width: 0; }
|
||||
.weekday-grid { display: flex; flex-wrap: wrap; gap: 8px; padding: 12px; border: 1px solid var(--border); border-radius: 10px; }
|
||||
.weekday-grid legend { padding: 0 5px; color: var(--text-muted); font-size: .68rem; }
|
||||
.weekday-grid label { min-width: 52px; display: flex; align-items: center; justify-content: center; gap: 5px; padding: 7px 9px; border-radius: 8px; background: var(--surface-muted); font-size: .66rem; }
|
||||
.run-list { display: flex; flex-direction: column; }
|
||||
.run-row { display: grid; grid-template-columns: 90px 155px 100px minmax(0,1fr); align-items: center; gap: 12px; min-height: 42px; border-bottom: 1px solid var(--border); color: var(--text-muted); font-size: .65rem; }
|
||||
.run-row:last-child { border: 0; }
|
||||
.run-row__error { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dialog-form { display: flex; flex-direction: column; gap: 14px; margin-top: 17px; }
|
||||
|
||||
.connector-card { padding: 22px; }
|
||||
.connector-hero { display: flex; align-items: center; gap: 13px; margin-bottom: 16px; padding: 13px; border-radius: 12px; background: var(--surface-muted); }
|
||||
.connector-hero > span { width: 43px; height: 43px; display: grid; place-items: center; border-radius: 12px; color: var(--accent); background: var(--accent-soft); }
|
||||
.connector-hero p { font-size: .66rem; }
|
||||
.connector-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 13px; }
|
||||
.connector-meta div { display: flex; flex-direction: column; padding: 9px; border: 1px solid var(--border); border-radius: 9px; }
|
||||
.connector-meta span { color: var(--text-muted); font-size: .58rem; }
|
||||
.connector-meta strong { font-size: .68rem; }
|
||||
.mini-warning { display: flex; gap: 7px; margin: 0 0 13px; padding: 9px 10px; border-radius: 8px; background: var(--warning-soft); color: var(--warning); font-size: .62rem; line-height: 1.45; }
|
||||
.planned-connector .badge { margin-left: auto; }
|
||||
.discovery-mode { display: flex; align-items: center; gap: 11px; }
|
||||
.discovery-mode > span { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 10px; background: var(--accent-soft); color: var(--accent); }
|
||||
.discovery-mode p { font-size: .64rem; }
|
||||
|
||||
.table-wrap { max-width: 100%; overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: .7rem; }
|
||||
th { padding: 9px 11px; color: var(--text-muted); text-align: left; font-size: .61rem; font-weight: 700; letter-spacing: .04em; text-transform: uppercase; background: var(--surface-muted); }
|
||||
th:first-child { border-radius: 8px 0 0 8px; }
|
||||
th:last-child { border-radius: 0 8px 8px 0; }
|
||||
td { padding: 11px; border-bottom: 1px solid var(--border); }
|
||||
td:first-child { display: flex; align-items: center; gap: 7px; font-weight: 630; }
|
||||
tr:last-child td { border: 0; }
|
||||
|
||||
.event-list { display: flex; flex-direction: column; }
|
||||
.event-row { display: grid; grid-template-columns: auto minmax(0,1fr) auto; align-items: center; gap: 12px; min-height: 62px; border-bottom: 1px solid var(--border); }
|
||||
.event-row:last-child { border: 0; }
|
||||
.event-row__icon { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 9px; background: var(--surface-muted); color: var(--text-muted); }
|
||||
.event-row__icon--succeeded, .event-row__icon--info { color: var(--success); background: var(--success-soft); }
|
||||
.event-row__icon--failed, .event-row__icon--error { color: var(--danger); background: var(--danger-soft); }
|
||||
.event-row__icon--warning { color: var(--warning); background: var(--warning-soft); }
|
||||
.event-row strong { font-size: .74rem; }
|
||||
.event-row p { font-size: .64rem; }
|
||||
.event-row > div:last-child { display: flex; align-items: flex-end; flex-direction: column; gap: 3px; color: var(--text-muted); font-size: .59rem; }
|
||||
|
||||
.health-card { min-height: 164px; }
|
||||
.health-card__top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
|
||||
.health-card__top > span { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 10px; color: var(--accent); background: var(--accent-soft); }
|
||||
.health-card h2 { font-size: .83rem; }
|
||||
.health-card p { min-height: 38px; margin-bottom: 10px; font-size: .64rem; }
|
||||
.action-list { display: flex; flex-direction: column; }
|
||||
.action-list button, .action-list a { width: 100%; display: flex; align-items: center; gap: 11px; padding: 10px 0; border: 0; border-bottom: 1px solid var(--border); background: transparent; text-align: left; cursor: pointer; }
|
||||
.action-list button:last-child, .action-list a:last-child { border: 0; }
|
||||
.action-list > * > span { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 9px; color: var(--accent); background: var(--accent-soft); }
|
||||
.action-list strong { font-size: .7rem; }
|
||||
.action-list p { font-size: .61rem; }
|
||||
.check-list, .icon-list { display: flex; flex-direction: column; gap: 11px; margin: 0; padding: 0; list-style: none; }
|
||||
.check-list li, .icon-list li { display: flex; align-items: center; gap: 8px; color: var(--text-muted); font-size: .68rem; }
|
||||
.check-list svg { color: var(--success); }
|
||||
.icon-list svg { color: var(--accent); }
|
||||
|
||||
.backup-list { display: flex; flex-direction: column; }
|
||||
.backup-row { display: grid; grid-template-columns: auto minmax(0,1fr) auto auto; align-items: center; gap: 11px; padding: 11px 0; border-bottom: 1px solid var(--border); }
|
||||
.backup-row:last-child { border: 0; }
|
||||
.backup-row > span { width: 36px; height: 36px; display: grid; place-items: center; border-radius: 9px; color: var(--accent); background: var(--accent-soft); }
|
||||
.backup-row strong { font-size: .71rem; }
|
||||
.backup-row p { font-size: .61rem; }
|
||||
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.setting-select { display: grid; grid-template-columns: auto minmax(0,1fr); align-items: center; gap: 9px; color: var(--text-muted); }
|
||||
.theme-options { display: grid; grid-template-columns: repeat(3, 1fr); gap: 7px; }
|
||||
.theme-options button { display: flex; align-items: center; justify-content: center; gap: 7px; min-height: 58px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); color: var(--text-muted); cursor: pointer; font-size: .66rem; }
|
||||
.theme-options button.active { border-color: var(--accent); background: var(--accent-soft); color: var(--accent); }
|
||||
.about-hero { display: flex; align-items: center; gap: 16px; margin-bottom: 20px; padding: 23px; border-radius: var(--radius); background: radial-gradient(circle at 13% 20%, rgba(123,131,255,.28), transparent 28%), linear-gradient(130deg, #171e43, #101526); color: #fff; box-shadow: var(--shadow-lg); }
|
||||
.about-hero > span { width: 58px; height: 58px; display: grid; place-items: center; border-radius: 17px; background: rgba(255,255,255,.1); color: #aeb4ff; }
|
||||
.about-hero h2 { font-size: 1.2rem; }
|
||||
.about-hero p { color: #a7b0c9; font-size: .76rem; }
|
||||
.credits { margin-top: 16px; }
|
||||
|
||||
.empty-state { min-height: 240px; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 30px; text-align: center; }
|
||||
.empty-state__icon { width: 48px; height: 48px; display: grid; place-items: center; margin-bottom: 13px; border-radius: 14px; color: var(--accent); background: var(--accent-soft); }
|
||||
.empty-state h2 { font-size: .92rem; }
|
||||
.empty-state p { max-width: 390px; margin-bottom: 15px; font-size: .72rem; }
|
||||
.skeleton { width: 100%; height: 12px; border-radius: 6px; background: linear-gradient(90deg, var(--surface-muted), var(--border), var(--surface-muted)); background-size: 200% 100%; animation: shimmer 1.2s infinite; }
|
||||
.skeleton--short { width: 36%; }
|
||||
.skeleton--medium { width: 68%; }
|
||||
.skeleton-card { min-height: 140px; display: flex; flex-direction: column; gap: 17px; }
|
||||
@keyframes shimmer { to { background-position: -200% 0; } }
|
||||
|
||||
.dialog { width: min(470px, calc(100vw - 30px)); border: 1px solid var(--border); border-radius: 16px; padding: 23px; background: var(--surface-raised); color: var(--text); box-shadow: var(--shadow-lg); }
|
||||
.dialog::backdrop { background: rgba(5,8,15,.65); backdrop-filter: blur(3px); }
|
||||
.dialog h2 { font-size: 1.05rem; }
|
||||
.dialog p { font-size: .75rem; }
|
||||
.dialog__actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 23px; }
|
||||
.toasts { position: fixed; right: 20px; bottom: 20px; z-index: 100; width: min(370px, calc(100vw - 30px)); display: flex; flex-direction: column; gap: 8px; }
|
||||
.toast { display: grid; grid-template-columns: auto minmax(0,1fr) auto; align-items: center; gap: 9px; padding: 10px 11px; border: 1px solid var(--border); border-radius: 12px; background: var(--surface-raised); box-shadow: var(--shadow-lg); font-size: .7rem; }
|
||||
.toast--success > svg { color: var(--success); }
|
||||
.toast--danger > svg { color: var(--danger); }
|
||||
.toast .icon-button { width: 28px; height: 28px; }
|
||||
|
||||
.setup-shell { min-height: 100vh; display: grid; grid-template-columns: 260px minmax(0,1fr); background: var(--bg); }
|
||||
.setup-sidebar { position: sticky; top: 0; height: 100vh; overflow-y: auto; background: var(--sidebar); color: #fff; }
|
||||
.setup-sidebar ol { margin: 0; padding: 8px 14px 30px; list-style: none; }
|
||||
.setup-sidebar li button { width: 100%; min-height: 47px; display: flex; align-items: center; gap: 10px; border: 0; border-radius: 9px; padding: 5px 10px; background: transparent; color: #78839b; text-align: left; cursor: pointer; }
|
||||
.setup-sidebar li button > span { width: 27px; height: 27px; display: grid; place-items: center; flex: 0 0 auto; border: 1px solid #303a51; border-radius: 8px; }
|
||||
.setup-sidebar li button div { display: flex; flex-direction: column; }
|
||||
.setup-sidebar li small { color: #606b84; font-size: .54rem; }
|
||||
.setup-sidebar li strong { font-size: .69rem; }
|
||||
.setup-sidebar li.active button { color: #fff; background: rgba(111,120,255,.14); }
|
||||
.setup-sidebar li.active button > span { border-color: #767fff; background: #5964f3; }
|
||||
.setup-sidebar li.complete button { color: #9da7bd; }
|
||||
.setup-sidebar li.complete button > span { border-color: rgba(63,208,161,.35); background: rgba(63,208,161,.15); color: #51d8ad; }
|
||||
.setup-main { min-width: 0; display: flex; flex-direction: column; align-items: center; padding: 30px clamp(20px, 5vw, 70px); }
|
||||
.setup-progress { width: min(820px, 100%); display: flex; align-items: center; gap: 13px; margin-bottom: 32px; color: var(--text-muted); font-size: .65rem; }
|
||||
.setup-progress > div { flex: 1; height: 4px; overflow: hidden; border-radius: 999px; background: var(--border); }
|
||||
.setup-progress i { display: block; height: 100%; border-radius: inherit; background: var(--accent); transition: width .3s ease; }
|
||||
.setup-panel { width: min(820px, 100%); margin: auto 0; padding: clamp(24px, 4vw, 48px); border: 1px solid var(--border); border-radius: 22px; background: var(--surface); box-shadow: var(--shadow-lg); }
|
||||
.setup-welcome { text-align: center; }
|
||||
.setup-welcome > p, .setup-panel > p { max-width: 610px; margin: 0 auto 23px; font-size: .82rem; }
|
||||
.setup-hero-icon { width: 65px; height: 65px; display: grid; place-items: center; margin: 0 auto 17px; border-radius: 19px; color: #fff; background: linear-gradient(145deg, #747cff, #4d58e9); box-shadow: 0 12px 30px rgba(89,100,243,.32); }
|
||||
.setup-welcome .badge, .setup-panel > .badge { margin: 0 auto 15px; }
|
||||
.setup-welcome h1, .setup-panel > h1 { text-align: center; }
|
||||
.setup-promises { display: grid; grid-template-columns: repeat(3, 1fr); gap: 9px; margin: 26px 0; text-align: left; }
|
||||
.setup-promises > div { display: flex; align-items: flex-start; gap: 9px; padding: 11px; border: 1px solid var(--border); border-radius: 11px; }
|
||||
.setup-promises svg { flex: 0 0 auto; color: var(--accent); }
|
||||
.setup-promises span { display: flex; flex-direction: column; color: var(--text-muted); font-size: .59rem; }
|
||||
.setup-promises strong { color: var(--text); font-size: .65rem; }
|
||||
.setup-title { display: flex; align-items: center; gap: 14px; margin-bottom: 26px; }
|
||||
.setup-title > span { width: 47px; height: 47px; display: grid; place-items: center; border-radius: 13px; color: var(--accent); background: var(--accent-soft); }
|
||||
.setup-title h1 { margin-bottom: 4px; font-size: 1.45rem; }
|
||||
.setup-title p { font-size: .7rem; }
|
||||
.setup-content { min-height: 260px; }
|
||||
.setup-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 24px; padding-top: 18px; border-top: 1px solid var(--border); }
|
||||
.setup-actions .button:first-child { margin-right: auto; }
|
||||
.setup-health { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 35px; text-align: center; }
|
||||
.setup-health p { max-width: 480px; font-size: .7rem; }
|
||||
.choice-card { position: relative; display: grid; grid-template-columns: auto auto minmax(0,1fr); align-items: center; gap: 12px; margin-bottom: 10px; padding: 14px; border: 1px solid var(--border); border-radius: 12px; cursor: pointer; }
|
||||
.choice-card:has(input:checked) { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.choice-card > span { width: 38px; height: 38px; display: grid; place-items: center; border-radius: 10px; color: var(--accent); background: var(--surface); }
|
||||
.choice-card input { accent-color: var(--accent); }
|
||||
.choice-card strong { font-size: .72rem; }
|
||||
.choice-card p { font-size: .62rem; }
|
||||
.status-list { display: flex; flex-direction: column; }
|
||||
.status-list > div { display: flex; align-items: center; gap: 11px; padding: 11px 0; border-bottom: 1px solid var(--border); }
|
||||
.status-list > div:last-child { border: 0; }
|
||||
.status-list > div > span { width: 26px; height: 26px; display: grid; place-items: center; border-radius: 8px; font-size: .65rem; font-weight: 800; }
|
||||
.status-list .ok { color: var(--success); background: var(--success-soft); }
|
||||
.status-list .bad { color: var(--danger); background: var(--danger-soft); }
|
||||
.status-list strong { font-size: .68rem; overflow-wrap: anywhere; }
|
||||
.status-list p { font-size: .6rem; }
|
||||
.recovery { display: flex; align-items: flex-start; gap: 10px; margin-bottom: 8px; padding: 11px; border-radius: 10px; background: var(--warning-soft); color: var(--warning); }
|
||||
.recovery svg { flex: 0 0 auto; }
|
||||
.recovery strong { font-size: .67rem; text-transform: capitalize; }
|
||||
.recovery p { color: color-mix(in srgb, var(--warning) 68%, var(--text)); font-size: .61rem; }
|
||||
.report-summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin: 25px 0; }
|
||||
.report-summary div { display: flex; flex-direction: column; align-items: center; padding: 13px 8px; border: 1px solid var(--border); border-radius: 11px; }
|
||||
.report-summary strong { font-size: 1rem; }
|
||||
.report-summary span { color: var(--text-muted); font-size: .57rem; }
|
||||
.app-loading { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 13px; }
|
||||
.app-loading p { font-size: .75rem; }
|
||||
.login-shell { min-height: 100vh; display: grid; place-items: center; padding: 24px; background: radial-gradient(circle at top, var(--accent-soft), var(--bg) 44%); }
|
||||
.login-panel { width: min(100%, 430px); display: grid; gap: 24px; padding: 36px; border: 1px solid var(--border); border-radius: 22px; background: var(--surface-raised); box-shadow: var(--shadow-lg); }
|
||||
.login-panel form { display: grid; gap: 10px; }
|
||||
.login-panel label { font-size: .8rem; font-weight: 700; }
|
||||
.login-panel input { width: 100%; padding: 12px 13px; color: var(--text); border: 1px solid var(--border-strong); border-radius: 10px; background: var(--surface); }
|
||||
.login-panel .button { margin-top: 6px; }
|
||||
.login-error { color: var(--danger); font-size: .78rem; }
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.grid--stats, .grid--health { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.grid--devices, .grid--scenes { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.automation-row__meta { flex-wrap: wrap; justify-content: flex-end; }
|
||||
.argb-zone-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.app-shell, .app-shell--collapsed { display: block; }
|
||||
.sidebar, .app-shell--collapsed .sidebar { width: 255px; transform: translateX(-100%); box-shadow: var(--shadow-lg); }
|
||||
.sidebar--open { transform: translateX(0); }
|
||||
.app-shell--collapsed .brand__text, .app-shell--collapsed .nav-label, .app-shell--collapsed .nav-link span, .app-shell--collapsed .sidebar__collapse span { display: initial; }
|
||||
.app-shell--collapsed .nav-link { justify-content: flex-start; padding: 0 11px; }
|
||||
.sidebar__collapse { display: none; }
|
||||
.sidebar__close { display: inline-grid !important; }
|
||||
.sidebar-backdrop { position: fixed; inset: 0; z-index: 35; border: 0; background: rgba(3,6,13,.62); backdrop-filter: blur(2px); }
|
||||
.mobile-menu { display: inline-grid !important; }
|
||||
.topbar__status { margin-left: 0; }
|
||||
.topbar .icon-button:last-child { margin-left: auto; }
|
||||
.dashboard-grid, .detail-grid, .settings-grid { grid-template-columns: 1fr; }
|
||||
.grid--cards { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.argb-zone-list { grid-template-columns: 1fr; }
|
||||
.setup-shell { display: block; }
|
||||
.setup-sidebar { position: static; height: auto; overflow: visible; }
|
||||
.setup-sidebar ol { display: none; }
|
||||
.setup-main { min-height: calc(100vh - 72px); }
|
||||
}
|
||||
|
||||
@media (max-width: 650px) {
|
||||
.page { padding: 22px 15px 35px; }
|
||||
.topbar { padding: 0 15px; }
|
||||
.topbar__device-count { display: none; }
|
||||
.page-header { flex-direction: column; margin-bottom: 20px; }
|
||||
.page-header__actions { width: 100%; justify-content: flex-start; }
|
||||
.page-header__actions .button { flex: 1; }
|
||||
.grid--stats, .grid--cards, .grid--devices, .grid--scenes, .grid--connectors, .grid--health { grid-template-columns: 1fr; }
|
||||
.toolbar { align-items: stretch; flex-wrap: wrap; }
|
||||
.search { flex-basis: 100%; max-width: none; }
|
||||
.segmented { flex: 1; }
|
||||
.segmented button { flex: 1; }
|
||||
.color-control { grid-template-columns: 48px minmax(0,1fr); }
|
||||
.color-control .button { grid-column: 1 / -1; }
|
||||
.device-row { grid-template-columns: 64px minmax(0,1fr); }
|
||||
.device-row .device-card__content { display: block; }
|
||||
.device-row .device-card__meta { margin-top: 7px; }
|
||||
.device-row__extra { display: none; }
|
||||
.detail-status > span:last-child { width: 100%; margin-left: 0; }
|
||||
.form-grid, .form-grid--two, .form-grid--three, .check-grid, .scene-target-form { grid-template-columns: 1fr; }
|
||||
.automation-row { align-items: flex-start; flex-wrap: wrap; }
|
||||
.automation-row__main { display: block; }
|
||||
.automation-row__meta { justify-content: flex-start; margin-top: 9px; }
|
||||
.automation-row__actions { width: 100%; justify-content: flex-end; }
|
||||
.run-row { grid-template-columns: 80px 1fr; gap: 7px; padding: 8px 0; }
|
||||
.run-row__error { grid-column: 1 / -1; }
|
||||
.backup-row { grid-template-columns: auto minmax(0,1fr); }
|
||||
.backup-row > .badge, .backup-row > .button { grid-column: 2; justify-self: start; }
|
||||
.event-row { grid-template-columns: auto minmax(0,1fr); padding: 8px 0; }
|
||||
.event-row > div:last-child { grid-column: 2; align-items: flex-start; flex-direction: row; flex-wrap: wrap; }
|
||||
.setup-main { padding: 20px 12px; }
|
||||
.setup-panel { padding: 22px 17px; border-radius: 17px; }
|
||||
.setup-progress { margin-bottom: 18px; }
|
||||
.setup-promises, .report-summary { grid-template-columns: 1fr 1fr; }
|
||||
.setup-actions { flex-wrap: wrap; }
|
||||
.setup-actions .button:first-child { margin-right: 0; }
|
||||
.setup-actions .button { flex: 1; }
|
||||
.mock-banner { justify-content: flex-start; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { render, type RenderResult } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import type { ReactElement } from "react";
|
||||
import { ToastProvider } from "../components/Toast";
|
||||
import { I18nProvider } from "../lib/i18n";
|
||||
import { ThemeProvider } from "../lib/theme";
|
||||
|
||||
export function renderApp(element: ReactElement, route = "/"): RenderResult {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
<I18nProvider>
|
||||
<ToastProvider>
|
||||
<MemoryRouter initialEntries={[route]}>{element}</MemoryRouter>
|
||||
</ToastProvider>
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
export function jsonResponse(value: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", "X-Request-ID": "test-request" },
|
||||
});
|
||||
}
|
||||
|
||||
export function requestUrl(input: RequestInfo | URL): string {
|
||||
if (typeof input === "string") return input;
|
||||
return input instanceof URL ? input.href : input.url;
|
||||
}
|
||||
|
||||
export function requestJson(init?: RequestInit): unknown {
|
||||
return typeof init?.body === "string" ? JSON.parse(init.body) as unknown : null;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach, beforeEach, vi } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: vi.fn(),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(window.matchMedia).mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
class EventSourceStub {
|
||||
addEventListener = vi.fn();
|
||||
close = vi.fn();
|
||||
onerror: (() => void) | null = null;
|
||||
constructor(public readonly url: string) {}
|
||||
}
|
||||
|
||||
vi.stubGlobal("EventSource", EventSourceStub);
|
||||
vi.stubGlobal("crypto", { randomUUID: () => "00000000-0000-4000-8000-000000000001" });
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts", "eslint.config.js"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": "http://127.0.0.1:8080",
|
||||
"/health": "http://127.0.0.1:8080",
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/test/setup.ts",
|
||||
css: true,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user