Merge pull request #483 from ylei-tsubame/issues/442-dashboard-nodes

Web UI: add node list to dashboard
main
Digimer 1 year ago committed by GitHub
commit dc5b01f087
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 39
      striker-ui-api/src/lib/request_handlers/anvil/buildAnvilSummary.ts
  2. 86
      striker-ui-api/src/lib/request_handlers/anvil/getAnvilCpu.ts
  3. 26
      striker-ui-api/src/lib/request_handlers/anvil/getAnvilStore.ts
  4. 23
      striker-ui-api/src/types/ApiAn.d.ts
  5. 312
      striker-ui/components/Anvils/AnvilSummary.tsx
  6. 84
      striker-ui/components/Anvils/AnvilSummaryList.tsx
  7. 23
      striker-ui/components/Bars/AllocationBar.tsx
  8. 17
      striker-ui/components/Bars/BorderLinearProgress.tsx
  9. 18
      striker-ui/components/Bars/ProgressBar.tsx
  10. 93
      striker-ui/components/Bars/StackBar.tsx
  11. 13
      striker-ui/components/Bars/Underline.tsx
  12. 3
      striker-ui/components/Bars/index.tsx
  13. 36
      striker-ui/components/Files/ManageFilePanel.tsx
  14. 8
      striker-ui/components/Memory.tsx
  15. 13
      striker-ui/lib/api_converters/index.ts
  16. 39
      striker-ui/lib/api_converters/toAnvilDetail.ts
  17. 15
      striker-ui/lib/api_converters/toAnvilMemoryCalcable.ts
  18. 13
      striker-ui/lib/api_converters/toAnvilOverviewHostList.ts
  19. 28
      striker-ui/lib/api_converters/toAnvilOverviewList.ts
  20. 29
      striker-ui/lib/api_converters/toAnvilSharedStorageOverview.ts
  21. 119
      striker-ui/pages/index.tsx
  22. 58
      striker-ui/types/APIAnvil.d.ts
  23. 3
      striker-ui/types/AnvilSummary.d.ts
  24. 14
      striker-ui/types/StackBar.d.ts

