diff --git a/.gitignore b/.gitignore index f659601..59dc9f7 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,13 @@ shock.code-workspace devel/lo.data shock-server/shock-server shock-server/shock-server + +# Node.js +node_modules/ + +# UI build outputs (embedded copy) +shock-server/ui/dist/ +!shock-server/ui/dist/.gitkeep +clients/shock-ts/dist/ +clients/shock-ui/dist/ +*.tsbuildinfo diff --git a/Dockerfile b/Dockerfile index 3a735d9..1d24b23 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,13 @@ # This allows the shock-server to bind to port 80 if desired. #setcap 'cap_net_bind_service=+ep' bin/shock-server +FROM node:20-alpine AS ui-builder +WORKDIR /build/clients +COPY clients/ . +RUN npm install && \ + cd shock-ts && npm run build && \ + cd ../shock-ui && npm run build + FROM golang:alpine ENV PYTHONUNBUFFERED=1 @@ -19,6 +26,9 @@ WORKDIR /go/bin COPY . /go/src/github.com/MG-RAST/Shock +# Copy built UI into the Go embed directory +COPY --from=ui-builder /build/clients/shock-ui/dist ${DIR}/shock-server/ui/dist + RUN mkdir -p /var/log/shock /usr/local/shock/data ${DIR} # set version diff --git a/build-ui.sh b/build-ui.sh new file mode 100755 index 0000000..d4feabe --- /dev/null +++ b/build-ui.sh @@ -0,0 +1,25 @@ +#!/bin/sh +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CLIENTS_DIR="$SCRIPT_DIR/clients" +UI_DIR="$CLIENTS_DIR/shock-ui" +EMBED_DIR="$SCRIPT_DIR/shock-server/ui/dist" + +echo "==> Installing dependencies..." +cd "$CLIENTS_DIR" +npm install + +echo "==> Building shock-ts..." +cd "$CLIENTS_DIR/shock-ts" +npm run build + +echo "==> Building shock-ui..." +cd "$UI_DIR" +npm run build + +echo "==> Copying dist to shock-server/ui/dist..." +rm -rf "$EMBED_DIR" +cp -r "$UI_DIR/dist" "$EMBED_DIR" + +echo "==> UI build complete. Run ./compile-server.sh to embed in binary." diff --git a/clients/package.json b/clients/package.json new file mode 100644 index 0000000..9fdff5d --- /dev/null +++ b/clients/package.json @@ -0,0 +1,7 @@ +{ + "private": true, + "workspaces": [ + "shock-ts", + "shock-ui" + ] +} diff --git a/clients/shock-ts/src/client.ts b/clients/shock-ts/src/client.ts index 2165b14..d15cc64 100644 --- a/clients/shock-ts/src/client.ts +++ b/clients/shock-ts/src/client.ts @@ -3,6 +3,12 @@ import type { DisplayAcl, AclType, DownloadOptions, + LocationInfo, + LocationNodeList, + LockedFiles, + LockedIndexes, + LockedNodes, + LockerState, NodeListQuery, NodeLocation, PaginatedResult, @@ -23,12 +29,14 @@ export class ShockClient { private baseUrl: string; private staticToken?: string; private getTokenFn?: () => string | undefined; + private authType: "basic" | "oauth"; constructor(options: ShockClientOptions) { // Strip trailing slash this.baseUrl = options.url.replace(/\/+$/, ""); this.staticToken = options.token; this.getTokenFn = options.getToken; + this.authType = options.authType ?? "oauth"; } /** Update the static auth token. */ @@ -207,6 +215,92 @@ export class ShockClient { return this.request("GET", `/node/${nodeId}/locations/`); } + // ─── Index Management ──────────────────────────────────────── + + /** `DELETE /node/{id}/index/{type}` — delete an index. */ + async deleteIndex(nodeId: string, indexType: string): Promise { + this.validateId(nodeId); + await this.request("DELETE", `/node/${nodeId}/index/${indexType}`); + } + + // ─── Admin: Location Info ──────────────────────────────────── + + /** `GET /location/{loc}/info` — get location configuration info (admin only). */ + async getLocationInfo(locId: string): Promise { + return this.request("GET", `/location/${locId}/info`); + } + + /** `GET /location/{loc}/missing` — get nodes missing from a location (admin only). */ + async getLocationMissing(locId: string): Promise { + return this.request("GET", `/location/${locId}/missing`); + } + + /** `GET /location/{loc}/present` — get nodes present in a location (admin only). */ + async getLocationPresent(locId: string): Promise { + return this.request("GET", `/location/${locId}/present`); + } + + // ─── Admin: Locker ─────────────────────────────────────────── + + /** `GET /locker` — get all lock manager states (admin check). */ + async getLocker(): Promise { + return this.request("GET", "/locker"); + } + + /** `GET /locked/node` — get locked node IDs. */ + async getLockedNodes(): Promise { + return this.request("GET", "/locked/node"); + } + + /** `GET /locked/file` — get locked file IDs. */ + async getLockedFiles(): Promise { + return this.request("GET", "/locked/file"); + } + + /** `GET /locked/index` — get locked index IDs. */ + async getLockedIndexes(): Promise { + return this.request("GET", "/locked/index"); + } + + // ─── Admin: Trace ──────────────────────────────────────────── + + /** `GET /trace/start` — start execution trace (admin only). */ + async startTrace(): Promise { + return this.request("GET", "/trace/start"); + } + + /** `GET /trace/stop` — stop execution trace (admin only). */ + async stopTrace(): Promise { + return this.request("GET", "/trace/stop"); + } + + /** `GET /trace/download` — download the latest trace file as a Blob (admin only). */ + async downloadTrace(): Promise { + const response = await this.rawFetch("GET", "/trace/download"); + if (!response.ok) { + await this.throwFromResponse(response); + } + return response.blob(); + } + + /** `GET /trace/summary` — get trace footprint summary as text (admin only). */ + async getTraceSummary(): Promise { + const response = await this.rawFetch("GET", "/trace/summary"); + if (!response.ok) { + await this.throwFromResponse(response); + } + return response.text(); + } + + /** `GET /trace/events` — get parsed trace events as text (admin only). */ + async getTraceEvents(): Promise { + const response = await this.rawFetch("GET", "/trace/events"); + if (!response.ok) { + await this.throwFromResponse(response); + } + return response.text(); + } + // ─── Polling ─────────────────────────────────────────────────── /** @@ -245,7 +339,8 @@ export class ShockClient { private authHeaders(): Record { const token = this.resolveToken(); if (token) { - return { Authorization: `OAuth ${token}` }; + const prefix = this.authType === "basic" ? "Basic" : "OAuth"; + return { Authorization: `${prefix} ${token}` }; } return {}; } @@ -422,7 +517,8 @@ export class ShockClient { const token = this.resolveToken(); if (token) { - xhr.setRequestHeader("Authorization", `OAuth ${token}`); + const prefix = this.authType === "basic" ? "Basic" : "OAuth"; + xhr.setRequestHeader("Authorization", `${prefix} ${token}`); } xhr.upload.addEventListener("progress", (e) => { diff --git a/clients/shock-ts/src/index.ts b/clients/shock-ts/src/index.ts index 361ee38..e50b26f 100644 --- a/clients/shock-ts/src/index.ts +++ b/clients/shock-ts/src/index.ts @@ -30,4 +30,10 @@ export type { ChunkedUploadOptions, ChunkedUploadResult, SmartUploadOptions, + LockerState, + LockedNodes, + LockedFiles, + LockedIndexes, + LocationInfo, + LocationNodeList, } from "./types.js"; diff --git a/clients/shock-ts/src/react/hooks.ts b/clients/shock-ts/src/react/hooks.ts index 5bfec9b..6e95b25 100644 --- a/clients/shock-ts/src/react/hooks.ts +++ b/clients/shock-ts/src/react/hooks.ts @@ -9,6 +9,12 @@ import { useShockClient } from "./provider.js"; import type { DisplayAcl, AclType, + LocationInfo, + LocationNodeList, + LockedFiles, + LockedIndexes, + LockedNodes, + LockerState, NodeListQuery, PaginatedResult, ShockNode, @@ -24,6 +30,15 @@ export const shockKeys = { nodes: (query?: NodeListQuery) => ["shock", "nodes", query] as const, node: (id: string | undefined) => ["shock", "node", id] as const, acl: (id: string | undefined) => ["shock", "node", id, "acl"] as const, + locker: () => ["shock", "locker"] as const, + lockedNodes: () => ["shock", "locked", "nodes"] as const, + lockedFiles: () => ["shock", "locked", "files"] as const, + lockedIndexes: () => ["shock", "locked", "indexes"] as const, + locationInfo: (locId: string) => ["shock", "location", locId, "info"] as const, + locationMissing: (locId: string) => ["shock", "location", locId, "missing"] as const, + locationPresent: (locId: string) => ["shock", "location", locId, "present"] as const, + traceSummary: () => ["shock", "trace", "summary"] as const, + traceEvents: () => ["shock", "trace", "events"] as const, }; // ─── Queries ───────────────────────────────────────────────────── @@ -137,3 +152,163 @@ export function useRemoveAcl( }, }); } + +// ─── Index Mutations ────────────────────────────────────────── + +export function useCreateIndex( + nodeId: string +): UseMutationResult { + const client = useShockClient(); + const qc = useQueryClient(); + + return useMutation({ + mutationFn: (indexType: string) => client.createIndex(nodeId, indexType), + onSuccess: () => { + qc.invalidateQueries({ queryKey: shockKeys.node(nodeId) }); + }, + }); +} + +export function useDeleteIndex( + nodeId: string +): UseMutationResult { + const client = useShockClient(); + const qc = useQueryClient(); + + return useMutation({ + mutationFn: (indexType: string) => client.deleteIndex(nodeId, indexType), + onSuccess: () => { + qc.invalidateQueries({ queryKey: shockKeys.node(nodeId) }); + }, + }); +} + +// ─── Admin Queries ──────────────────────────────────────────── + +export function useLocker(): UseQueryResult { + const client = useShockClient(); + return useQuery({ + queryKey: shockKeys.locker(), + queryFn: () => client.getLocker(), + staleTime: 10_000, + }); +} + +export function useLockedNodes(): UseQueryResult { + const client = useShockClient(); + return useQuery({ + queryKey: shockKeys.lockedNodes(), + queryFn: () => client.getLockedNodes(), + staleTime: 10_000, + }); +} + +export function useLockedFiles(): UseQueryResult { + const client = useShockClient(); + return useQuery({ + queryKey: shockKeys.lockedFiles(), + queryFn: () => client.getLockedFiles(), + staleTime: 10_000, + }); +} + +export function useLockedIndexes(): UseQueryResult { + const client = useShockClient(); + return useQuery({ + queryKey: shockKeys.lockedIndexes(), + queryFn: () => client.getLockedIndexes(), + staleTime: 10_000, + }); +} + +export function useLocationInfo( + locId: string, + enabled = true +): UseQueryResult { + const client = useShockClient(); + return useQuery({ + queryKey: shockKeys.locationInfo(locId), + queryFn: () => client.getLocationInfo(locId), + enabled, + staleTime: 60_000, + }); +} + +export function useLocationMissing( + locId: string, + enabled = true +): UseQueryResult { + const client = useShockClient(); + return useQuery({ + queryKey: shockKeys.locationMissing(locId), + queryFn: () => client.getLocationMissing(locId), + enabled, + staleTime: 30_000, + }); +} + +export function useLocationPresent( + locId: string, + enabled = true +): UseQueryResult { + const client = useShockClient(); + return useQuery({ + queryKey: shockKeys.locationPresent(locId), + queryFn: () => client.getLocationPresent(locId), + enabled, + staleTime: 30_000, + }); +} + +// ─── Admin Mutations ────────────────────────────────────────── + +export function useStartTrace(): UseMutationResult { + const client = useShockClient(); + return useMutation({ + mutationFn: () => client.startTrace(), + }); +} + +export function useStopTrace(): UseMutationResult { + const client = useShockClient(); + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => client.stopTrace(), + onSuccess: () => { + // Invalidate summary/events so they reload after a new trace is captured + qc.invalidateQueries({ queryKey: shockKeys.traceSummary() }); + qc.invalidateQueries({ queryKey: shockKeys.traceEvents() }); + }, + }); +} + +export function useTraceSummary( + enabled = true +): UseQueryResult { + const client = useShockClient(); + return useQuery({ + queryKey: shockKeys.traceSummary(), + queryFn: () => client.getTraceSummary(), + enabled, + staleTime: 60_000, + }); +} + +export function useTraceEvents( + enabled = true +): UseQueryResult { + const client = useShockClient(); + return useQuery({ + queryKey: shockKeys.traceEvents(), + queryFn: () => client.getTraceEvents(), + enabled, + staleTime: 60_000, + }); +} + +export function useDownloadTrace(): UseMutationResult { + const client = useShockClient(); + return useMutation({ + mutationFn: () => client.downloadTrace(), + }); +} diff --git a/clients/shock-ts/src/react/index.ts b/clients/shock-ts/src/react/index.ts index 118d880..922073d 100644 --- a/clients/shock-ts/src/react/index.ts +++ b/clients/shock-ts/src/react/index.ts @@ -11,6 +11,20 @@ export { useUpdateAttributes, useAddAcl, useRemoveAcl, + useCreateIndex, + useDeleteIndex, + useLocker, + useLockedNodes, + useLockedFiles, + useLockedIndexes, + useLocationInfo, + useLocationMissing, + useLocationPresent, + useStartTrace, + useStopTrace, + useTraceSummary, + useTraceEvents, + useDownloadTrace, } from "./hooks.js"; export { useUpload } from "./use-upload.js"; diff --git a/clients/shock-ts/src/react/provider.tsx b/clients/shock-ts/src/react/provider.tsx index dd0fe54..1f89b70 100644 --- a/clients/shock-ts/src/react/provider.tsx +++ b/clients/shock-ts/src/react/provider.tsx @@ -20,6 +20,8 @@ export interface ShockProviderProps { token?: string; /** Dynamic token getter (takes priority over `token`). */ getToken?: () => string | undefined; + /** Auth header prefix: "basic" or "oauth". Defaults to "oauth". */ + authType?: "basic" | "oauth"; /** Optional externally-managed QueryClient. */ queryClient?: QueryClient; children: ReactNode; @@ -35,6 +37,7 @@ export function ShockProvider({ url, token, getToken, + authType, queryClient: externalQc, children, }: ShockProviderProps) { @@ -45,10 +48,10 @@ export function ShockProvider({ } const qc = externalQc ?? internalQcRef.current!; - // Memoize the client on [url, getToken]. Token changes go through setToken. + // Memoize the client on [url, getToken, authType]. Token changes go through setToken. const client = useMemo( - () => new ShockClient({ url, token, getToken }), - [url, getToken] // eslint-disable-line react-hooks/exhaustive-deps + () => new ShockClient({ url, token, getToken, authType }), + [url, getToken, authType] // eslint-disable-line react-hooks/exhaustive-deps ); // Update token without re-creating client diff --git a/clients/shock-ts/src/types.ts b/clients/shock-ts/src/types.ts index 3fb9053..986be1f 100644 --- a/clients/shock-ts/src/types.ts +++ b/clients/shock-ts/src/types.ts @@ -170,8 +170,41 @@ export interface ShockClientOptions { token?: string; /** Dynamic token getter (takes priority over static `token`). */ getToken?: () => string | undefined; + /** Auth header prefix: "basic" → `Basic `, "oauth" → `OAuth `. Defaults to "oauth". */ + authType?: "basic" | "oauth"; } +// ─── Admin Types ────────────────────────────────────────────────── + +/** Response from `GET /locker` — all lock manager states. */ +export interface LockerState { + [key: string]: string[]; +} + +/** Response from `GET /locked/node` — list of locked node IDs. */ +export type LockedNodes = string[]; + +/** Response from `GET /locked/file` — all file locks. */ +export interface LockedFiles { + [key: string]: string[]; +} + +/** Response from `GET /locked/index` — all index locks. */ +export interface LockedIndexes { + [key: string]: string[]; +} + +/** Location info from `GET /location/{loc}/info`. */ +export interface LocationInfo { + id: string; + description?: string; + type?: string; + [key: string]: unknown; +} + +/** Missing/present node list from location endpoints. */ +export type LocationNodeList = string[]; + // ─── Query & Upload Options ──────────────────────────────────────── /** Query parameters for listing nodes. */ diff --git a/clients/shock-ui/.gitignore b/clients/shock-ui/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/clients/shock-ui/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/clients/shock-ui/index.html b/clients/shock-ui/index.html new file mode 100644 index 0000000..44f3523 --- /dev/null +++ b/clients/shock-ui/index.html @@ -0,0 +1,12 @@ + + + + + + Shock + + +
+ + + diff --git a/clients/shock-ui/package.json b/clients/shock-ui/package.json new file mode 100644 index 0000000..80420b0 --- /dev/null +++ b/clients/shock-ui/package.json @@ -0,0 +1,32 @@ +{ + "name": "shock-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "@tanstack/react-query": "^5.62.0", + "shock-client": "*", + "clsx": "^2.1.1", + "tailwind-merge": "^2.6.0", + "lucide-react": "^0.460.0", + "class-variance-authority": "^0.7.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^6.0.0", + "tailwindcss": "^4.0.0", + "@tailwindcss/vite": "^4.0.0", + "autoprefixer": "^10.4.20" + } +} diff --git a/clients/shock-ui/src/App.tsx b/clients/shock-ui/src/App.tsx new file mode 100644 index 0000000..99bfeb1 --- /dev/null +++ b/clients/shock-ui/src/App.tsx @@ -0,0 +1,129 @@ +import { lazy, Suspense } from "react"; +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import { ShockUIProvider } from "@/providers/ShockUIProvider"; +import { ErrorBoundary } from "@/components/ErrorBoundary"; +import { ProtectedRoute, AdminRoute } from "@/components/auth/ProtectedRoute"; +import { AppShell } from "@/components/layout/AppShell"; +import { LoginPage } from "@/pages/LoginPage"; +import { Skeleton } from "@/components/ui/skeleton"; + +// Lazy-load pages for code splitting +const NodesPage = lazy(() => import("@/pages/NodesPage").then((m) => ({ default: m.NodesPage }))); +const NodeDetailPage = lazy(() => import("@/pages/NodeDetailPage").then((m) => ({ default: m.NodeDetailPage }))); +const UploadPage = lazy(() => import("@/pages/UploadPage").then((m) => ({ default: m.UploadPage }))); +const AdminDashboardPage = lazy(() => import("@/pages/AdminDashboardPage").then((m) => ({ default: m.AdminDashboardPage }))); +const AdminLocationsPage = lazy(() => import("@/pages/AdminLocationsPage").then((m) => ({ default: m.AdminLocationsPage }))); +const AdminLockerPage = lazy(() => import("@/pages/AdminLockerPage").then((m) => ({ default: m.AdminLockerPage }))); +const AdminTracePage = lazy(() => import("@/pages/AdminTracePage").then((m) => ({ default: m.AdminTracePage }))); +const SettingsPage = lazy(() => import("@/pages/SettingsPage").then((m) => ({ default: m.SettingsPage }))); + +function PageLoader() { + return ( +
+ + +
+ ); +} + +export function App() { + return ( + + + + + } /> + + + + + } + > + }> + + + } + /> + }> + + + } + /> + }> + + + } + /> + }> + + + } + /> + + {/* Admin routes */} + + }> + + + + } + /> + + }> + + + + } + /> + + }> + + + + } + /> + + }> + + + + } + /> + + + {/* Redirects */} + } /> + } /> + } /> + + + + + ); +} diff --git a/clients/shock-ui/src/components/ErrorBoundary.tsx b/clients/shock-ui/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..e1dfd85 --- /dev/null +++ b/clients/shock-ui/src/components/ErrorBoundary.tsx @@ -0,0 +1,48 @@ +import { Component, type ReactNode, type ErrorInfo } from "react"; +import { Button } from "@/components/ui/button"; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error("UI Error:", error, info); + } + + render() { + if (this.state.hasError) { + return ( +
+

Something went wrong

+

+ {this.state.error?.message ?? "An unexpected error occurred"} +

+ +
+ ); + } + return this.props.children; + } +} diff --git a/clients/shock-ui/src/components/acl/AclPanel.tsx b/clients/shock-ui/src/components/acl/AclPanel.tsx new file mode 100644 index 0000000..69d7da7 --- /dev/null +++ b/clients/shock-ui/src/components/acl/AclPanel.tsx @@ -0,0 +1,121 @@ +import { useNodeAcl, useAddAcl, useRemoveAcl } from "shock-client/react"; +import type { AclType } from "shock-client"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Plus, X, Globe } from "lucide-react"; + +interface AclPanelProps { + nodeId: string; +} + +function AclUserList({ + nodeId, + type, + users, +}: { + nodeId: string; + type: AclType; + users: string[]; +}) { + const addAcl = useAddAcl(nodeId); + const removeAcl = useRemoveAcl(nodeId); + const [newUser, setNewUser] = useState(""); + + const handleAdd = () => { + const trimmed = newUser.trim(); + if (!trimmed) return; + addAcl.mutate({ type, users: [trimmed] }); + setNewUser(""); + }; + + return ( +
+
+ {users.length === 0 && ( + No users + )} + {users.map((user) => ( + + {user} + + + ))} +
+
+ setNewUser(e.target.value)} + placeholder="Add user..." + className="h-8 text-xs" + onKeyDown={(e) => e.key === "Enter" && handleAdd()} + /> + +
+
+ ); +} + +export function AclPanel({ nodeId }: AclPanelProps) { + const { data: acl, isLoading } = useNodeAcl(nodeId); + const addAcl = useAddAcl(nodeId); + const removeAcl = useRemoveAcl(nodeId); + + if (isLoading) { + return
; + } + + if (!acl) return null; + + const togglePublic = (type: "public_read" | "public_write" | "public_delete", current: boolean) => { + if (current) { + removeAcl.mutate({ type, users: [] }); + } else { + addAcl.mutate({ type, users: [] }); + } + }; + + return ( +
+
+

Owner

+ {acl.owner} +
+ +
+

+ Public Access + +

+
+ {(["read", "write", "delete"] as const).map((perm) => ( + + ))} +
+
+ + {(["read", "write", "delete"] as const).map((type) => ( +
+

{type}

+ +
+ ))} +
+ ); +} diff --git a/clients/shock-ui/src/components/admin/Dashboard.tsx b/clients/shock-ui/src/components/admin/Dashboard.tsx new file mode 100644 index 0000000..b3dc578 --- /dev/null +++ b/clients/shock-ui/src/components/admin/Dashboard.tsx @@ -0,0 +1,78 @@ +import { useServerInfo, useLockedNodes, useLockedFiles, useLockedIndexes } from "shock-client/react"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Server, Lock, Clock } from "lucide-react"; + +export function Dashboard() { + const { data: info, isLoading: infoLoading } = useServerInfo(); + const { data: lockedNodes } = useLockedNodes(); + const { data: lockedFiles } = useLockedFiles(); + const { data: lockedIndexes } = useLockedIndexes(); + + if (infoLoading) { + return ( +
+ {[1, 2, 3].map((i) => )} +
+ ); + } + + if (!info) return null; + + return ( +
+
+ + + + Server + + +
+

Version: {info.version}

+

+ Uptime: {info.uptime} +

+

Contact: {info.contact || "—"}

+
+
+
+ + + + + Locked Resources + + +
+

Nodes: {lockedNodes?.length ?? "—"}

+

Files: {lockedFiles ? Object.keys(lockedFiles).length : "—"}

+

Indexes: {lockedIndexes ? Object.keys(lockedIndexes).length : "—"}

+
+
+
+ + + + Configuration + + +
+

Auth: {info.auth && info.auth.length > 0 ? info.auth.join(", ") : "basic"}

+
+ Anon: + {info.anonymous_permissions.read && read} + {info.anonymous_permissions.write && write} + {info.anonymous_permissions.delete && delete} + {!info.anonymous_permissions.read && !info.anonymous_permissions.write && !info.anonymous_permissions.delete && ( + none + )} +
+
+
+
+
+
+ ); +} diff --git a/clients/shock-ui/src/components/admin/LocationBrowser.tsx b/clients/shock-ui/src/components/admin/LocationBrowser.tsx new file mode 100644 index 0000000..9d2720e --- /dev/null +++ b/clients/shock-ui/src/components/admin/LocationBrowser.tsx @@ -0,0 +1,106 @@ +import { useState } from "react"; +import { useLocationInfo, useLocationMissing, useLocationPresent } from "shock-client/react"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Search } from "lucide-react"; + +export function LocationBrowser() { + const [locId, setLocId] = useState(""); + const [activeId, setActiveId] = useState(null); + + const { data: info, isLoading: infoLoading, error: infoError } = useLocationInfo( + activeId ?? "", + Boolean(activeId) + ); + const { data: missing } = useLocationMissing(activeId ?? "", Boolean(activeId)); + const { data: present } = useLocationPresent(activeId ?? "", Boolean(activeId)); + + const handleSearch = () => { + if (locId.trim()) setActiveId(locId.trim()); + }; + + return ( +
+
+ setLocId(e.target.value)} + placeholder="Enter location ID (e.g., s3)" + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + /> + +
+ + {activeId && ( + + + Location: {activeId} + + + {infoLoading ? ( + + ) : infoError ? ( +

+ {infoError instanceof Error ? infoError.message : "Failed to load location info"} +

+ ) : info ? ( +
+
+                  {JSON.stringify(info, null, 2)}
+                
+ + + + + Missing {missing?.length ?? "—"} + + + Present {present?.length ?? "—"} + + + + {!missing || missing.length === 0 ? ( +

No missing nodes

+ ) : ( +
+
+ {missing.map((id) => ( + + {id.slice(0, 8)}... + + ))} +
+
+ )} +
+ + {!present || present.length === 0 ? ( +

No present nodes

+ ) : ( +
+
+ {present.map((id) => ( + + {id.slice(0, 8)}... + + ))} +
+
+ )} +
+
+
+ ) : null} +
+
+ )} +
+ ); +} diff --git a/clients/shock-ui/src/components/admin/LockerView.tsx b/clients/shock-ui/src/components/admin/LockerView.tsx new file mode 100644 index 0000000..7e3b581 --- /dev/null +++ b/clients/shock-ui/src/components/admin/LockerView.tsx @@ -0,0 +1,101 @@ +import { useLockedNodes, useLockedFiles, useLockedIndexes } from "shock-client/react"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Link } from "react-router-dom"; + +export function LockerView() { + const { data: nodes, isLoading: nodesLoading } = useLockedNodes(); + const { data: files, isLoading: filesLoading } = useLockedFiles(); + const { data: indexes, isLoading: indexesLoading } = useLockedIndexes(); + + if (nodesLoading || filesLoading || indexesLoading) { + return
; + } + + return ( +
+ + + + Locked Nodes + {nodes?.length ?? 0} + + + + {!nodes || nodes.length === 0 ? ( +

No locked nodes

+ ) : ( +
+ {nodes.map((id) => ( + + + {id.slice(0, 8)}... + + + ))} +
+ )} +
+
+ + + + + Locked Files + {files ? Object.keys(files).length : 0} + + + + {!files || Object.keys(files).length === 0 ? ( +

No locked files

+ ) : ( +
+ {Object.entries(files).map(([key, ids]) => ( +
+ {key}: +
+ {ids.map((id) => ( + + {id.slice(0, 8)}... + + ))} +
+
+ ))} +
+ )} +
+
+ + + + + Locked Indexes + {indexes ? Object.keys(indexes).length : 0} + + + + {!indexes || Object.keys(indexes).length === 0 ? ( +

No locked indexes

+ ) : ( +
+ {Object.entries(indexes).map(([key, ids]) => ( +
+ {key}: +
+ {ids.map((id) => ( + + {id.slice(0, 8)}... + + ))} +
+
+ ))} +
+ )} +
+
+
+ ); +} diff --git a/clients/shock-ui/src/components/admin/TraceControls.tsx b/clients/shock-ui/src/components/admin/TraceControls.tsx new file mode 100644 index 0000000..a1800da --- /dev/null +++ b/clients/shock-ui/src/components/admin/TraceControls.tsx @@ -0,0 +1,167 @@ +import { useState } from "react"; +import { + useStartTrace, + useStopTrace, + useTraceSummary, + useTraceEvents, + useDownloadTrace, +} from "shock-client/react"; +import { Button } from "@/components/ui/button"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Play, Square, Download, Loader2 } from "lucide-react"; + +export function TraceControls() { + const startTrace = useStartTrace(); + const stopTrace = useStopTrace(); + const downloadTrace = useDownloadTrace(); + const [showAnalysis, setShowAnalysis] = useState(false); + + const { data: summary, isLoading: summaryLoading, error: summaryError } = + useTraceSummary(showAnalysis); + const { data: events, isLoading: eventsLoading, error: eventsError } = + useTraceEvents(showAnalysis); + + const handleDownload = () => { + downloadTrace.mutate(undefined, { + onSuccess: (blob) => { + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `trace-${Date.now()}.log`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, + }); + }; + + const handleStop = () => { + stopTrace.mutate(undefined, { + onSuccess: () => { + // Show analysis tabs after stopping + setShowAnalysis(true); + }, + }); + }; + + return ( +
+ {/* Controls */} + + + Capture + + +

+ Capture a runtime execution trace, then download the raw file or + view a summary. Raw files can be analyzed locally with{" "} + go tool trace. +

+
+ + + +
+ {startTrace.data && ( +

{startTrace.data}

+ )} + {stopTrace.data && ( +

{stopTrace.data}

+ )} + {downloadTrace.error && ( +

{downloadTrace.error.message}

+ )} + {(startTrace.error || stopTrace.error) && ( +

+ {(startTrace.error ?? stopTrace.error)?.message} +

+ )} +
+
+ + {/* Analysis */} + {!showAnalysis ? ( + + ) : ( + + + Analysis + + + + + Footprint Summary + Parsed Events + + + + {summaryLoading ? ( + + ) : summaryError ? ( +

+ {summaryError.message} +

+ ) : summary ? ( +
+                    {summary}
+                  
+ ) : ( +

+ No trace data. Start and stop a trace first. +

+ )} +
+ + + {eventsLoading ? ( + + ) : eventsError ? ( +

+ {eventsError.message} +

+ ) : events ? ( +
+                    {events}
+                  
+ ) : ( +

+ No trace data. Start and stop a trace first. +

+ )} +
+
+
+
+ )} +
+ ); +} diff --git a/clients/shock-ui/src/components/auth/LoginForm.tsx b/clients/shock-ui/src/components/auth/LoginForm.tsx new file mode 100644 index 0000000..61e4dae --- /dev/null +++ b/clients/shock-ui/src/components/auth/LoginForm.tsx @@ -0,0 +1,76 @@ +import { useState, type FormEvent } from "react"; +import { useAuth } from "@/hooks/use-auth"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"; +import { Database } from "lucide-react"; + +export function LoginForm() { + const { login } = useAuth(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + try { + await login(username, password); + } catch (err) { + setError(err instanceof Error ? err.message : "Login failed"); + } finally { + setLoading(false); + } + }; + + return ( + + +
+ +
+ Shock + Sign in to your account +
+ +
+
+ + setUsername(e.target.value)} + placeholder="Enter username" + autoComplete="username" + required + /> +
+
+ + setPassword(e.target.value)} + placeholder="Enter password" + autoComplete="current-password" + required + /> +
+ {error && ( +

{error}

+ )} + +
+
+
+ ); +} diff --git a/clients/shock-ui/src/components/auth/ProtectedRoute.tsx b/clients/shock-ui/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..f4a6ba6 --- /dev/null +++ b/clients/shock-ui/src/components/auth/ProtectedRoute.tsx @@ -0,0 +1,24 @@ +import { Navigate, useLocation } from "react-router-dom"; +import { useAuth } from "@/hooks/use-auth"; +import type { ReactNode } from "react"; + +export function ProtectedRoute({ children }: { children: ReactNode }) { + const { isAuthenticated } = useAuth(); + const location = useLocation(); + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +} + +export function AdminRoute({ children }: { children: ReactNode }) { + const { isAdmin } = useAuth(); + + if (!isAdmin) { + return ; + } + + return <>{children}; +} diff --git a/clients/shock-ui/src/components/download/DownloadButton.tsx b/clients/shock-ui/src/components/download/DownloadButton.tsx new file mode 100644 index 0000000..dbb2a21 --- /dev/null +++ b/clients/shock-ui/src/components/download/DownloadButton.tsx @@ -0,0 +1,44 @@ +import { useState } from "react"; +import { useShockClient } from "shock-client/react"; +import { Button } from "@/components/ui/button"; +import { Download, Loader2 } from "lucide-react"; + +interface DownloadButtonProps { + nodeId: string; + fileName: string; +} + +export function DownloadButton({ nodeId, fileName }: DownloadButtonProps) { + const client = useShockClient(); + const [loading, setLoading] = useState(false); + + const handleDownload = async () => { + setLoading(true); + try { + const blob = await client.downloadFile(nodeId); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = fileName || "download"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (err) { + console.error("Download failed:", err); + } finally { + setLoading(false); + } + }; + + return ( + + ); +} diff --git a/clients/shock-ui/src/components/download/PreAuthButton.tsx b/clients/shock-ui/src/components/download/PreAuthButton.tsx new file mode 100644 index 0000000..2bde64f --- /dev/null +++ b/clients/shock-ui/src/components/download/PreAuthButton.tsx @@ -0,0 +1,62 @@ +import { useState } from "react"; +import { useShockClient } from "shock-client/react"; +import { Button } from "@/components/ui/button"; +import { Link2, Check, Loader2 } from "lucide-react"; + +interface PreAuthButtonProps { + nodeId: string; +} + +export function PreAuthButton({ nodeId }: PreAuthButtonProps) { + const client = useShockClient(); + const [url, setUrl] = useState(null); + const [loading, setLoading] = useState(false); + const [copied, setCopied] = useState(false); + + const handleGetUrl = async () => { + setLoading(true); + try { + const result = await client.getDownloadUrl(nodeId); + setUrl(result.url); + } catch (err) { + console.error("Failed to get download URL:", err); + } finally { + setLoading(false); + } + }; + + const handleCopy = async () => { + if (!url) return; + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + if (url) { + return ( +
+
+ + +
+
+ ); + } + + return ( + + ); +} diff --git a/clients/shock-ui/src/components/indexes/IndexList.tsx b/clients/shock-ui/src/components/indexes/IndexList.tsx new file mode 100644 index 0000000..99bb6bf --- /dev/null +++ b/clients/shock-ui/src/components/indexes/IndexList.tsx @@ -0,0 +1,97 @@ +import type { IdxInfo } from "shock-client"; +import { useCreateIndex, useDeleteIndex } from "shock-client/react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { Plus, Trash2, Lock } from "lucide-react"; +import { formatDate } from "@/lib/utils"; + +interface IndexListProps { + nodeId: string; + indexes: Record; +} + +export function IndexList({ nodeId, indexes }: IndexListProps) { + const createIndex = useCreateIndex(nodeId); + const deleteIndex = useDeleteIndex(nodeId); + const [newType, setNewType] = useState(""); + + const entries = Object.entries(indexes); + + const handleCreate = () => { + const trimmed = newType.trim(); + if (!trimmed) return; + createIndex.mutate(trimmed); + setNewType(""); + }; + + return ( +
+ {entries.length === 0 ? ( +

No indexes

+ ) : ( +
+ + + + + + + + + + + + {entries.map(([type, info]) => ( + + + + + + + + ))} + +
TypeUnitsAvg SizeCreatedActions
+ {type} + {info.locked && ( + + locked + + )} + {info.total_units}{info.average_unit_size}{formatDate(info.created_on)} + +
+
+ )} + +
+ setNewType(e.target.value)} + placeholder="Index type (e.g., size, line, chunkrecord)" + className="h-8 text-sm" + onKeyDown={(e) => e.key === "Enter" && handleCreate()} + /> + +
+
+ ); +} diff --git a/clients/shock-ui/src/components/layout/AppShell.tsx b/clients/shock-ui/src/components/layout/AppShell.tsx new file mode 100644 index 0000000..265448d --- /dev/null +++ b/clients/shock-ui/src/components/layout/AppShell.tsx @@ -0,0 +1,20 @@ +import { useState } from "react"; +import { Outlet } from "react-router-dom"; +import { Sidebar } from "./Sidebar"; +import { Header } from "./Header"; + +export function AppShell() { + const [collapsed, setCollapsed] = useState(false); + + return ( +
+ setCollapsed(!collapsed)} /> +
+
+
+ +
+
+
+ ); +} diff --git a/clients/shock-ui/src/components/layout/Header.tsx b/clients/shock-ui/src/components/layout/Header.tsx new file mode 100644 index 0000000..f713c3e --- /dev/null +++ b/clients/shock-ui/src/components/layout/Header.tsx @@ -0,0 +1,49 @@ +import { useServerInfo } from "shock-client/react"; +import { useAuth } from "@/hooks/use-auth"; +import { Button } from "@/components/ui/button"; +import { LogOut, Sun, Moon } from "lucide-react"; +import { useCallback, useState, useEffect } from "react"; + +export function Header() { + const { data: serverInfo } = useServerInfo(); + const { username, logout } = useAuth(); + const [dark, setDark] = useState(() => + document.documentElement.classList.contains("dark") + ); + + const toggleTheme = useCallback(() => { + setDark((prev) => { + const next = !prev; + document.documentElement.classList.toggle("dark", next); + return next; + }); + }, []); + + // sync on mount + useEffect(() => { + document.documentElement.classList.toggle("dark", dark); + }, [dark]); + + return ( +
+
+ {serverInfo && ( + + {serverInfo.type} v{serverInfo.version} + + )} +
+
+ + {username && ( + {username} + )} + +
+
+ ); +} diff --git a/clients/shock-ui/src/components/layout/Sidebar.tsx b/clients/shock-ui/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..0919348 --- /dev/null +++ b/clients/shock-ui/src/components/layout/Sidebar.tsx @@ -0,0 +1,107 @@ +import { NavLink } from "react-router-dom"; +import { cn } from "@/lib/utils"; +import { useAuth } from "@/hooks/use-auth"; +import { + Database, + Upload, + Settings, + Shield, + MapPin, + Lock, + Activity, + LayoutDashboard, + PanelLeftClose, + PanelLeft, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; + +interface SidebarProps { + collapsed: boolean; + onToggle: () => void; +} + +const navLinkClass = ({ isActive }: { isActive: boolean }) => + cn( + "flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors", + isActive + ? "bg-accent text-accent-foreground" + : "text-muted-foreground hover:bg-accent hover:text-accent-foreground" + ); + +export function Sidebar({ collapsed, onToggle }: SidebarProps) { + const { isAdmin } = useAuth(); + + return ( + + ); +} diff --git a/clients/shock-ui/src/components/nodes/AttributeEditor.tsx b/clients/shock-ui/src/components/nodes/AttributeEditor.tsx new file mode 100644 index 0000000..58a647f --- /dev/null +++ b/clients/shock-ui/src/components/nodes/AttributeEditor.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import { useUpdateAttributes } from "shock-client/react"; +import { Button } from "@/components/ui/button"; +import { Save, RotateCcw } from "lucide-react"; + +interface AttributeEditorProps { + nodeId: string; + attributes: unknown; +} + +export function AttributeEditor({ nodeId, attributes }: AttributeEditorProps) { + const original = JSON.stringify(attributes, null, 2) || "{}"; + const [text, setText] = useState(original); + const [parseError, setParseError] = useState(null); + const updateAttrs = useUpdateAttributes(nodeId); + + const isDirty = text !== original; + + const handleSave = () => { + try { + const parsed = JSON.parse(text); + setParseError(null); + updateAttrs.mutate({ attributes: parsed }); + } catch (e) { + setParseError(e instanceof Error ? e.message : "Invalid JSON"); + } + }; + + const handleReset = () => { + setText(original); + setParseError(null); + }; + + return ( +
+