diff --git a/package-lock.json b/package-lock.json index a01e732b..44440993 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@testing-library/user-event": "^14.5.2", "framer-motion": "^11", "immutable": "^4.3.6", - "nighthouse": "3.0.3", + "nighthouse": "3.0.4", "react": "^18.3.1", "react-card-flip": "^1.2.3", "react-dom": "^18.3.1", @@ -20092,9 +20092,9 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" }, "node_modules/nighthouse": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/nighthouse/-/nighthouse-3.0.3.tgz", - "integrity": "sha512-Xdf05MpHYUvKRy2Fc8iuX1UasMir7SyzZkwuqVMI0cYmZoCu943BbZkYb6EOiGF+KU5VNNpkyU+6NdPcjsV3+g==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/nighthouse/-/nighthouse-3.0.4.tgz", + "integrity": "sha512-CUMq62io/fCxn2QFDTcPQT2LaJ14d5Jdj/0WicrKvKMf7YJJa6tj+8CqFiKqJ7t5tFF3z1SWCkbnHANFEfX0Ng==", "dependencies": { "@msgpack/msgpack": "^3.0.0-beta2" }, diff --git a/package.json b/package.json index 2f051b2f..b3bb50d4 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "@testing-library/user-event": "^14.5.2", "framer-motion": "^11", "immutable": "^4.3.6", - "nighthouse": "3.0.3", + "nighthouse": "3.0.4", "react": "^18.3.1", "react-card-flip": "^1.2.3", "react-dom": "^18.3.1", diff --git a/src/contexts/api/model/ModelContext.tsx b/src/contexts/api/model/ModelContext.tsx index 5fff57bc..0f23887c 100644 --- a/src/contexts/api/model/ModelContext.tsx +++ b/src/contexts/api/model/ModelContext.tsx @@ -7,10 +7,12 @@ import { Map, Set } from 'immutable'; import { connect, ConsoleLogHandler, + DirectoryTree, LeveledLogHandler, Lighthouse, LIGHTHOUSE_FRAME_BYTES, LogLevel, + ServerMessage, } from 'nighthouse/browser'; import { createContext, @@ -34,6 +36,12 @@ export interface ModelContextValue { /** The user models, active users etc. */ readonly users: Users; + /** Lists an arbitrary path. */ + list(path: string[]): Promise>; + + /** Fetches an arbitrary path. */ + get(path: string[]): Promise>; + /** Fetches lamp server metrics. */ getLaserMetrics(): Promise>; } @@ -43,6 +51,8 @@ export const ModelContext = createContext({ models: Map(), active: Set(), }, + list: async () => errorResult('No model context for listing path'), + get: async () => errorResult('No model context for fetching path'), getLaserMetrics: async () => errorResult('No model context for fetching laser metrics'), }); @@ -51,6 +61,22 @@ interface ModelContextProviderProps { children: ReactNode; } +function messageToResult(message: ServerMessage | undefined): Result { + try { + if (!message) { + return errorResult('Model server provided no results'); + } + if (message.RNUM >= 400) { + return errorResult( + `Model server errored: ${message.RNUM} ${message.RESPONSE ?? ''}` + ); + } + return okResult(message.PAYL); + } catch (error) { + return errorResult(error); + } +} + export function ModelContextProvider({ children }: ModelContextProviderProps) { const auth = useContext(AuthContext); @@ -148,16 +174,16 @@ export function ModelContextProvider({ children }: ModelContextProviderProps) { const value: ModelContextValue = useMemo( () => ({ users, + async list(path) { + const message = await client?.list(path); + return messageToResult(message); + }, + async get(path) { + const message = await client?.get(path); + return messageToResult(message); + }, async getLaserMetrics() { - try { - const message = await client?.get(['metrics', 'laser']); - if (!message || message.RNUM >= 400) { - return errorResult('Model server provided no laser metrics'); - } - return okResult(message.PAYL as LaserMetrics); - } catch (error) { - return errorResult(error); - } + return (await this.get(['metrics', 'laser'])) as Result; }, }), [client, users] diff --git a/src/screens/home/admin/MonitorView.tsx b/src/screens/home/admin/MonitorView.tsx index f0cb5b52..f60eb13e 100644 --- a/src/screens/home/admin/MonitorView.tsx +++ b/src/screens/home/admin/MonitorView.tsx @@ -252,9 +252,9 @@ export function MonitorView() { title="Monitoring" toolbar={ /* TODO: auto-refresh (polling) or streaming metrics */ - } > diff --git a/src/screens/home/admin/ResourcesContentsView.tsx b/src/screens/home/admin/ResourcesContentsView.tsx new file mode 100644 index 00000000..4c7d0717 --- /dev/null +++ b/src/screens/home/admin/ResourcesContentsView.tsx @@ -0,0 +1,36 @@ +import { Spinner } from '@heroui/react'; +import { ModelContext } from '@luna/contexts/api/model/ModelContext'; +import { useContext, useEffect, useState } from 'react'; + +export interface ResourcesContentsViewProps { + path: string[]; +} + +export function ResourcesContentsView({ path }: ResourcesContentsViewProps) { + const model = useContext(ModelContext); + const [value, setValue] = useState(); + const [error, setError] = useState(); + + useEffect(() => { + (async () => { + const result = await model.get(path); + if (result.ok) { + setValue(result.value); + } else { + setError(`${result.error}`); + } + })(); + }, [model, path]); + + return ( + <> + {value !== undefined ? ( +
{JSON.stringify(value, null, 2)}
+ ) : error ? ( + error + ) : ( + + )} + + ); +} diff --git a/src/screens/home/admin/ResourcesToolbar.tsx b/src/screens/home/admin/ResourcesToolbar.tsx index e7eb4e75..952b09ec 100644 --- a/src/screens/home/admin/ResourcesToolbar.tsx +++ b/src/screens/home/admin/ResourcesToolbar.tsx @@ -3,26 +3,35 @@ import { resourcesLayouts, } from '@luna/screens/home/admin/ResourcesLayout'; import { ResourcesLayoutIcon } from '@luna/screens/home/admin/ResourcesLayoutIcon'; -import { Tab, Tabs } from '@heroui/react'; +import { Button, Tab, Tabs } from '@heroui/react'; import { Key } from 'react'; +import { IconRefresh } from '@tabler/icons-react'; export interface ResourcesToolbarProps { layout: ResourcesLayout; onLayoutChange: (layout: ResourcesLayout) => void; + refreshListing: () => void; } export function ResourcesToolbar({ layout, onLayoutChange, + refreshListing, }: ResourcesToolbarProps) { return ( - void} - > - {resourcesLayouts.map(layout => ( - } /> - ))} - +
+ + void} + > + {resourcesLayouts.map(layout => ( + } /> + ))} + +
); } diff --git a/src/screens/home/admin/ResourcesTreeView.tsx b/src/screens/home/admin/ResourcesTreeView.tsx new file mode 100644 index 00000000..0a10caf9 --- /dev/null +++ b/src/screens/home/admin/ResourcesTreeView.tsx @@ -0,0 +1,152 @@ +import { Button, Divider } from '@heroui/react'; +import { ResourcesContentsView } from '@luna/screens/home/admin/ResourcesContentsView'; +import { ResourcesLayout } from '@luna/screens/home/admin/ResourcesLayout'; +import { + IconChevronDown, + IconChevronRight, + IconFile, + IconFolder, +} from '@tabler/icons-react'; +import { DirectoryTree } from 'nighthouse/browser'; +import { useCallback, useMemo, useState } from 'react'; + +export interface ResourcesTreeViewProps { + parentPath?: string[]; + tree: DirectoryTree | undefined | null; + layout: ResourcesLayout; +} + +export function ResourcesTreeView({ + parentPath: path = [], + tree, + layout, +}: ResourcesTreeViewProps) { + // TODO: Use a set here and let the user expand multiple nodes (but only in + // list view, in column view this doesn't make sense and shouldn't be allowed) + const [expanded, setExpanded] = useState(); + + const sortedEntries = useMemo( + () => + tree + ? Object.entries(tree).sort(([name1, _1], [name2, _2]) => + name1.localeCompare(name2) + ) + : undefined, + [tree] + ); + + switch (layout) { + case 'column': + return ( +
+
+ {sortedEntries + ? sortedEntries.map(([name, subTree]) => ( + + )) + : undefined} +
+ {expanded !== undefined ? ( + <> +
+ +
+ {tree?.[expanded] !== null ? ( + <> + + + ) : ( + + )} + + ) : undefined} +
+ ); + case 'list': + return ( +
+ {sortedEntries + ? sortedEntries.map(([name, subTree]) => ( + <> + + {expanded === name ? ( +
+ {subTree === null ? ( + + ) : ( + + )} +
+ ) : undefined} + + )) + : undefined} +
+ ); + } +} + +function ResourcesTreeButton({ + name, + subTree, + layout, + expanded, + setExpanded, +}: { + name: string; + subTree: DirectoryTree | null; + layout: ResourcesLayout; + expanded: string | undefined; + setExpanded: (name?: string) => void; +}) { + const isExpanded = useMemo(() => expanded === name, [expanded, name]); + + const color = useMemo( + () => (isExpanded && layout === 'column' ? 'primary' : 'default'), + [isExpanded, layout] + ); + + const onPress = useCallback(() => { + setExpanded(isExpanded ? undefined : name); + }, [name, isExpanded, setExpanded]); + + return ( + + ); +} diff --git a/src/screens/home/admin/ResourcesView.tsx b/src/screens/home/admin/ResourcesView.tsx index a905942b..591027f9 100644 --- a/src/screens/home/admin/ResourcesView.tsx +++ b/src/screens/home/admin/ResourcesView.tsx @@ -1,9 +1,12 @@ -import { UnderConstruction } from '@luna/components/UnderConstruction'; import { LocalStorageKey } from '@luna/constants/LocalStorageKey'; +import { ModelContext } from '@luna/contexts/api/model/ModelContext'; import { useLocalStorage } from '@luna/hooks/useLocalStorage'; import { ResourcesLayout } from '@luna/screens/home/admin/ResourcesLayout'; import { ResourcesToolbar } from '@luna/screens/home/admin/ResourcesToolbar'; +import { ResourcesTreeView } from '@luna/screens/home/admin/ResourcesTreeView'; import { HomeContent } from '@luna/screens/home/HomeContent'; +import { DirectoryTree } from 'nighthouse/browser'; +import { useCallback, useContext, useEffect, useState } from 'react'; export function ResourcesView() { const [layout, setLayout] = useLocalStorage( @@ -11,12 +14,34 @@ export function ResourcesView() { () => 'column' ); + const model = useContext(ModelContext); + const [tree, setTree] = useState(); + + const refreshListing = useCallback(async () => { + const result = await model.list([]); + if (result.ok) { + setTree(result.value); + } else { + console.log(result.error); + } + }, [model]); + + useEffect(() => { + refreshListing(); + }, [refreshListing]); + return ( } + toolbar={ + + } > - + {tree ? : undefined} ); } diff --git a/src/utils/result.ts b/src/utils/result.ts index 2ab5eb3a..b08c599e 100644 --- a/src/utils/result.ts +++ b/src/utils/result.ts @@ -10,6 +10,17 @@ export function errorResult(error: E): Result { return { ok: false, error }; } +export function mapResult( + result: Result, + transform: (value: T) => U +): Result { + if (result.ok) { + return { ok: true, value: transform(result.value) }; + } else { + return result; + } +} + export function getOrThrow(result: Result): T { if (result.ok) { return result.value;