chore: check compatibility in edit

This commit is contained in:
jialin
2025-04-01 20:47:52 +08:00
parent a3ee010286
commit f5c4749954
8 changed files with 212 additions and 188 deletions
+6 -12
View File
@@ -5,6 +5,7 @@ import qs from 'query-string';
import { import {
CatalogItem, CatalogItem,
CatalogSpec, CatalogSpec,
EvaluateResult,
EvaluateSpec, EvaluateSpec,
FormData, FormData,
GPUListItem, GPUListItem,
@@ -373,18 +374,11 @@ export async function evaluationsModelSpec(
}, },
options: { token: any } options: { token: any }
) { ) {
return { return request<{ results: EvaluateResult[] }>(`${MODEL_EVALUATIONS}`, {
results: [ method: 'POST',
{ data,
compatible: true cancelToken: options?.token
} });
]
};
// return request<{ results: EvaluateResult[] }>(`${MODEL_EVALUATIONS}`, {
// method: 'POST',
// data,
// cancelToken: options?.token
// });
} }
// export const evaluationsModelSpec = async ( // export const evaluationsModelSpec = async (
@@ -26,6 +26,7 @@ const CloseWrapper = styled.div`
top: 6px; top: 6px;
right: 18px; right: 18px;
cursor: pointer; cursor: pointer;
background-color: var(--ant-color-warning-bg);
`; `;
const MessageWrapper = styled.div` const MessageWrapper = styled.div`
+10 -35
View File
@@ -8,7 +8,6 @@ import { FC, useCallback, useEffect, useRef, useState } from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { import {
backendOptionsMap, backendOptionsMap,
excludeFields,
getSourceRepoConfigValue, getSourceRepoConfigValue,
modelSourceMap modelSourceMap
} from '../config'; } from '../config';
@@ -44,6 +43,7 @@ const FormWrapper = styled.div`
type AddModalProps = { type AddModalProps = {
title: string; title: string;
hasLinuxWorker?: boolean;
action: PageActionType; action: PageActionType;
open: boolean; open: boolean;
source: SourceType; source: SourceType;
@@ -63,6 +63,7 @@ const AddModal: FC<AddModalProps> = (props) => {
open, open,
onOk, onOk,
onCancel, onCancel,
hasLinuxWorker,
source, source,
action, action,
width = 600, width = 600,
@@ -79,6 +80,7 @@ const AddModal: FC<AddModalProps> = (props) => {
handleUpdateWarning, handleUpdateWarning,
setWarningStatus, setWarningStatus,
handleEvaluate, handleEvaluate,
handleOnValuesChange,
checkTokenRef, checkTokenRef,
warningStatus warningStatus
} = useCheckCompatibility(); } = useCheckCompatibility();
@@ -103,14 +105,12 @@ const AddModal: FC<AddModalProps> = (props) => {
const handleOnSelectModel = (item: any, isgguf?: boolean) => { const handleOnSelectModel = (item: any, isgguf?: boolean) => {
setSelectedModel(item); setSelectedModel(item);
form.current?.handleOnSelectModel?.(item); form.current?.handleOnSelectModel?.(item);
console.log('isgguf+++++++', isgguf, item);
if (!isgguf) { if (!isgguf) {
handleShowCompatibleAlert(item.evaluateResult); handleShowCompatibleAlert(item.evaluateResult);
} }
}; };
const handleOnOk = async (allValues: FormData) => { const handleOnOk = async (allValues: FormData) => {
console.log('allValues---------', allValues);
if (submitAnyway.current) { if (submitAnyway.current) {
onOk(allValues); onOk(allValues);
return; return;
@@ -181,38 +181,12 @@ const AddModal: FC<AddModalProps> = (props) => {
} }
}; };
const handleOnValuesChange = async (changedValues: any, allValues: any) => { const onValuesChange = async (changedValues: any, allValues: any) => {
const keys = Object.keys(changedValues); handleOnValuesChange?.({
const isExcludeField = keys.some((key) => excludeFields.includes(key)); changedValues,
const hasValue = keys.every((key) => { allValues,
return !!changedValues[key]; source: props.source
}); });
// let hasExcludeField = false;
// let allFieldsHaveValue = true;
// for (const key of keys) {
// if (excludeFields.includes(key)) {
// hasExcludeField = true;
// break;
// }
// if (!changedValues[key]) {
// allFieldsHaveValue = false;
// }
// }
if (
!isExcludeField &&
hasValue &&
!_.has(changedValues, 'backend') &&
!_.has(changedValues, 'local_path')
) {
const values = form.current?.form.getFieldsValue?.();
const data = getSourceRepoConfigValue(props.source, values);
const evalutionData = await handleEvaluate(data.values);
handleShowCompatibleAlert?.(evalutionData);
}
}; };
const handleCancel = useCallback(() => { const handleCancel = useCallback(() => {
@@ -285,6 +259,7 @@ const AddModal: FC<AddModalProps> = (props) => {
<ColWrapper> <ColWrapper>
<ColumnWrapper> <ColumnWrapper>
<SearchModel <SearchModel
hasLinuxWorker={hasLinuxWorker}
modelSource={props.source} modelSource={props.source}
onSelectModel={handleOnSelectModel} onSelectModel={handleOnSelectModel}
></SearchModel> ></SearchModel>
@@ -382,7 +357,7 @@ const AddModal: FC<AddModalProps> = (props) => {
gpuOptions={props.gpuOptions} gpuOptions={props.gpuOptions}
modelFileOptions={props.modelFileOptions} modelFileOptions={props.modelFileOptions}
onBackendChange={handleBackendChange} onBackendChange={handleBackendChange}
onValuesChange={handleOnValuesChange} onValuesChange={onValuesChange}
></DataForm> ></DataForm>
</> </>
</ColumnWrapper> </ColumnWrapper>
+9 -39
View File
@@ -27,6 +27,7 @@ import SearchInput from './search-input';
import SearchResult from './search-result'; import SearchResult from './search-result';
interface SearchInputProps { interface SearchInputProps {
hasLinuxWorker?: boolean;
modelSource: string; modelSource: string;
isDownload?: boolean; isDownload?: boolean;
setLoadingModel?: (flag: boolean) => void; setLoadingModel?: (flag: boolean) => void;
@@ -36,7 +37,13 @@ interface SearchInputProps {
const SearchModel: React.FC<SearchInputProps> = (props) => { const SearchModel: React.FC<SearchInputProps> = (props) => {
const intl = useIntl(); const intl = useIntl();
const { modelSource, isDownload, setLoadingModel, onSelectModel } = props; const {
modelSource,
isDownload,
hasLinuxWorker,
setLoadingModel,
onSelectModel
} = props;
const [dataSource, setDataSource] = useState<{ const [dataSource, setDataSource] = useState<{
repoOptions: any[]; repoOptions: any[];
loading: boolean; loading: boolean;
@@ -57,12 +64,10 @@ const SearchModel: React.FC<SearchInputProps> = (props) => {
const cacheRepoOptions = useRef<any[]>([]); const cacheRepoOptions = useRef<any[]>([]);
const axiosTokenRef = useRef<any>(null); const axiosTokenRef = useRef<any>(null);
const checkTokenRef = useRef<any>(null); const checkTokenRef = useRef<any>(null);
const evaluateTokenRef = useRef<any>(null);
const searchInputRef = useRef<any>(''); const searchInputRef = useRef<any>('');
const filterGGUFRef = useRef<boolean | undefined>(); const filterGGUFRef = useRef<boolean | undefined>(!hasLinuxWorker);
const filterTaskRef = useRef<string>(''); const filterTaskRef = useRef<string>('');
const timer = useRef<any>(null); const timer = useRef<any>(null);
const workerRef = useRef<any>(null);
const modelFilesSortOptions = useRef<any[]>([ const modelFilesSortOptions = useRef<any[]>([
{ {
label: intl.formatMessage({ id: 'models.sort.trending' }), label: intl.formatMessage({ id: 'models.sort.trending' }),
@@ -219,41 +224,6 @@ const SearchModel: React.FC<SearchInputProps> = (props) => {
} }
}; };
const handleEvaluateWorker = (params: {
list: any[];
modelSource: string;
modelSourceMap: any;
}) => {
console.log('handleEvaluateWorker=======');
const { list, modelSource, modelSourceMap } = params;
workerRef.current?.terminate();
setIsEvaluating(true);
workerRef.current = new Worker(
// @ts-ignore
new URL('../apis/evaluateWorker.ts', import.meta.url)
);
workerRef.current.postMessage({
list,
modelSource,
modelSourceMap
});
workerRef.current.onmessage = function (event: any) {
const { success, resultList } = event.data;
if (success) {
setDataSource((pre) => {
return {
...pre,
repoOptions: resultList
};
});
}
setIsEvaluating(false);
handleOnSelectModel(resultList[0]);
workerRef.current.terminate();
workerRef.current = null;
};
};
const handleOnSearchRepo = async (sortType?: string) => { const handleOnSearchRepo = async (sortType?: string) => {
if (!SUPPORTEDSOURCE.includes(modelSource)) { if (!SUPPORTEDSOURCE.includes(modelSource)) {
return; return;
+8 -1
View File
@@ -180,11 +180,13 @@ const Models: React.FC<ModelsProps> = ({
const [openDeployModal, setOpenDeployModal] = useState<{ const [openDeployModal, setOpenDeployModal] = useState<{
show: boolean; show: boolean;
width: number | string; width: number | string;
hasLinuxWorker?: boolean;
source: SourceType; source: SourceType;
gpuOptions: any[]; gpuOptions: any[];
modelFileOptions?: any[]; modelFileOptions?: any[];
}>({ }>({
show: false, show: false,
hasLinuxWorker: false,
width: 600, width: 600,
source: modelSourceMap.huggingface_value as SourceType, source: modelSourceMap.huggingface_value as SourceType,
gpuOptions: [], gpuOptions: [],
@@ -516,10 +518,14 @@ const Models: React.FC<ModelsProps> = ({
} }
const config = modalConfig[item.key]; const config = modalConfig[item.key];
console.log('modelFileOptions:', modelFileOptions); const hasLinuxWorker = workerList.some(
(worker) => worker.labels?.os === 'linux'
);
if (config) { if (config) {
setOpenDeployModal({ setOpenDeployModal({
...config, ...config,
hasLinuxWorker: hasLinuxWorker,
gpuOptions: gpuDeviceList.current, gpuOptions: gpuDeviceList.current,
modelFileOptions: modelFileOptions modelFileOptions: modelFileOptions
}); });
@@ -835,6 +841,7 @@ const Models: React.FC<ModelsProps> = ({
title={intl.formatMessage({ id: 'models.button.deploy' })} title={intl.formatMessage({ id: 'models.button.deploy' })}
source={openDeployModal.source} source={openDeployModal.source}
width={openDeployModal.width} width={openDeployModal.width}
hasLinuxWorker={openDeployModal.hasLinuxWorker}
gpuOptions={openDeployModal.gpuOptions} gpuOptions={openDeployModal.gpuOptions}
modelFileOptions={openDeployModal.modelFileOptions || []} modelFileOptions={openDeployModal.modelFileOptions || []}
onCancel={handleDeployModalCancel} onCancel={handleDeployModalCancel}
+104 -98
View File
@@ -1,4 +1,3 @@
import AlertBlockInfo from '@/components/alert-info/block';
import IconFont from '@/components/icon-font'; import IconFont from '@/components/icon-font';
import ModalFooter from '@/components/modal-footer'; import ModalFooter from '@/components/modal-footer';
import SealAutoComplete from '@/components/seal-form/auto-complete'; import SealAutoComplete from '@/components/seal-form/auto-complete';
@@ -9,20 +8,23 @@ import { PageAction } from '@/config';
import { PageActionType } from '@/config/types'; import { PageActionType } from '@/config/types';
import useAppUtils from '@/hooks/use-app-utils'; import useAppUtils from '@/hooks/use-app-utils';
import { useIntl } from '@umijs/max'; import { useIntl } from '@umijs/max';
import { Form, Modal, Typography } from 'antd'; import { Button, Form, Modal, Typography } from 'antd';
import _ from 'lodash'; import _ from 'lodash';
import React, { useEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useMemo, useRef } from 'react';
import { import {
backendOptionsMap, backendOptionsMap,
backendTipsList, backendTipsList,
getSourceRepoConfigValue,
localPathTipsList, localPathTipsList,
modelSourceMap, modelSourceMap,
ollamaModelOptions, ollamaModelOptions,
sourceOptions sourceOptions
} from '../config'; } from '../config';
import { FormData, ListItem } from '../config/types'; import { FormData, ListItem } from '../config/types';
import { useCheckCompatibility } from '../hooks';
import AdvanceConfig from './advance-config'; import AdvanceConfig from './advance-config';
import ColumnWrapper from './column-wrapper'; import ColumnWrapper from './column-wrapper';
import CompatibilityAlert from './compatible-alert';
type AddModalProps = { type AddModalProps = {
title: string; title: string;
@@ -51,17 +53,21 @@ const UpdateModal: React.FC<AddModalProps> = (props) => {
onCancel, onCancel,
updateFormInitials: { gpuOptions, isGGUF, data: formData } updateFormInitials: { gpuOptions, isGGUF, data: formData }
} = props || {}; } = props || {};
const {
handleShowCompatibleAlert,
handleUpdateWarning,
setWarningStatus,
handleEvaluate,
generateGPUIds,
handleOnValuesChange,
checkTokenRef,
warningStatus
} = useCheckCompatibility();
const { getRuleMessage } = useAppUtils(); const { getRuleMessage } = useAppUtils();
const [form] = Form.useForm(); const [form] = Form.useForm();
const intl = useIntl(); const intl = useIntl();
const localPathCache = useRef<string>(''); const localPathCache = useRef<string>('');
const [warningStatus, setWarningStatus] = useState<{ const submitAnyway = useRef<boolean>(false);
show: boolean;
message: string;
}>({
show: false,
message: ''
});
const handleSetGPUIds = (backend: string) => { const handleSetGPUIds = (backend: string) => {
if (backend === backendOptionsMap.llamaBox) { if (backend === backendOptionsMap.llamaBox) {
@@ -77,37 +83,30 @@ const UpdateModal: React.FC<AddModalProps> = (props) => {
} }
}; };
const updateShowWarning = (backend: string) => { // trigger from local_path change or backend change
const handleBackendChangeHook = async () => {
const localPath = form.getFieldValue?.('local_path'); const localPath = form.getFieldValue?.('local_path');
const backend = form.getFieldValue?.('backend');
if (formData?.source !== modelSourceMap.local_path_value || !localPath) { const res = handleUpdateWarning?.({
return; backend,
} localPath: localPath,
source: formData?.source as string
const isBlobFile = localPath?.split('/').pop()?.includes('sha256');
const isOllamaModel = localPath?.includes('ollama');
const isGGUFFile = localPath.endsWith('.gguf');
let warningMessage = '';
if (isBlobFile && isOllamaModel && backend === backendOptionsMap.llamaBox) {
warningMessage = '';
} else if (
isBlobFile &&
isOllamaModel &&
backend !== backendOptionsMap.llamaBox
) {
warningMessage = 'models.form.ollama.warning';
} else if (isGGUFFile && backend !== backendOptionsMap.llamaBox) {
warningMessage = 'models.form.backend.warning';
} else if (!isGGUFFile && backend === backendOptionsMap.llamaBox) {
warningMessage = 'models.form.backend.warning.llamabox';
}
setWarningStatus({
show: !!warningMessage,
message: warningMessage
}); });
if (!res.show) {
const values = form.getFieldsValue?.();
const data = getSourceRepoConfigValue(formData?.source as string, values);
const evalutionData = await handleEvaluate(
_.omit(data.values, [
'cpu_offloading',
'distributed_inference_across_workers'
])
);
handleShowCompatibleAlert?.(evalutionData);
} else {
setWarningStatus?.(res);
}
}; };
const handleBackendChange = (val: string) => { const handleBackendChange = (val: string) => {
@@ -119,7 +118,7 @@ const UpdateModal: React.FC<AddModalProps> = (props) => {
} }
form.setFieldValue('backend_version', ''); form.setFieldValue('backend_version', '');
handleSetGPUIds(val); handleSetGPUIds(val);
updateShowWarning(val); handleBackendChangeHook();
}; };
const handleOnFocus = () => { const handleOnFocus = () => {
@@ -289,37 +288,14 @@ const UpdateModal: React.FC<AddModalProps> = (props) => {
form.submit(); form.submit();
}; };
const generateGPUIds = (data: FormData) => { const handleSubmitAnyway = async () => {
const gpu_ids = _.get(data, 'gpu_selector.gpu_ids', []); submitAnyway.current = true;
if (!gpu_ids.length) { form.submit?.();
return {};
}
const result = _.reduce(
gpu_ids,
(acc: string[], item: string | string[], index: number) => {
if (Array.isArray(item)) {
acc.push(item[1]);
} else if (index === 1) {
acc.push(item);
}
return acc;
},
[]
);
if (result.length) {
return {
gpu_selector: {
gpu_ids: result
}
};
}
return {};
}; };
const handleOk = (formdata: FormData) => { const handleOk = async (formdata: FormData) => {
let obj = {}; let obj = {};
let submitData = {} as FormData;
if ( if (
[backendOptionsMap.vllm, backendOptionsMap.voxBox].includes( [backendOptionsMap.vllm, backendOptionsMap.voxBox].includes(
formdata.backend formdata.backend
@@ -332,26 +308,40 @@ const UpdateModal: React.FC<AddModalProps> = (props) => {
} }
if (formdata.scheduleType === 'manual') { if (formdata.scheduleType === 'manual') {
const gpuSelector = generateGPUIds(formdata); const gpuSelector = generateGPUIds(formdata);
onOk({ submitData = {
..._.omit(formdata, ['scheduleType']), ..._.omit(formdata, ['scheduleType']),
categories: formdata.categories ? [formdata.categories] : [], categories: formdata.categories ? [formdata.categories] : [],
worker_selector: null, worker_selector: null,
gpu_selector: formdata.gpu_selector?.gpu_ids?.length
? {
gpu_ids: formdata.gpu_selector.gpu_ids
}
: null,
...obj, ...obj,
...gpuSelector ...gpuSelector
}); };
} else { } else {
onOk({ submitData = {
..._.omit(formdata, ['scheduleType']), ..._.omit(formdata, ['scheduleType']),
categories: formdata.categories ? [formdata.categories] : [], categories: formdata.categories ? [formdata.categories] : [],
gpu_selector: null, gpu_selector: null,
...obj ...obj
}); };
} }
if (submitAnyway.current) {
onOk(submitData);
return;
}
const evalutionData = await handleEvaluate(submitData);
handleShowCompatibleAlert?.(evalutionData);
if (evalutionData?.compatible) {
onOk(submitData);
}
};
const onValuesChange = (changedValues: any, allValues: any) => {
handleOnValuesChange({
changedValues,
allValues,
source: formData?.source as string
});
}; };
const handleOnClose = () => { const handleOnClose = () => {
@@ -363,6 +353,7 @@ const UpdateModal: React.FC<AddModalProps> = (props) => {
form.setFieldsValue(formData); form.setFieldsValue(formData);
} }
if (!open) { if (!open) {
checkTokenRef.current?.cancel?.();
setWarningStatus({ setWarningStatus({
show: false, show: false,
message: '' message: ''
@@ -401,40 +392,55 @@ const UpdateModal: React.FC<AddModalProps> = (props) => {
}} }}
footer={ footer={
<> <>
<ModalFooter onCancel={onCancel} onOk={handleSumit}></ModalFooter> <ModalFooter
onCancel={onCancel}
onOk={handleSumit}
showOkBtn={!warningStatus.show}
extra={
warningStatus.show && (
<Button
type="primary"
onClick={handleSubmitAnyway}
style={{ width: '130px' }}
>
{intl.formatMessage({
id: 'models.form.submit.anyway'
})}
</Button>
)
}
></ModalFooter>
</> </>
} }
> >
<ColumnWrapper <ColumnWrapper
maxHeight={550} maxHeight={550}
paddingBottom={warningStatus.show ? 70 : 0} paddingBottom={
warningStatus.show
? Array.isArray(warningStatus.message)
? 100
: 70
: 0
}
footer={ footer={
<div style={{ paddingInline: 12 }}> <CompatibilityAlert
{warningStatus.show && ( showClose={true}
<AlertBlockInfo onClose={() => {
ellipsis={false} setWarningStatus({
message={ show: false,
<span message: ''
dangerouslySetInnerHTML={{ });
__html: intl.formatMessage({ }}
id: warningStatus.message warningStatus={warningStatus}
}) contentStyle={{ paddingInline: 0 }}
}} ></CompatibilityAlert>
></span>
}
title={intl.formatMessage({
id: 'common.text.tips'
})}
type="warning"
></AlertBlockInfo>
)}
</div>
} }
> >
<Form <Form
name="addModalForm" name="addModalForm"
form={form} form={form}
onFinish={handleOk} onFinish={handleOk}
onValuesChange={onValuesChange}
preserve={false} preserve={false}
clearOnDestroy={true} clearOnDestroy={true}
initialValues={{ initialValues={{
+73 -2
View File
@@ -8,13 +8,21 @@ import { useEffect, useRef, useState } from 'react';
import { evaluationsModelSpec, queryGPUList } from '../apis'; import { evaluationsModelSpec, queryGPUList } from '../apis';
import { import {
backendOptionsMap, backendOptionsMap,
excludeFields,
getSourceRepoConfigValue,
modelSourceMap, modelSourceMap,
setSourceRepoConfigValue setSourceRepoConfigValue
} from '../config'; } from '../config';
import { EvaluateResult, GPUListItem, ListItem } from '../config/types'; import {
EvaluateResult,
FormData,
GPUListItem,
ListItem
} from '../config/types';
export const useGenerateFormEditInitialValues = () => { export const useGenerateFormEditInitialValues = () => {
const gpuDeviceList = useRef<any[]>([]); const gpuDeviceList = useRef<any[]>([]);
const workerList = useRef<any[]>([]);
const generateCascaderOptions = ( const generateCascaderOptions = (
list: GPUListItem[], list: GPUListItem[],
@@ -75,6 +83,7 @@ export const useGenerateFormEditInitialValues = () => {
]); ]);
const gpuList = generateCascaderOptions(gpuData.items, workerData.items); const gpuList = generateCascaderOptions(gpuData.items, workerData.items);
gpuDeviceList.current = gpuList; gpuDeviceList.current = gpuList;
workerList.current = workerData.items;
return gpuList; return gpuList;
}; };
@@ -115,7 +124,8 @@ export const useGenerateFormEditInitialValues = () => {
return { return {
getGPUList, getGPUList,
generateFormValues, generateFormValues,
gpuDeviceList gpuDeviceList,
workerList
}; };
}; };
@@ -331,6 +341,65 @@ export const useCheckCompatibility = () => {
return warningMessage; return warningMessage;
}; };
const generateGPUIds = (data: FormData) => {
const gpu_ids = _.get(data, 'gpu_selector.gpu_ids', []);
if (!gpu_ids.length) {
return {
gpu_selector: null
};
}
const result = _.reduce(
gpu_ids,
(acc: string[], item: string | string[], index: number) => {
if (Array.isArray(item)) {
acc.push(item[1]);
} else if (index === 1) {
acc.push(item);
}
return acc;
},
[]
);
return {
gpu_selector: {
gpu_ids: result
}
};
};
const handleOnValuesChange = async (params: {
changedValues: any;
allValues: any;
source: string;
}) => {
const { changedValues, allValues, source } = params;
const keys = Object.keys(changedValues);
const isExcludeField = keys.some((key) => excludeFields.includes(key));
const hasValue = keys.every((key) => {
return !!changedValues[key];
});
if (
!isExcludeField &&
hasValue &&
!_.has(changedValues, 'backend') &&
!_.has(changedValues, 'local_path')
) {
const data = getSourceRepoConfigValue(source, allValues);
const gpuSelector = generateGPUIds(data.values);
const evalutionData = await handleEvaluate({
...data.values,
...gpuSelector
});
handleShowCompatibleAlert?.(evalutionData);
}
};
const debounceHandleValuesChange = _.debounce(handleOnValuesChange, 300);
useEffect(() => { useEffect(() => {
return () => { return () => {
checkTokenRef.current?.cancel(); checkTokenRef.current?.cancel();
@@ -341,8 +410,10 @@ export const useCheckCompatibility = () => {
return { return {
handleShowCompatibleAlert, handleShowCompatibleAlert,
handleUpdateWarning, handleUpdateWarning,
handleOnValuesChange: debounceHandleValuesChange,
warningStatus, warningStatus,
checkTokenRef, checkTokenRef,
generateGPUIds,
handleEvaluate, handleEvaluate,
setWarningStatus setWarningStatus
}; };
+1 -1
View File
@@ -61,7 +61,7 @@ export interface ListItem {
name: string; name: string;
hostname: string; hostname: string;
address: string; address: string;
labels: object; labels: Record<string, string>;
state: string; state: string;
ip: string; ip: string;
state_message: string; state_message: string;