diff --git a/.github/workflows/release-macos.yml b/.github/workflows/release-macos.yml index b6635cd..f2a0d5b 100644 --- a/.github/workflows/release-macos.yml +++ b/.github/workflows/release-macos.yml @@ -65,6 +65,9 @@ jobs: APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_ENTITLEMENTS_PATH: ${{ github.workspace }}/apps/desktop/src-tauri/entitlements.plist + VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }} + VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }} + VITE_CAPSULE_URL: ${{ secrets.VITE_CAPSULE_URL }} working-directory: apps/desktop run: npm run tauri -- build --target aarch64-apple-darwin diff --git a/apps/client/public/font/Inter.ttf b/apps/client/public/font/Inter.ttf new file mode 100644 index 0000000..e31b51e Binary files /dev/null and b/apps/client/public/font/Inter.ttf differ diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index 5b769fc..a88bf0a 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -5,17 +5,12 @@ import { DashboardSwipeLayout, DashboardSwipeRoutePlaceholder, } from "./features/dashboard-swipe/DashboardSwipeLayout"; -import { ServersPage } from "./pages/servers"; -import { KeyPage } from "./pages/key"; import { DevicePage } from "./pages/devices"; import { FlowPage } from "./pages/flow"; import { AuthInterceptor } from "./features/auth/AuthInterceptor"; import { NotFound } from "./pages/notfound"; import LandingPage from "./pages/landing"; -import { UsersPage } from "./pages/users"; -import { LogPage } from "./pages/log"; import { MapPage } from "./pages/map"; -import { IntegrationPage } from "./pages/integration"; import { SetupPage } from "./pages/setup"; import { CodePage } from "./pages/code"; import { AuthenticatedLayout } from "./widgets/auth/AuthenticatedLayout"; @@ -23,7 +18,13 @@ import { TopBarWrapper } from "./widgets/auth/TopBarWrapper"; import { useDesktopSidecar } from "./hooks/useDesktopSidecar"; import { usePreventBackNavigation } from "./hooks/usePreventBackNavigation"; import { SettingsPage } from "./pages/settings"; -import { NetworksPage } from "./pages/networks"; +import { AccountSettingsPage } from "./pages/settings/account"; +import { ServicesSettingsPage } from "./pages/settings/services"; +import { UsersSettingsPage } from "./pages/settings/users"; +import { NetworksSettingsPage } from "./pages/settings/networks"; +import { IntegrationSettingsPage } from "./pages/settings/integration"; +import { LogSettingsPage } from "./pages/settings/log"; +import { ConfigSettingsPage } from "./pages/settings/config"; import { RecordingsPage } from "./pages/recordings"; import { DesktopSettingsPage } from "./pages/desktop-settings"; @@ -69,99 +70,106 @@ const router = createBrowserRouter([ ], }, { - path: "/servers", + path: "/devices", element: ( - + ), }, { - path: "/key", + path: "/flow", element: ( - + ), }, { - path: "/devices", + path: "/map", element: ( - + ), }, { - path: "/flow", + path: "/setup", element: ( - + ), }, { - path: "/users", + path: "/settings", element: ( - + ), }, { - path: "/log", + path: "/settings/account", element: ( - + ), }, { - path: "/map", + path: "/settings/services", element: ( - + ), }, { - path: "/integration", + path: "/settings/users", element: ( - + ), }, { - path: "/setup", + path: "/settings/networks", element: ( - + ), }, { - path: "/settings", + path: "/settings/integration", element: ( - + ), }, - { - path: "/code", + path: "/settings/log", element: ( - + ), }, { - path: "/networks", + path: "/settings/config", element: ( - + + + ), + }, + { + path: "/code", + element: ( + + ), }, diff --git a/apps/client/src/entities/entity/api.ts b/apps/client/src/entities/entity/api.ts index 59e7dfe..7fbdb0c 100644 --- a/apps/client/src/entities/entity/api.ts +++ b/apps/client/src/entities/entity/api.ts @@ -1,8 +1,12 @@ import { apiClient } from "@/shared/api"; -import type { Entity, EntityAll, EntityPayload } from "./types"; +import type { Entity, EntityAll, EntityPayload, State } from "./types"; export const getEntities = () => apiClient.get("/entities"); export const getAllEntities = () => apiClient.get("/entities/all"); +export const getEntityHistory = (entityId: string) => + apiClient.get( + `/entities/${encodeURIComponent(entityId)}/history`, + ); export const getEntitiesFilter = (entityType?: string) => { return apiClient.get("/entities", { params: { diff --git a/apps/client/src/features/auth/index.tsx b/apps/client/src/features/auth/index.tsx index 8db9216..f7c523a 100644 --- a/apps/client/src/features/auth/index.tsx +++ b/apps/client/src/features/auth/index.tsx @@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useNavigate } from "react-router"; -import { Loader2, Trash2 } from "lucide-react"; +import { ArrowRight, Loader2, Trash2 } from "lucide-react"; import { DEMO_SERVER_URL, DEMO_TOKEN, isDemoMode } from "@/shared/demo"; import { DefaultAdminPasswordDialog } from "./DefaultAdminPasswordDialog"; import { authenticateWithPassword } from "./api"; @@ -291,15 +291,31 @@ export function LoginForm({ ) : !showAuthFields ? (
{/* */} - setUrl(e.target.value)} - disabled={isLoading} - /> +
+ setUrl(e.target.value)} + disabled={isLoading} + className='flex-1' + /> + +
) : ( <> @@ -328,15 +344,12 @@ export function LoginForm({ )} - {!(isTauriClient && (isResolvingDesktop || desktopError)) && ( - - )} + {!(isTauriClient && (isResolvingDesktop || desktopError)) && + showAuthFields && ( + + )} {!isTauriClient && recentUrls.length > 0 && !showAuthFields && ( diff --git a/apps/client/src/features/device-token/DeviceTokenManager.tsx b/apps/client/src/features/device-token/DeviceTokenManager.tsx index 9914c4f..6412ae9 100644 --- a/apps/client/src/features/device-token/DeviceTokenManager.tsx +++ b/apps/client/src/features/device-token/DeviceTokenManager.tsx @@ -1,13 +1,6 @@ import { useEffect, useState } from "react"; import { Copy, Check, Key, AlertTriangle, RefreshCw } from "lucide-react"; import { Button } from "@/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; import { Dialog, DialogContent, @@ -61,78 +54,70 @@ export function DeviceTokenManager({ deviceId }: Props) { return ( <> - - - Access Token - - Manage the permanent access token for this device. - - - - {tokenInfo ? ( -
-
-

Token is active

-

- Created at: {new Date(tokenInfo.created_at).toLocaleString()} -

-
-
- - - - - - - - Are you sure? - - This will permanently revoke the access token. The - device will no longer be able to connect. - - - - Cancel - revokeToken(deviceId)}> - Continue - - - - -
+
+ {tokenInfo ? ( +
+
+

Token is active

+

+ Created at: {new Date(tokenInfo.created_at).toLocaleString()} +

- ) : ( -
-
-

- No active token -

-

- Issue a new token to allow this device to connect. -

-
- + + + + + + + Are you sure? + + This will permanently revoke the access token. The + device will no longer be able to connect. + + + + Cancel + revokeToken(deviceId)}> + Continue + + + + +
+
+ ) : ( +
+
+

+ No active token +

+

+ Issue a new token to allow this device to connect. +

- )} - - + +
+ )} +
clearNewToken()}> diff --git a/apps/client/src/features/device/DeviceKeyButton.tsx b/apps/client/src/features/device/DeviceKeyButton.tsx new file mode 100644 index 0000000..f30b549 --- /dev/null +++ b/apps/client/src/features/device/DeviceKeyButton.tsx @@ -0,0 +1,40 @@ +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Key } from "lucide-react"; +import { DropdownMenuItem } from "@/components/ui/dropdown-menu"; +import { DeviceTokenManager } from "@/features/device-token/DeviceTokenManager"; + +interface Props { + deviceId: number; +} + +export function DeviceKeyButton({ deviceId }: Props) { + const [isOpen, setIsOpen] = useState(false); + + return ( + + + e.preventDefault()}> + + Key + + + + + Access Token + + Manage the permanent access token for this device. + + + + + + ); +} diff --git a/apps/client/src/features/entity/Card.tsx b/apps/client/src/features/entity/Card.tsx index 7562737..64ec3e4 100644 --- a/apps/client/src/features/entity/Card.tsx +++ b/apps/client/src/features/entity/Card.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { EntityAll } from "@/entities/entity/types"; import { Card, @@ -17,6 +18,7 @@ import { formatSimpleDateTime } from "@/lib/time"; import { StreamReceiver } from "../rtc/StreamReceiver"; import { RecordingMenuItem } from "../recording/RecordingButton"; import { AnalyzeMenuItem } from "./AnalyzeMenuItem"; +import { StateHistorySheet } from "./StateHistorySheet"; import { useNavigate } from "react-router"; type StreamState = { @@ -219,21 +221,41 @@ export function EntityCard({ ); } + return ; +} + +function DefaultEntityCard({ item }: { item: EntityAll }) { + const [historyOpen, setHistoryOpen] = useState(false); + return ( - - - {item.friendly_name} - - {item.state?.state || "N/A"} - - - -
{item.platform}
-
- {formatSimpleDateTime(item.state?.last_updated || "")} -
-
-
+ <> + + + {item.friendly_name} + + + +
{item.platform}
+
+ {formatSimpleDateTime(item.state?.last_updated || "")} +
+
+
+ + ); } diff --git a/apps/client/src/features/entity/StateHistorySheet.tsx b/apps/client/src/features/entity/StateHistorySheet.tsx new file mode 100644 index 0000000..b644eed --- /dev/null +++ b/apps/client/src/features/entity/StateHistorySheet.tsx @@ -0,0 +1,248 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { scaleTime } from "d3-scale"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Skeleton } from "@/components/ui/skeleton"; +import { formatSimpleDateTime } from "@/lib/time"; +import * as api from "@/entities/entity/api"; +import type { State } from "@/entities/entity/types"; + +type Props = { + entityId: string; + entityName: string; + open: boolean; + onOpenChange: (open: boolean) => void; +}; + +const STRIP_WIDTH = 360; +const STRIP_HEIGHT = 80; +const STRIP_PADDING_X = 16; +const AXIS_Y = STRIP_HEIGHT - 24; + +export function StateHistorySheet({ + entityId, + entityName, + open, + onOpenChange, +}: Props) { + const [history, setHistory] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [now, setNow] = useState(() => new Date()); + const requestId = useRef(0); + + useEffect(() => { + if (!open) { + return; + } + const id = ++requestId.current; + setLoading(true); + setError(null); + setNow(new Date()); + + api + .getEntityHistory(entityId) + .then((res) => { + if (id !== requestId.current) return; + setHistory(res.data); + setLoading(false); + }) + .catch((err) => { + if (id !== requestId.current) return; + console.error("Failed to fetch entity history:", err); + setError("Failed to load history."); + setLoading(false); + }); + }, [open, entityId]); + + const sortedAsc = useMemo(() => { + if (!history) return []; + return [...history].sort( + (a, b) => + new Date(a.last_updated).getTime() - + new Date(b.last_updated).getTime(), + ); + }, [history]); + + const sortedDesc = useMemo(() => [...sortedAsc].reverse(), [sortedAsc]); + + const firstRecord = sortedAsc[0]; + const lastRecord = sortedAsc[sortedAsc.length - 1]; + + const xScale = useMemo(() => { + if (!firstRecord) return null; + const start = new Date(firstRecord.last_updated); + const end = now > new Date(lastRecord.last_updated) ? now : new Date(lastRecord.last_updated); + return scaleTime() + .domain([start, end]) + .range([STRIP_PADDING_X, STRIP_WIDTH - STRIP_PADDING_X]); + }, [firstRecord, lastRecord, now]); + + return ( + + + + {entityName || entityId} + + {entityId} + + + +
+ {loading && ( +
+ + + + +
+ )} + + {!loading && error && ( +
{error}
+ )} + + {!loading && !error && sortedAsc.length === 0 && ( +
+ No state history recorded yet. +
+ )} + + {!loading && !error && sortedAsc.length > 0 && xScale && ( + <> +
+ + + +
+ + + + + + first + + + now + + {sortedAsc.map((record) => { + const cx = xScale(new Date(record.last_updated)); + return ( + + + + + +
+
+ {formatSimpleDateTime(record.last_updated)} +
+
+ {record.state ?? "—"} +
+
+ + + ); + })} + {(() => { + const lastX = xScale(new Date(lastRecord!.last_updated)); + const nowX = xScale(now); + if (nowX > lastX + 6) { + return ( + + ); + } + return null; + })()} + +
+ + +
    + {sortedDesc.map((record) => ( +
  • + + {formatSimpleDateTime(record.last_updated)} + + + {record.state ?? "—"} + +
  • + ))} +
+
+ + )} +
+
+
+ ); +} + +function SummaryCell({ label, value }: { label: string; value: string }) { + return ( +
+ + {label} + + {value} +
+ ); +} diff --git a/apps/client/src/features/ros2/Ros2Dashboard.tsx b/apps/client/src/features/ros2/Ros2Dashboard.tsx index 0e1d77a..5417547 100644 --- a/apps/client/src/features/ros2/Ros2Dashboard.tsx +++ b/apps/client/src/features/ros2/Ros2Dashboard.tsx @@ -18,7 +18,7 @@ export function Ros2Dashboard() { Open flow diff --git a/apps/client/src/features/setup/index.tsx b/apps/client/src/features/setup/index.tsx index e11e43b..ff77edc 100644 --- a/apps/client/src/features/setup/index.tsx +++ b/apps/client/src/features/setup/index.tsx @@ -18,7 +18,7 @@ export const initialSetupSteps: SetupStep[] = [ title: "Enable MQTT & UDP", description: "Enable basic protocols", isCompleted: false, - url: "/servers", + url: "/settings/config", verifyStatus: async () => { try { const status = await getIntegrationStatus(); @@ -48,7 +48,7 @@ export const initialSetupSteps: SetupStep[] = [ title: "Add Sensor", description: "Add a sensor (entity) for the device.", isCompleted: false, - url: "/key", + url: "/devices", verifyStatus: async () => { try { const entity = await getAllEntities(); diff --git a/apps/client/src/features/sidebar/index.tsx b/apps/client/src/features/sidebar/index.tsx index 0383f95..2cf1be2 100644 --- a/apps/client/src/features/sidebar/index.tsx +++ b/apps/client/src/features/sidebar/index.tsx @@ -17,18 +17,12 @@ import { import { AccountSwitcher } from "../account-switcher"; import { useLocation, useNavigate } from "react-router"; import { - Key, LayoutDashboard, MonitorSmartphone, - ScrollText, - Server, - UserCog, Workflow, Map, - Blocks, CircleDashed, Code, - Network, RadioTower, ChevronDown, ChevronRight, @@ -90,11 +84,6 @@ const data = { url: "/recordings", icon: