Skip to content
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
44 changes: 35 additions & 9 deletions src/contexts/api/model/ModelContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -34,6 +36,12 @@ export interface ModelContextValue {
/** The user models, active users etc. */
readonly users: Users;

/** Lists an arbitrary path. */
list(path: string[]): Promise<Result<DirectoryTree>>;

/** Fetches an arbitrary path. */
get(path: string[]): Promise<Result<unknown>>;

/** Fetches lamp server metrics. */
getLaserMetrics(): Promise<Result<LaserMetrics>>;
}
Expand All @@ -43,6 +51,8 @@ export const ModelContext = createContext<ModelContextValue>({
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'),
});
Expand All @@ -51,6 +61,22 @@ interface ModelContextProviderProps {
children: ReactNode;
}

function messageToResult<T>(message: ServerMessage<T> | undefined): Result<T> {
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);

Expand Down Expand Up @@ -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<LaserMetrics>;
},
}),
[client, users]
Expand Down
4 changes: 2 additions & 2 deletions src/screens/home/admin/MonitorView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -252,9 +252,9 @@ export function MonitorView() {
title="Monitoring"
toolbar={
/* TODO: auto-refresh (polling) or streaming metrics */
<Button color="secondary" onPress={getLatestMetrics}>
<Button color="secondary" variant="ghost" onPress={getLatestMetrics}>
<IconRefresh />
Refresh all
Refresh All
</Button>
}
>
Expand Down
36 changes: 36 additions & 0 deletions src/screens/home/admin/ResourcesContentsView.tsx
Original file line number Diff line number Diff line change
@@ -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<any>();
const [error, setError] = useState<string>();

useEffect(() => {
(async () => {
const result = await model.get(path);
if (result.ok) {
setValue(result.value);
} else {
setError(`${result.error}`);
}
})();
}, [model, path]);

return (
<>
{value !== undefined ? (
<pre>{JSON.stringify(value, null, 2)}</pre>
) : error ? (
error
) : (
<Spinner />
)}
</>
);
}
27 changes: 18 additions & 9 deletions src/screens/home/admin/ResourcesToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Tabs
selectedKey={layout}
onSelectionChange={onLayoutChange as (layout: Key) => void}
>
{resourcesLayouts.map(layout => (
<Tab key={layout} title={<ResourcesLayoutIcon layout={layout} />} />
))}
</Tabs>
<div className="flex flex-row gap-2">
<Button color="secondary" onPress={refreshListing} variant="ghost">
<IconRefresh />
Refresh Listing
</Button>
<Tabs
selectedKey={layout}
onSelectionChange={onLayoutChange as (layout: Key) => void}
>
{resourcesLayouts.map(layout => (
<Tab key={layout} title={<ResourcesLayoutIcon layout={layout} />} />
))}
</Tabs>
</div>
);
}
152 changes: 152 additions & 0 deletions src/screens/home/admin/ResourcesTreeView.tsx
Original file line number Diff line number Diff line change
@@ -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<string>();

const sortedEntries = useMemo(
() =>
tree
? Object.entries(tree).sort(([name1, _1], [name2, _2]) =>
name1.localeCompare(name2)
)
: undefined,
[tree]
);

switch (layout) {
case 'column':
return (
<div className="flex flex-row gap-2">
<div className="flex flex-col gap-2">
{sortedEntries
? sortedEntries.map(([name, subTree]) => (
<ResourcesTreeButton
key={JSON.stringify([...path, name])}
name={name}
layout={layout}
subTree={subTree}
expanded={expanded}
setExpanded={setExpanded}
/>
))
: undefined}
</div>
{expanded !== undefined ? (
<>
<div>
<Divider orientation="vertical" />
</div>
{tree?.[expanded] !== null ? (
<>
<ResourcesTreeView
key={JSON.stringify([...path, expanded])}
parentPath={[...path, expanded]}
tree={tree?.[expanded]!}
layout={layout}
/>
</>
) : (
<ResourcesContentsView path={[...path, expanded]} />
)}
</>
) : undefined}
</div>
);
case 'list':
return (
<div className="flex flex-col">
{sortedEntries
? sortedEntries.map(([name, subTree]) => (
<>
<ResourcesTreeButton
key={JSON.stringify([...path, name])}
name={name}
subTree={subTree}
layout={layout}
expanded={expanded}
setExpanded={setExpanded}
/>
{expanded === name ? (
<div className="ml-4">
{subTree === null ? (
<ResourcesContentsView path={[...path, expanded]} />
) : (
<ResourcesTreeView
key={JSON.stringify([...path, expanded])}
parentPath={[...path, expanded]}
tree={subTree}
layout={layout}
/>
)}
</div>
) : undefined}
</>
))
: undefined}
</div>
);
}
}

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 (
<Button onPress={onPress} color={color} variant="faded">
<div className="flex flex-row justify-start gap-2 grow">
{layout === 'list' ? (
isExpanded ? (
<IconChevronDown />
) : (
<IconChevronRight />
)
) : undefined}
{subTree === null ? <IconFile /> : <IconFolder />}
{name}
</div>
</Button>
);
}
Loading