Skip to content

Commit 97c1440

Browse files
authored
Merge pull request #66 from ProjectLighthouseCAU/resources-view
Implement resources view
2 parents b42c7fe + c6844cd commit 97c1440

File tree

9 files changed

+287
-28
lines changed

9 files changed

+287
-28
lines changed

package-lock.json

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"@testing-library/user-event": "^14.5.2",
1212
"framer-motion": "^11",
1313
"immutable": "^4.3.6",
14-
"nighthouse": "3.0.3",
14+
"nighthouse": "3.0.4",
1515
"react": "^18.3.1",
1616
"react-card-flip": "^1.2.3",
1717
"react-dom": "^18.3.1",

src/contexts/api/model/ModelContext.tsx

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ import { Map, Set } from 'immutable';
77
import {
88
connect,
99
ConsoleLogHandler,
10+
DirectoryTree,
1011
LeveledLogHandler,
1112
Lighthouse,
1213
LIGHTHOUSE_FRAME_BYTES,
1314
LogLevel,
15+
ServerMessage,
1416
} from 'nighthouse/browser';
1517
import {
1618
createContext,
@@ -34,6 +36,12 @@ export interface ModelContextValue {
3436
/** The user models, active users etc. */
3537
readonly users: Users;
3638

39+
/** Lists an arbitrary path. */
40+
list(path: string[]): Promise<Result<DirectoryTree>>;
41+
42+
/** Fetches an arbitrary path. */
43+
get(path: string[]): Promise<Result<unknown>>;
44+
3745
/** Fetches lamp server metrics. */
3846
getLaserMetrics(): Promise<Result<LaserMetrics>>;
3947
}
@@ -43,6 +51,8 @@ export const ModelContext = createContext<ModelContextValue>({
4351
models: Map(),
4452
active: Set(),
4553
},
54+
list: async () => errorResult('No model context for listing path'),
55+
get: async () => errorResult('No model context for fetching path'),
4656
getLaserMetrics: async () =>
4757
errorResult('No model context for fetching laser metrics'),
4858
});
@@ -51,6 +61,22 @@ interface ModelContextProviderProps {
5161
children: ReactNode;
5262
}
5363