@ -34,6 +34,34 @@ export const buildAnvilSummary = async ({
hosts: [], hosts: [],
}; };
let scounts: [scount: number, hostUuid: string][];
try {
scounts = await query(
`SELECT
COUNT(a.server_name),
b.host_uuid
FROM servers AS a
JOIN hosts AS b
ON a.server_host_uuid = b.host_uuid
JOIN anvils AS c
ON b.host_uuid IN (
c.anvil_node1_host_uuid,
c.anvil_node2_host_uuid
)
WHERE c.anvil_uuid = '${anvilUuid}'
AND a.server_state = 'running'
GROUP BY b.host_uuid, b.host_name
ORDER BY b.host_name;`,
);
} catch (error) {
stderr(`Failed to get subnodes' server count; CAUSE: ${error}`);
throw error;
}
if (!scounts.length) throw new Error(`No host server records found`);
for (const huuid of [n1uuid, n2uuid]) { for (const huuid of [n1uuid, n2uuid]) {
const { const {
host_uuid: { host_uuid: {
@ -43,10 +71,21 @@ export const buildAnvilSummary = async ({
const { hosts: rhosts } = result; const { hosts: rhosts } = result;
const found = scounts.find((row) => {
if (row.length !== 2) return false;
const { 1: uuid } = row;
return uuid === huuid;
});
const scount = found ? found[0] : 0;
const hsummary: AnvilDetailHostSummary = { const hsummary: AnvilDetailHostSummary = {
host_name: hname, host_name: hname,
host_uuid: huuid, host_uuid: huuid,
maintenance_mode: false, maintenance_mode: false,
server_count: scount,
state: 'offline', state: 'offline',
state_message: buildHostStateMessage(), state_message: buildHostStateMessage(),
state_percent: 0, state_percent: 0,

@ -1,6 +1,7 @@
import { RequestHandler } from 'express'; import { RequestHandler } from 'express';
import { query } from '../../accessModule'; import { query } from '../../accessModule';
import { getShortHostName } from '../../disassembleHostName';
import { stderr } from '../../shell'; import { stderr } from '../../shell';
export const getAnvilCpu: RequestHandler<AnvilDetailParamsDictionary> = async ( export const getAnvilCpu: RequestHandler<AnvilDetailParamsDictionary> = async (
@ -11,26 +12,42 @@ export const getAnvilCpu: RequestHandler<AnvilDetailParamsDictionary> = async (
params: { anvilUuid }, params: { anvilUuid },
} = request; } = request;
let rCores: null | string; let rCpus: [
let rThreads: null | string; hostUuid: string,
hostName: string,
cpuModel: string,
cpuCores: string,
cpuThreads: string,
cpuMinCores: string,
cpuMinThreads: string,
][];
try { try {
[[rCores = '', rThreads = '']] = await query< rCpus = await query(
[[cpuCores: null | string, cpuThreads: null | string]]
>(
`SELECT `SELECT
b.host_uuid,
b.host_name,
c.scan_hardware_cpu_model,
c.scan_hardware_cpu_cores,
c.scan_hardware_cpu_threads,
MIN(c.scan_hardware_cpu_cores) AS cores, MIN(c.scan_hardware_cpu_cores) AS cores,
MIN(c.scan_hardware_cpu_threads) AS threads MIN(c.scan_hardware_cpu_threads) AS threads
FROM anvils AS a FROM anvils AS a
JOIN hosts AS b JOIN hosts AS b
ON b.host_uuid IN ( ON b.host_uuid IN (
a.anvil_node1_host_uuid, a.anvil_node1_host_uuid,
a.anvil_node2_host_uuid, a.anvil_node2_host_uuid
a.anvil_dr1_host_uuid
) )
JOIN scan_hardware AS c JOIN scan_hardware AS c
ON b.host_uuid = c.scan_hardware_host_uuid ON b.host_uuid = c.scan_hardware_host_uuid
WHERE a.anvil_uuid = '${anvilUuid}';`, WHERE a.anvil_uuid = '${anvilUuid}'
GROUP BY
b.host_uuid,
b.host_name,
c.scan_hardware_cpu_model,
c.scan_hardware_cpu_cores,
c.scan_hardware_cpu_threads
ORDER BY b.host_name;`,
); );
} catch (error) { } catch (error) {
stderr(`Failed to get anvil ${anvilUuid} cpu info; CAUSE: ${error}`); stderr(`Failed to get anvil ${anvilUuid} cpu info; CAUSE: ${error}`);
@ -38,13 +55,12 @@ export const getAnvilCpu: RequestHandler<AnvilDetailParamsDictionary> = async (
return response.status(500).send(); return response.status(500).send();
} }
const cores = Number.parseInt(rCores); if (!rCpus.length) return response.status(404).send();
const threads = Number.parseInt(rThreads);
let rAllocated: null | string; let rAllocatedRow: [cpuAllocated: string][];
try { try {
[[rAllocated = '']] = await query<[[cpuAllocated: null | string]]>( rAllocatedRow = await query(
`SELECT `SELECT
SUM( SUM(
CAST( CAST(
@ -64,11 +80,45 @@ export const getAnvilCpu: RequestHandler<AnvilDetailParamsDictionary> = async (
return response.status(500).send(); return response.status(500).send();
} }
const allocated = Number.parseInt(rAllocated); if (!rAllocatedRow.length) return response.status(404).send();
response.status(200).send({ const {
allocated, 0: { 5: rMinCores, 6: rMinThreads },
cores, } = rCpus;
threads,
}); const minCores = Number(rMinCores);
const minThreads = Number(rMinThreads);
const [[rAllocated]] = rAllocatedRow;
const allocated = Number(rAllocated);
const rsBody = rCpus.reduce<AnvilDetailCpuSummary>(
(previous, current) => {
const { 0: uuid, 1: name, 2: model, 3: rCores, 4: rThreads } = current;
const cores = Number(rCores);
const threads = Number(rThreads);
const vendor = model.replace(/^(\w+).*$/, '$1');
previous.hosts[uuid] = {
cores,
model,
name: getShortHostName(name),
threads,
uuid,
vendor,
};
return previous;
},
{
allocated,
cores: minCores,
hosts: {},
threads: minThreads,
},
);
response.status(200).send(rsBody);
}; };

@ -27,7 +27,14 @@ export const getAnvilStore: RequestHandler<
return response.status(400).send(); return response.status(400).send();
} }
let rows: [uuid: string, name: string, size: string, free: string][]; let rows: [
uuid: string,
name: string,
size: string,
free: string,
totalSize: string,
totalFree: string,
][];
try { try {
rows = await query( rows = await query(
@ -35,7 +42,9 @@ export const getAnvilStore: RequestHandler<
DISTINCT ON (b.storage_group_uuid) storage_group_uuid, DISTINCT ON (b.storage_group_uuid) storage_group_uuid,
b.storage_group_name, b.storage_group_name,
d.scan_lvm_vg_size, d.scan_lvm_vg_size,
d.scan_lvm_vg_free d.scan_lvm_vg_free,
SUM(d.scan_lvm_vg_size) AS total_vg_size,
SUM(d.scan_lvm_vg_free) AS total_vg_free
FROM anvils AS a FROM anvils AS a
JOIN storage_groups AS b JOIN storage_groups AS b
ON a.anvil_uuid = b.storage_group_anvil_uuid ON a.anvil_uuid = b.storage_group_anvil_uuid
@ -44,6 +53,11 @@ export const getAnvilStore: RequestHandler<
JOIN scan_lvm_vgs AS d JOIN scan_lvm_vgs AS d
ON c.storage_group_member_vg_uuid = d.scan_lvm_vg_internal_uuid ON c.storage_group_member_vg_uuid = d.scan_lvm_vg_internal_uuid
WHERE a.anvil_uuid = '${anUuid}' WHERE a.anvil_uuid = '${anUuid}'
GROUP BY
b.storage_group_uuid,
b.storage_group_name,
d.scan_lvm_vg_size,
d.scan_lvm_vg_free
ORDER BY b.storage_group_uuid, d.scan_lvm_vg_free;`, ORDER BY b.storage_group_uuid, d.scan_lvm_vg_free;`,
); );
} catch (error) { } catch (error) {
@ -52,6 +66,12 @@ export const getAnvilStore: RequestHandler<
return response.status(500).send(); return response.status(500).send();
} }
if (!rows.length) return response.status(404).send();
const {
0: { 4: totalSize, 5: totalFree },
} = rows;
const rsbody: AnvilDetailStoreSummary = { const rsbody: AnvilDetailStoreSummary = {
storage_groups: rows.map<AnvilDetailStore>( storage_groups: rows.map<AnvilDetailStore>(
([sgUuid, sgName, sgSize, sgFree]) => ({ ([sgUuid, sgName, sgSize, sgFree]) => ({
@ -61,6 +81,8 @@ export const getAnvilStore: RequestHandler<
storage_group_uuid: sgUuid, storage_group_uuid: sgUuid,
}), }),
), ),
total_free: totalFree,
total_size: totalSize,
}; };
return response.json(rsbody); return response.json(rsbody);

@ -1,3 +1,23 @@
type AnvilDetailCpuHost = {
cores: number;
model: string;
name: string;
threads: number;
uuid: string;
vendor: string;
};
type AnvilDetailCpuHostList = {
[hostUuid: string]: AnvilDetailCpuHost;
};
type AnvilDetailCpuSummary = {
allocated: number;
cores: number;
hosts: AnvilDetailCpuHostList;
threads: number;
};
type AnvilDetailFileForProvisionServer = { type AnvilDetailFileForProvisionServer = {
fileUUID: string; fileUUID: string;
fileName: string; fileName: string;
@ -66,6 +86,7 @@ type AnvilDetailHostSummary = {
host_name: string; host_name: string;
host_uuid: string; host_uuid: string;
maintenance_mode: boolean; maintenance_mode: boolean;
server_count: number;
state: string; state: string;
state_message: string; state_message: string;
state_percent: number; state_percent: number;
@ -109,6 +130,8 @@ type AnvilDetailParamsDictionary = {
type AnvilDetailStoreSummary = { type AnvilDetailStoreSummary = {
storage_groups: AnvilDetailStore[]; storage_groups: AnvilDetailStore[];
total_free: string;
total_size: string;
}; };
type AnvilOverview = { type AnvilOverview = {

@ -0,0 +1,312 @@
import { Grid, gridClasses } from '@mui/material';
import { dSizeStr } from 'format-data-size';
import { FC, ReactNode, useMemo } from 'react';
import { BLUE, GREY, PURPLE, RED } from '../../lib/consts/DEFAULT_THEME';
import {
toAnvilDetail,
toAnvilMemoryCalcable,
toAnvilSharedStorageOverview,
} from '../../lib/api_converters';
import Divider from '../Divider';
import FlexBox from '../FlexBox';
import Spinner from '../Spinner';
import StackBar from '../Bars/StackBar';
import { BodyText, InlineMonoText, MonoText } from '../Text';
import useFetch from '../../hooks/useFetch';
const N_100 = BigInt(100);
const MAP_TO_ANVIL_STATE_COLOUR = {
degraded: RED,
not_ready: PURPLE,
optimal: BLUE,
};
const MAP_TO_HOST_STATE_COLOUR: Record<string, string> = {
offline: PURPLE,
online: BLUE,
};
const AnvilSummary: FC<AnvilSummaryProps> = (props) => {
const { anvilUuid } = props;
const { data: rAnvil, loading: loadingAnvil } = useFetch<AnvilListItem>(
`/anvil/${anvilUuid}`,
);
const anvil = useMemo<APIAnvilDetail | undefined>(
() => rAnvil && toAnvilDetail(rAnvil),
[rAnvil],
);
const { data: cpu, loading: loadingCpu } = useFetch<AnvilCPU>(
`/anvil/${anvilUuid}/cpu`,
);
const cpuSubnodes = useMemo<AnvilCPU['hosts'][string][] | undefined>(
() => cpu && Object.values(cpu.hosts),
[cpu],
);
const { data: rMemory, loading: loadingMemory } = useFetch<AnvilMemory>(
`/anvil/${anvilUuid}/memory`,
);
const memory = useMemo<AnvilMemoryCalcable | undefined>(
() => rMemory && toAnvilMemoryCalcable(rMemory),
[rMemory],
);
const { data: rStorages, loading: loadingStorages } =
useFetch<AnvilSharedStorage>(`/anvil/${anvilUuid}/store`);
const storages = useMemo<APIAnvilSharedStorageOverview | undefined>(
() => rStorages && toAnvilSharedStorageOverview(rStorages),
[rStorages],
);
const loading = useMemo<boolean>(
() =>
[loadingAnvil, loadingCpu, loadingMemory, loadingStorages].some(
(cond) => cond,
),
[loadingAnvil, loadingCpu, loadingMemory, loadingStorages],
);
const anvilSummary = useMemo(
() =>
anvil && (
<MonoText inheritColour color={MAP_TO_ANVIL_STATE_COLOUR[anvil.state]}>
{anvil.state}
</MonoText>
),
[anvil],
);
const hostsSummary = useMemo(
() =>
anvil && (
<Grid
alignItems="center"
columns={20}
columnSpacing="0.5em"
container
sx={{
[`& > .${gridClasses.item}:nth-child(-n + 4)`]: {
marginBottom: '-.6em',
},
}}
>
{Object.values(anvil.hosts).map<ReactNode>((host) => {
const { name, serverCount, state, stateProgress, uuid } = host;
const stateColour: string = MAP_TO_HOST_STATE_COLOUR[state] ?? GREY;
let stateValue: string = state;
let servers: ReactNode;
if (['offline', 'online'].includes(state)) {
servers = <MonoText variant="caption">{serverCount}</MonoText>;
} else {
stateValue = `${stateProgress}%`;
}
return [
<Grid item key={`${uuid}-state-label`} xs={7}>
<BodyText variant="caption" whiteSpace="nowrap">
{name}
</BodyText>
</Grid>,
<Grid item key={`${uuid}-state`} xs={5}>
<MonoText inheritColour color={stateColour}>
{stateValue}
</MonoText>
</Grid>,
<Grid item key={`${uuid}-divider`} xs>
<Divider sx={{ marginBottom: '-.4em' }} />
</Grid>,
<Grid item key={`${uuid}-server-label`} width="2.2em">
{servers && <BodyText variant="caption">Servers</BodyText>}
</Grid>,
<Grid
display="flex"
item
justifyContent="flex-end"
key={`${uuid}-server-count`}
width="2em"
>
{servers}
</Grid>,
];
})}
</Grid>
),
[anvil],
);
const cpuSummary = useMemo(
() =>
cpu &&
cpuSubnodes && (
<FlexBox row spacing=".5em">
<FlexBox spacing={0}>
<BodyText variant="caption" whiteSpace="nowrap">
Vendor{' '}
<InlineMonoText sx={{ paddingRight: 0 }}>
{cpuSubnodes[0].vendor}
</InlineMonoText>
</BodyText>
</FlexBox>
<Divider sx={{ flexGrow: 1 }} />
<Grid
alignItems="center"
columns={2}
container
sx={{
width: '3.7em',
[`& > .${gridClasses.item}:nth-child(-n + 2)`]: {
marginBottom: '-.6em',
},
}}
>
<Grid item xs={1}>
<BodyText variant="caption">Cores</BodyText>
</Grid>
<Grid display="flex" item justifyContent="flex-end" xs={1}>
<MonoText variant="caption">{cpu.cores}</MonoText>
</Grid>
<Grid item xs={1}>
<BodyText variant="caption">Threads</BodyText>
</Grid>
<Grid display="flex" item justifyContent="flex-end" xs={1}>
<MonoText variant="caption">{cpu.threads}</MonoText>
</Grid>
</Grid>
</FlexBox>
),
[cpu, cpuSubnodes],
);
const memorySummary = useMemo(
() =>
memory && (
<FlexBox spacing={0}>
<FlexBox row justifyContent="flex-end">
<BodyText mb="-.3em" variant="caption">
Free
<InlineMonoText>
{dSizeStr(memory.total - (memory.reserved + memory.allocated), {
toUnit: 'ibyte',
})}
</InlineMonoText>
/
<InlineMonoText sx={{ paddingRight: 0 }}>
{dSizeStr(memory.total, { toUnit: 'ibyte' })}
</InlineMonoText>
</BodyText>
</FlexBox>
<StackBar
thin
value={{
reserved: {
value: Number((memory.reserved * N_100) / memory.total),
},
allocated: {
value: Number(
((memory.reserved + memory.allocated) * N_100) / memory.total,
),
colour: { 0: BLUE, 70: PURPLE, 90: RED },
},
}}
/>
</FlexBox>
),
[memory],
);
const storeSummary = useMemo(
() =>
storages && (
<FlexBox spacing={0}>
<FlexBox row justifyContent="flex-end">
<BodyText mb="-.3em" variant="caption">
Total free
<InlineMonoText>
{dSizeStr(storages.totalFree, { toUnit: 'ibyte' })}
</InlineMonoText>
/
<InlineMonoText sx={{ paddingRight: 0 }}>
{dSizeStr(storages.totalSize, { toUnit: 'ibyte' })}
</InlineMonoText>
</BodyText>
</FlexBox>
<StackBar
thin
value={{
allocated: {
value: Number(
((storages.totalSize - storages.totalFree) * N_100) /
storages.totalSize,
),
colour: { 0: BLUE, 70: PURPLE, 90: RED },
},
}}
/>
</FlexBox>
),
[storages],
);
return loading ? (
<Spinner mt={0} />
) : (
<Grid
alignItems="center"
columns={4}
container
sx={{
[`& > .${gridClasses.item}:nth-child(odd)`]: {
alignItems: 'center',
display: 'flex',
height: '2.2em',
},
}}
>
<Grid item xs={1}>
<BodyText>Node</BodyText>
</Grid>
<Grid item xs={3}>
{anvilSummary}
</Grid>
<Grid item xs={1}>
<BodyText>Subnodes</BodyText>
</Grid>
<Grid item xs={3}>
{hostsSummary}
</Grid>
<Grid item xs={1}>
<BodyText>CPU</BodyText>
</Grid>
<Grid item xs={3}>
{cpuSummary}
</Grid>
<Grid item xs={1}>
<BodyText>Memory</BodyText>
</Grid>
<Grid item xs={3}>
{memorySummary}
</Grid>
<Grid item xs={1}>
<BodyText>Storage</BodyText>
</Grid>
<Grid item xs={3}>
{storeSummary}
</Grid>
</Grid>
);
};
export default AnvilSummary;

@ -0,0 +1,84 @@
import { gridClasses } from '@mui/material';
import { FC, ReactNode, useMemo } from 'react';
import AnvilSummary from './AnvilSummary';
import { toAnvilOverviewList } from '../../lib/api_converters';
import Grid from '../Grid';
import {
InnerPanel,
InnerPanelBody,
InnerPanelHeader,
Panel,
PanelHeader,
} from '../Panels';
import Spinner from '../Spinner';
import { BodyText, HeaderText } from '../Text';
import useFetch from '../../hooks/useFetch';
const AnvilSummaryList: FC = () => {
const { data: rawAnvils, loading: loadingAnvils } =
useFetch<APIAnvilOverviewArray>('/anvil', { refreshInterval: 5000 });
const anvils = useMemo<APIAnvilOverviewList | undefined>(
() => rawAnvils && toAnvilOverviewList(rawAnvils),
[rawAnvils],
);
const grid = useMemo<ReactNode>(
() =>
anvils && (
<Grid
columns={{ xs: 1, sm: 2, md: 3, xl: 4 }}
layout={Object.values(anvils).reduce<GridLayout>(
(previous, current) => {
const { description, name, uuid } = current;
const key = `anvil-${uuid}`;
previous[key] = {
children: (
<InnerPanel height="100%" mv={0}>
<InnerPanelHeader>
<BodyText
overflow="hidden"
textOverflow="ellipsis"
whiteSpace="nowrap"
>
{name}: {description}
</BodyText>
</InnerPanelHeader>
<InnerPanelBody>
<AnvilSummary anvilUuid={uuid} />
</InnerPanelBody>
</InnerPanel>
),
};
return previous;
},
{},
)}
spacing="1em"
sx={{
alignContent: 'stretch',
[`& > .${gridClasses.item}`]: {
minWidth: '20em',
},
}}
/>
),
[anvils],
);
return (
<Panel>
<PanelHeader>
<HeaderText>Nodes</HeaderText>
</PanelHeader>
{loadingAnvils ? <Spinner /> : grid}
</Panel>
);
};
export default AnvilSummaryList;

@ -1,13 +1,9 @@
import { LinearProgress } from '@mui/material'; import { styled } from '@mui/material';
import { styled } from '@mui/material/styles';
import { PURPLE, RED, BLUE } from '../../lib/consts/DEFAULT_THEME';
import {
PURPLE,
RED,
BLUE,
BORDER_RADIUS,
} from '../../lib/consts/DEFAULT_THEME';
import BorderLinearProgress from './BorderLinearProgress'; import BorderLinearProgress from './BorderLinearProgress';
import Underline from './Underline';
const PREFIX = 'AllocationBar'; const PREFIX = 'AllocationBar';
@ -15,7 +11,6 @@ const classes = {
barOk: `${PREFIX}-barOk`, barOk: `${PREFIX}-barOk`,
barWarning: `${PREFIX}-barWarning`, barWarning: `${PREFIX}-barWarning`,
barAlert: `${PREFIX}-barAlert`, barAlert: `${PREFIX}-barAlert`,
underline: `${PREFIX}-underline`,
}; };
const StyledDiv = styled('div')(() => ({ const StyledDiv = styled('div')(() => ({
@ -30,10 +25,6 @@ const StyledDiv = styled('div')(() => ({
[`& .${classes.barAlert}`]: { [`& .${classes.barAlert}`]: {
backgroundColor: RED, backgroundColor: RED,
}, },
[`& .${classes.underline}`]: {
borderRadius: BORDER_RADIUS,
},
})); }));
const breakpointWarning = 70; const breakpointWarning = 70;
@ -54,11 +45,7 @@ const AllocationBar = ({ allocated }: { allocated: number }): JSX.Element => (
variant="determinate" variant="determinate"
value={allocated} value={allocated}
/> />
<LinearProgress <Underline />
className={classes.underline}
variant="determinate"
value={0}
/>
</StyledDiv> </StyledDiv>
); );

@ -1,14 +1,15 @@
import { LinearProgress } from '@mui/material'; import { LinearProgress, linearProgressClasses, styled } from '@mui/material';
import { styled } from '@mui/material/styles';
import { import { BORDER_RADIUS } from '../../lib/consts/DEFAULT_THEME';
PANEL_BACKGROUND,
BORDER_RADIUS,
} from '../../lib/consts/DEFAULT_THEME';
const BorderLinearProgress = styled(LinearProgress)({ const BorderLinearProgress = styled(LinearProgress)({
height: '1em', backgroundColor: 'transparent',
borderRadius: BORDER_RADIUS, borderRadius: BORDER_RADIUS,
backgroundColor: PANEL_BACKGROUND, height: '1em',
[`& .${linearProgressClasses.bar}`]: {
borderRadius: BORDER_RADIUS,
},
}); });
export default BorderLinearProgress; export default BorderLinearProgress;

@ -1,15 +1,15 @@
import { LinearProgress } from '@mui/material'; import { styled } from '@mui/material';
import { styled } from '@mui/material/styles';
import { PURPLE, BLUE } from '../../lib/consts/DEFAULT_THEME';
import { PURPLE, BLUE, BORDER_RADIUS } from '../../lib/consts/DEFAULT_THEME';
import BorderLinearProgress from './BorderLinearProgress'; import BorderLinearProgress from './BorderLinearProgress';
import Underline from './Underline';
const PREFIX = 'ProgressBar'; const PREFIX = 'ProgressBar';
const classes = { const classes = {
barOk: `${PREFIX}-barOk`, barOk: `${PREFIX}-barOk`,
barInProgress: `${PREFIX}-barInProgress`, barInProgress: `${PREFIX}-barInProgress`,
underline: `${PREFIX}-underline`,
}; };
const StyledDiv = styled('div')(() => ({ const StyledDiv = styled('div')(() => ({
@ -20,10 +20,6 @@ const StyledDiv = styled('div')(() => ({
[`& .${classes.barInProgress}`]: { [`& .${classes.barInProgress}`]: {
backgroundColor: PURPLE, backgroundColor: PURPLE,
}, },
[`& .${classes.underline}`]: {
borderRadius: BORDER_RADIUS,
},
})); }));
const completed = 100; const completed = 100;
@ -44,11 +40,7 @@ const ProgressBar = ({
variant="determinate" variant="determinate"
value={progressPercentage} value={progressPercentage}
/> />
<LinearProgress <Underline />
className={classes.underline}
variant="determinate"
value={0}
/>
</StyledDiv> </StyledDiv>
); );

@ -0,0 +1,93 @@
import { Box, linearProgressClasses, styled } from '@mui/material';
import { FC, ReactElement, createElement, useMemo } from 'react';
import { GREY } from '../../lib/consts/DEFAULT_THEME';
import RoundedLinearProgress from './BorderLinearProgress';
import Underline from './Underline';
const ThinRoundedLinearProgress = styled(RoundedLinearProgress)({
height: '.4em',
});
const ThinUnderline = styled(Underline)({
height: '.2em',
});
const StackBar: FC<StackBarProps> = (props) => {
const { barProps = {}, thin, underlineProps, value } = props;
const { sx: barSx, ...restBarProps } = barProps;
const values = useMemo<Record<string, StackBarValue>>(
() => ('value' in value ? { default: value as StackBarValue } : value),
[value],
);
const entries = useMemo<[string, StackBarValue][]>(
() => Object.entries(values).reverse(),
[values],
);
const creatableBar = useMemo(
() => (thin ? ThinRoundedLinearProgress : RoundedLinearProgress),
[thin],
);
const creatableUnderline = useMemo(
() => (thin ? ThinUnderline : Underline),
[thin],
);
const bars = useMemo<ReactElement[]>(
() =>
entries.map<ReactElement>(
([id, { colour = GREY, value: val }], index) => {
const backgroundColor =
typeof colour === 'string'
? colour
: Object.entries(colour).findLast(
([mark]) => val >= Number(mark),
)?.[1] ?? GREY;
let position: 'absolute' | 'relative' = 'relative';
let top: 0 | undefined;
let width: string | undefined;
if (index) {
position = 'absolute';
top = 0;
width = '100%';
}
return createElement(creatableBar, {
key: `stack-bar-${id}`,
sx: {
position,
top,
width,
[`& .${linearProgressClasses.bar}`]: {
backgroundColor,
},
...barSx,
},
variant: 'determinate',
value: val,
...restBarProps,
});
},
),
[barSx, entries, creatableBar, restBarProps],
);
return (
<Box position="relative">
{bars}
{createElement(creatableUnderline, underlineProps)}
</Box>
);
};
export default StackBar;

@ -0,0 +1,13 @@
import { Box, styled } from '@mui/material';
import { BORDER_RADIUS, DISABLED } from '../../lib/consts/DEFAULT_THEME';
const Underline = styled(Box)({
backgroundColor: DISABLED,
borderRadius: BORDER_RADIUS,
display: 'block',
height: '4px',
position: 'relative',
});
export default Underline;

@ -1,4 +1,5 @@
import AllocationBar from './AllocationBar'; import AllocationBar from './AllocationBar';
import ProgressBar from './ProgressBar'; import ProgressBar from './ProgressBar';
import StackBar from './StackBar';
export { AllocationBar, ProgressBar }; export { AllocationBar, ProgressBar, StackBar };

@ -6,6 +6,7 @@ import { UPLOAD_FILE_TYPES } from '../../lib/consts/UPLOAD_FILE_TYPES';
import AddFileForm from './AddFileForm'; import AddFileForm from './AddFileForm';
import api from '../../lib/api'; import api from '../../lib/api';
import { toAnvilOverviewList } from '../../lib/api_converters';
import ConfirmDialog from '../ConfirmDialog'; import ConfirmDialog from '../ConfirmDialog';
import { DialogWithHeader } from '../Dialog'; import { DialogWithHeader } from '../Dialog';
import Divider from '../Divider'; import Divider from '../Divider';
@ -23,41 +24,6 @@ import useConfirmDialogProps from '../../hooks/useConfirmDialogProps';
import useFetch from '../../hooks/useFetch'; import useFetch from '../../hooks/useFetch';
import useProtectedState from '../../hooks/useProtectedState'; import useProtectedState from '../../hooks/useProtectedState';
const toAnvilOverviewHostList = (
data: APIAnvilOverviewArray[number]['hosts'],
) =>
data.reduce<APIAnvilOverview['hosts']>(
(previous, { hostName: name, hostType: type, hostUUID: uuid }) => {
previous[uuid] = { name, type, uuid };
return previous;
},
{},
);
const toAnvilOverviewList = (data: APIAnvilOverviewArray) =>
data.reduce<APIAnvilOverviewList>(
(
previous,
{
anvilDescription: description,
anvilName: name,
anvilUUID: uuid,
hosts,
},
) => {
previous[uuid] = {
description,
hosts: toAnvilOverviewHostList(hosts),
name,
uuid,
};
return previous;
},
{},
);
const toFileOverviewList = (rows: string[][]) => const toFileOverviewList = (rows: string[][]) =>
rows.reduce<APIFileOverviewList>((previous, row) => { rows.reduce<APIFileOverviewList>((previous, row) => {
const [uuid, name, size, type, checksum] = row; const [uuid, name, size, type, checksum] = row;

@ -35,14 +35,18 @@ const Memory = (): JSX.Element => {
<BodyText text={`Allocated: ${toBinaryByte(nAllocated)}`} /> <BodyText text={`Allocated: ${toBinaryByte(nAllocated)}`} />
</Box> </Box>
<Box> <Box>
<BodyText text={`Free: ${toBinaryByte(nTotal - nAllocated)}`} /> <BodyText
text={`Free: ${toBinaryByte(
nTotal - (nReserved + nAllocated),
)}`}
/>
</Box> </Box>
</Box> </Box>
<Box display="flex" width="100%"> <Box display="flex" width="100%">
<Box flexGrow={1}> <Box flexGrow={1}>
<AllocationBar <AllocationBar
allocated={Number( allocated={Number(
((nAllocated + nReserved) * BigInt(100)) / nTotal, ((nReserved + nAllocated) * BigInt(100)) / nTotal,
)} )}
/> />
</Box> </Box>

@ -0,0 +1,13 @@
import toAnvilDetail from './toAnvilDetail';
import toAnvilMemoryCalcable from './toAnvilMemoryCalcable';
import toAnvilOverviewHostList from './toAnvilOverviewHostList';
import toAnvilOverviewList from './toAnvilOverviewList';
import toAnvilSharedStorageOverview from './toAnvilSharedStorageOverview';
export {
toAnvilDetail,
toAnvilMemoryCalcable,
toAnvilOverviewHostList,
toAnvilOverviewList,
toAnvilSharedStorageOverview,
};

@ -0,0 +1,39 @@
const toAnvilDetail = (data: AnvilListItem): APIAnvilDetail => {
const {
anvil_name: anvilName,
anvil_state: anvilState,
anvil_uuid: anvilUuid,
hosts: rHosts,
} = data;
const hosts = rHosts.reduce<APIAnvilDetail['hosts']>((previous, current) => {
const {
host_name: hostName,
host_uuid: hostUuid,
maintenance_mode: maintenance,
server_count: serverCount,
state,
state_percent: stateProgress,
} = current;
previous[hostUuid] = {
name: hostName,
maintenance,
serverCount,
state,
stateProgress,
uuid: hostUuid,
};
return previous;
}, {});
return {
hosts,
name: anvilName,
state: anvilState,
uuid: anvilUuid,
};
};
export default toAnvilDetail;

@ -0,0 +1,15 @@
const toAnvilMemoryCalcable = (data: AnvilMemory): AnvilMemoryCalcable => {
const { allocated: rAllocated, reserved: rReserved, total: rTotal } = data;
const allocated = BigInt(rAllocated);
const reserved = BigInt(rReserved);
const total = BigInt(rTotal);
return {
allocated,
reserved,
total,
};
};
export default toAnvilMemoryCalcable;

@ -0,0 +1,13 @@
const toAnvilOverviewHostList = (
data: APIAnvilOverviewArray[number]['hosts'],
): APIAnvilOverview['hosts'] =>
data.reduce<APIAnvilOverview['hosts']>(
(previous, { hostName: name, hostType: type, hostUUID: uuid }) => {
previous[uuid] = { name, type, uuid };
return previous;
},
{},
);
export default toAnvilOverviewHostList;

@ -0,0 +1,28 @@
import toAnvilOverviewHostList from './toAnvilOverviewHostList';
const toAnvilOverviewList = (
data: APIAnvilOverviewArray,
): APIAnvilOverviewList =>
data.reduce<APIAnvilOverviewList>(
(
previous,
{
anvilDescription: description,
anvilName: name,
anvilUUID: uuid,
hosts,
},
) => {
previous[uuid] = {
description,
hosts: toAnvilOverviewHostList(hosts),
name,
uuid,
};
return previous;
},
{},
);
export default toAnvilOverviewList;

@ -0,0 +1,29 @@
const toAnvilSharedStorageOverview = (
data: AnvilSharedStorage,
): APIAnvilSharedStorageOverview => {
const { storage_groups, total_free, total_size } = data;
const totalFree = BigInt(total_free);
const totalSize = BigInt(total_size);
return storage_groups.reduce<APIAnvilSharedStorageOverview>(
(previous, current) => {
const {
storage_group_free: rFree,
storage_group_name: name,
storage_group_total: rSize,
storage_group_uuid: uuid,
} = current;
const free = BigInt(rFree);
const size = BigInt(rSize);
previous.storageGroups[uuid] = { free, name, size, uuid };
return previous;
},
{ storageGroups: {}, totalFree, totalSize },
);
};
export default toAnvilSharedStorageOverview;

@ -1,12 +1,13 @@
import { Add as AddIcon } from '@mui/icons-material';
import { Box, Divider, Grid } from '@mui/material';
import Head from 'next/head'; import Head from 'next/head';
import { NextRouter, useRouter } from 'next/router'; import { NextRouter, useRouter } from 'next/router';
import { FC, useEffect, useRef, useState } from 'react'; import { FC, useEffect, useRef, useState } from 'react';
import { Box, Divider } from '@mui/material';
import { Add as AddIcon } from '@mui/icons-material';
import API_BASE_URL from '../lib/consts/API_BASE_URL'; import API_BASE_URL from '../lib/consts/API_BASE_URL';
import { DIVIDER } from '../lib/consts/DEFAULT_THEME'; import { DIVIDER } from '../lib/consts/DEFAULT_THEME';
import AnvilSummaryList from '../components/Anvils/AnvilSummaryList';
import { Preview } from '../components/Display'; import { Preview } from '../components/Display';
import fetchJSON from '../lib/fetchers/fetchJSON'; import fetchJSON from '../lib/fetchers/fetchJSON';
import Header from '../components/Header'; import Header from '../components/Header';
@ -17,6 +18,7 @@ import { Panel, PanelHeader } from '../components/Panels';
import periodicFetch from '../lib/fetchers/periodicFetch'; import periodicFetch from '../lib/fetchers/periodicFetch';
import ProvisionServerDialog from '../components/ProvisionServerDialog'; import ProvisionServerDialog from '../components/ProvisionServerDialog';
import Spinner from '../components/Spinner'; import Spinner from '../components/Spinner';
import { HeaderText } from '../components/Text';
import { last } from '../lib/time'; import { last } from '../lib/time';
type ServerListItem = ServerOverviewMetadata & { type ServerListItem = ServerOverviewMetadata & {
@ -30,20 +32,11 @@ const createServerPreviewContainer = (
servers: ServerListItem[], servers: ServerListItem[],
router: NextRouter, router: NextRouter,
) => ( ) => (
<Box <Grid
sx={{ alignContent="stretch"
display: 'flex', columns={{ xs: 1, sm: 2, md: 3, xl: 4 }}
flexDirection: 'row', container
flexWrap: 'wrap', spacing="1em"
'& > *': {
width: { xs: '20em', md: '24em' },
},
'& > :not(:last-child)': {
marginRight: '2em',
},
}}
> >
{servers.map( {servers.map(
({ ({
@ -57,43 +50,57 @@ const createServerPreviewContainer = (
serverUUID, serverUUID,
timestamp, timestamp,
}) => ( }) => (
<Preview <Grid
externalPreview={screenshot} item
externalTimestamp={timestamp} key={`${serverUUID}-preview`}
headerEndAdornment={[ sx={{
<Link minWidth: '20em',
href={`/server?uuid=${serverUUID}&server_name=${serverName}&server_state=${serverState}`}
key={`server_list_to_server_${serverUUID}`} '& > div': {
> height: '100%',
{serverName} marginBottom: 0,
</Link>, marginTop: 0,
<Link },
href={`/anvil?anvil_uuid=${anvilUUID}`}
key={`server_list_server_${serverUUID}_to_anvil_${anvilUUID}`}
sx={{
opacity: 0.7,
}}
>
{anvilName}
</Link>,
]}
isExternalLoading={loading}
isExternalPreviewStale={isScreenshotStale}
isFetchPreview={false}
isShowControls={false}
isUseInnerPanel
key={`server-preview-${serverUUID}`}
onClickPreview={() => {
router.push(
`/server?uuid=${serverUUID}&server_name=${serverName}&server_state=${serverState}&vnc=1`,
);
}} }}
serverState={serverState} xs={1}
serverUUID={serverUUID} >
/> <Preview
externalPreview={screenshot}
externalTimestamp={timestamp}
headerEndAdornment={[
<Link
href={`/server?uuid=${serverUUID}&server_name=${serverName}&server_state=${serverState}`}
key={`server_list_to_server_${serverUUID}`}
>
{serverName}
</Link>,
<Link
href={`/anvil?anvil_uuid=${anvilUUID}`}
key={`server_list_server_${serverUUID}_to_anvil_${anvilUUID}`}
sx={{
opacity: 0.7,
}}
>
{anvilName}
</Link>,
]}
isExternalLoading={loading}
isExternalPreviewStale={isScreenshotStale}
isFetchPreview={false}
isShowControls={false}
isUseInnerPanel
onClickPreview={() => {
router.push(
`/server?uuid=${serverUUID}&server_name=${serverName}&server_state=${serverState}&vnc=1`,
);
}}
serverState={serverState}
serverUUID={serverUUID}
/>
</Grid>
), ),
)} )}
</Box> </Grid>
); );
const filterServers = (allServers: ServerListItem[], searchTerm: string) => const filterServers = (allServers: ServerListItem[], searchTerm: string) =>
@ -222,19 +229,20 @@ const Dashboard: FC = () => {
<Spinner /> <Spinner />
) : ( ) : (
<> <>
<PanelHeader> <PanelHeader sx={{ marginBottom: '2em' }}>
<HeaderText>Servers</HeaderText>
<IconButton onClick={() => setIsOpenProvisionServerDialog(true)}>
<AddIcon />
</IconButton>
<OutlinedInput <OutlinedInput
placeholder="Search by server name" placeholder="Search by server name"
onChange={({ target: { value } }) => { onChange={({ target: { value } }) => {
setInputSearchTerm(value); setInputSearchTerm(value);
updateServerList(allServers, value); updateServerList(allServers, value);
}} }}
sx={{ marginRight: '.6em' }} sx={{ minWidth: '16em' }}
value={inputSearchTerm} value={inputSearchTerm}
/> />
<IconButton onClick={() => setIsOpenProvisionServerDialog(true)}>
<AddIcon />
</IconButton>
</PanelHeader> </PanelHeader>
{createServerPreviewContainer(includeServers, router)} {createServerPreviewContainer(includeServers, router)}
{includeServers.length > 0 && ( {includeServers.length > 0 && (
@ -244,6 +252,7 @@ const Dashboard: FC = () => {
</> </>
)} )}
</Panel> </Panel>
<AnvilSummaryList />
<ProvisionServerDialog <ProvisionServerDialog
dialogProps={{ open: isOpenProvisionServerDialog }} dialogProps={{ open: isOpenProvisionServerDialog }}
onClose={() => { onClose={() => {

@ -1,6 +1,16 @@
type AnvilCPU = { type AnvilCPU = {
allocated: number; allocated: number;
cores: number; cores: number;
hosts: {
[hostUuid: string]: {
cores: number;
model: string;
name: string;
threads: number;
uuid: string;
vendor: string;
};
};
threads: number; threads: number;
}; };
@ -10,6 +20,12 @@ type AnvilMemory = {
total: string; total: string;
}; };
type AnvilMemoryCalcable = {
allocated: bigint;
reserved: bigint;
total: bigint;
};
type AnvilNetworkBondLink = { type AnvilNetworkBondLink = {
link_name: string; link_name: string;
link_uuid: string; link_uuid: string;
@ -62,15 +78,18 @@ type AnvilSharedStorageGroup = {
type AnvilSharedStorage = { type AnvilSharedStorage = {
storage_groups: AnvilSharedStorageGroup[]; storage_groups: AnvilSharedStorageGroup[];
total_size: string;
total_free: string;
}; };
type AnvilStatusHost = { type AnvilStatusHost = {
state: 'offline' | 'booted' | 'crmd' | 'in_ccm' | 'online';
host_uuid: string;
host_name: string; host_name: string;
state_percent: number; host_uuid: string;
maintenance_mode: boolean;
server_count: number;
state: 'offline' | 'booted' | 'crmd' | 'in_ccm' | 'online';
state_message: string; state_message: string;
removable: boolean; state_percent: number;
}; };
type AnvilStatus = { type AnvilStatus = {
@ -111,6 +130,37 @@ type APIAnvilOverview = {
uuid: string; uuid: string;
}; };
type APIAnvilDetail = {
hosts: {
[uuid: string]: {
maintenance: boolean;
name: string;
serverCount: number;
state: AnvilStatusHost['state'];
stateProgress: number;
uuid: string;
};
};
name: string;
state: AnvilStatus['anvil_state'];
uuid: string;
};
type APIAnvilOverviewList = { type APIAnvilOverviewList = {
[uuid: string]: APIAnvilOverview; [uuid: string]: APIAnvilOverview;
}; };
type APIAnvilStorageGroupCalcable = {
free: bigint;
name: string;
size: bigint;
uuid: string;
};
type APIAnvilSharedStorageOverview = {
storageGroups: {
[uuid: string]: APIAnvilStorageGroupCalcable;
};
totalFree: bigint;
totalSize: bigint;
};

@ -0,0 +1,3 @@
type AnvilSummaryProps = {
anvilUuid: string;
};

@ -0,0 +1,14 @@
type StackBarValue = {
colour?: string | Record<number, string>;
value: number;
};
type StackBarOptionalProps = {
barProps?: import('@mui/material').LinearProgressProps;
thin?: boolean;
underlineProps?: import('@mui/material').BoxProps;
};
type StackBarProps = StackBarOptionalProps & {
value: StackBarValue | Record<string, StackBarValue>;
};
Loading…
Cancel
Save