64+
function messageToResult<T>(message: ServerMessage<T> | undefined): Result<T> {
65+
try {
66+
if (!message) {
67+
return errorResult('Model server provided no results');
68+
}
69+
if (message.RNUM >= 400) {
70+
return errorResult(
71+
`Model server errored: ${message.RNUM} ${message.RESPONSE ?? ''}`
72+
);
73+
}
74+
return okResult(message.PAYL);
75+
} catch (error) {
76+
return errorResult(error);
77+
}
78+
}
79+
5480
export function ModelContextProvider({ children }: ModelContextProviderProps) {
5581
const auth = useContext(AuthContext);
5682

@@ -148,16 +174,16 @@ export function ModelContextProvider({ children }: ModelContextProviderProps) {
148174
const value: ModelContextValue = useMemo(
149175
() => ({
150176
users,
177+
async list(path) {
178+
const message = await client?.list(path);
179+
return messageToResult(message);
180+
},
181+
async get(path) {
182+
const message = await client?.get(path);
183+
return messageToResult(message);
184+
},
151185
async getLaserMetrics() {
152-
try {
153-
const message = await client?.get(['metrics', 'laser']);
154-
if (!message || message.RNUM >= 400) {
155-
return errorResult('Model server provided no laser metrics');
156-
}
157-
return okResult(message.PAYL as LaserMetrics);
158-
} catch (error) {
159-
return errorResult(error);
160-
}
186+
return (await this.get(['metrics', 'laser'])) as Result<LaserMetrics>;
161187
},
162188
}),
163189
[client, users]

src/screens/home/admin/MonitorView.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,9 +252,9 @@ export function MonitorView() {
252252
title="Monitoring"
253253
toolbar={
254254
/* TODO: auto-refresh (polling) or streaming metrics */
255-
<Button color="secondary" onPress={getLatestMetrics}>
255+
<Button color="secondary" variant="ghost" onPress={getLatestMetrics}>
256256
<IconRefresh />
257-
Refresh all
257+
Refresh All
258258
</Button>
259259
}
260260
>
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Spinner } from '@heroui/react';
2+
import { ModelContext } from '@luna/contexts/api/model/ModelContext';
3+
import { useContext, useEffect, useState } from 'react';
4+
5+
export interface ResourcesContentsViewProps {
6+
path: string[];
7+
}
8+
9+
export function ResourcesContentsView({ path }: ResourcesContentsViewProps) {
10+
const model = useContext(ModelContext);
11+
const [value, setValue] = useState<any>();
12+
const [error, setError] = useState<string>();
13+
14+
useEffect(() => {
15+
(async () => {
16+
const result = await model.get(path);
17+
if (result.ok) {
18+
setValue(result.value);
19+
} else {
20+
setError(`${result.error}`);
21+
}
22+
})();
23+
}, [model, path]);
24+
25+
return (
26+
<>
27+
{value !== undefined ? (
28+
<pre>{JSON.stringify(value, null, 2)}</pre>
29+
) : error ? (
30+
error
31+
) : (
32+
<Spinner />
33+
)}
34+
</>
35+
);
36+
}

src/screens/home/admin/ResourcesToolbar.tsx

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,35 @@ import {
33
resourcesLayouts,
44
} from '@luna/screens/home/admin/ResourcesLayout';
55
import { ResourcesLayoutIcon } from '@luna/screens/home/admin/ResourcesLayoutIcon';
6-
import { Tab, Tabs } from '@heroui/react';
6+
import { Button, Tab, Tabs } from '@heroui/react';
77
import { Key } from 'react';
8+
import { IconRefresh } from '@tabler/icons-react';
89

910
export interface ResourcesToolbarProps {
1011
layout: ResourcesLayout;
1112
onLayoutChange: (layout: ResourcesLayout) => void;
13+
refreshListing: () => void;
1214
}
1315

1416
export function ResourcesToolbar({
1517
layout,
1618
onLayoutChange,
19+
refreshListing,
1720
}: ResourcesToolbarProps) {
1821
return (
19-
<Tabs
20-
selectedKey={layout}
21-
onSelectionChange={onLayoutChange as (layout: Key) => void}
22-
>
23-
{resourcesLayouts.map(layout => (
24-
<Tab key={layout} title={<ResourcesLayoutIcon layout={layout} />} />
25-
))}
26-
</Tabs>
22+
<div className="flex flex-row gap-2">
23+
<Button color="secondary" onPress={refreshListing} variant="ghost">
24+
<IconRefresh />
25+
Refresh Listing
26+
</Button>
27+
<Tabs
28+
selectedKey={layout}
29+
onSelectionChange={onLayoutChange as (layout: Key) => void}
30+
>
31+
{resourcesLayouts.map(layout => (
32+
<Tab key={layout} title={<ResourcesLayoutIcon layout={layout} />} />
33+
))}
34+
</Tabs>
35+
</div>
2736
);
2837
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { Button, Divider } from '@heroui/react';
2+
import { ResourcesContentsView } from '@luna/screens/home/admin/ResourcesContentsView';
3+
import { ResourcesLayout } from '@luna/screens/home/admin/ResourcesLayout';
4+
import {
5+
IconChevronDown,
6+
IconChevronRight,
7+
IconFile,
8+
IconFolder,
9+
} from '@tabler/icons-react';
10+
import { DirectoryTree } from 'nighthouse/browser';
11+
import { useCallback, useMemo, useState } from 'react';
12+
13+
export interface ResourcesTreeViewProps {
14+
parentPath?: string[];
15+
tree: DirectoryTree | undefined | null;
16+
layout: ResourcesLayout;
17+
}
18+
19+
export function ResourcesTreeView({
20+
parentPath: path = [],
21+
tree,
22+
layout,
23+
}: ResourcesTreeViewProps) {
24+
// TODO: Use a set here and let the user expand multiple nodes (but only in
25+
// list view, in column view this doesn't make sense and shouldn't be allowed)
26+
const [expanded, setExpanded] = useState<string>();
27+
28+
const sortedEntries = useMemo(
29+
() =>
30+
tree
31+
? Object.entries(tree).sort(([name1, _1], [name2, _2]) =>
32+
name1.localeCompare(name2)
33+
)
34+
: undefined,
35+
[tree]
36+
);
37+
38+
switch (layout) {
39+
case 'column':
40+
return (
41+
<div className="flex flex-row gap-2">
42+
<div className="flex flex-col gap-2">
43+
{sortedEntries
44+
? sortedEntries.map(([name, subTree]) => (
45+
<ResourcesTreeButton
46+
key={JSON.stringify([...path, name])}
47+
name={name}
48+
layout={layout}
49+
subTree={subTree}
50+
expanded={expanded}
51+
setExpanded={setExpanded}
52+
/>
53+
))
54+
: undefined}
55+
</div>
56+
{expanded !== undefined ? (
57+
<>
58+
<div>
59+
<Divider orientation="vertical" />
60+
</div>
61+
{tree?.[expanded] !== null ? (
62+
<>
63+
<ResourcesTreeView
64+
key={JSON.stringify([...path, expanded])}
65+
parentPath={[...path, expanded]}
66+
tree={tree?.[expanded]!}
67+
layout={layout}
68+
/>
69+
</>
70+
) : (
71+
<ResourcesContentsView path={[...path, expanded]} />
72+
)}
73+
</>
74+
) : undefined}
75+
</div>
76+
);
77+
case 'list':
78+
return (
79+
<div className="flex flex-col">
80+
{sortedEntries
81+
? sortedEntries.map(([name, subTree]) => (
82+
<>
83+
<ResourcesTreeButton
84+
key={JSON.stringify([...path, name])}
85+
name={name}
86+
subTree={subTree}
87+
layout={layout}
88+
expanded={expanded}
89+
setExpanded={setExpanded}
90+
/>
91+
{expanded === name ? (
92+
<div className="ml-4">
93+
{subTree === null ? (
94+
<ResourcesContentsView path={[...path, expanded]} />
95+
) : (
96+
<ResourcesTreeView
97+
key={JSON.stringify([...path, expanded])}
98+
parentPath={[...path, expanded]}
99+
tree={subTree}
100+
layout={layout}
101+
/>
102+
)}
103+
</div>
104+
) : undefined}
105+
</>
106+
))
107+
: undefined}
108+
</div>
109+
);
110+
}
111+
}
112+
113+
function ResourcesTreeButton({
114+
name,
115+
subTree,
116+
layout,
117+
expanded,
118+
setExpanded,
119+
}: {
120+
name: string;
121+
subTree: DirectoryTree | null;
122+
layout: ResourcesLayout;
123+
expanded: string | undefined;
124+
setExpanded: (name?: string) => void;
125+
}) {
126+
const isExpanded = useMemo(() => expanded === name, [expanded, name]);
127+
128+
const color = useMemo(
129+
() => (isExpanded && layout === 'column' ? 'primary' : 'default'),
130+
[isExpanded, layout]
131+
);
132+
133+
const onPress = useCallback(() => {
134+
setExpanded(isExpanded ? undefined : name);
135+
}, [name, isExpanded, setExpanded]);
136+
137+
return (
138+
<Button onPress={onPress} color={color} variant="faded">
139+
<div className="flex flex-row justify-start gap-2 grow">
140+
{layout === 'list' ? (
141+
isExpanded ? (
142+
<IconChevronDown />
143+
) : (
144+
<IconChevronRight />
145+
)
146+
) : undefined}
147+
{subTree === null ? <IconFile /> : <IconFolder />}
148+
{name}
149+
</div>
150+
</Button>
151+
);
152+
}

0 commit comments

Comments
 (0)