Files
Foxel/web/src/pages/AdaptersPage.tsx

307 lines
10 KiB
TypeScript

import { memo, useState, useEffect, useCallback } from 'react';
import { Table, Button, Space, Drawer, Form, Input, Switch, message, Typography, Popconfirm, Select } from 'antd';
import PageCard from '../components/PageCard';
import { adaptersApi, type AdapterItem, type AdapterTypeMeta, type AdapterUsage } from '../api/client';
import { useI18n } from '../i18n';
const formatBytes = (bytes?: number | null) => {
if (bytes === null || bytes === undefined) return '-';
if (bytes === 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / (1024 ** index);
return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
};
const formatUsage = (usage?: AdapterUsage) => {
if (!usage?.supported || usage.used_bytes === null || usage.used_bytes === undefined) return '-';
const used = formatBytes(usage.used_bytes);
if (usage.total_bytes === null || usage.total_bytes === undefined) return used;
return `${used} / ${formatBytes(usage.total_bytes)}`;
};
const AdaptersPage = memo(function AdaptersPage() {
const [loading, setLoading] = useState(false);
const [data, setData] = useState<AdapterItem[]>([]);
const [usageMap, setUsageMap] = useState<Record<number, AdapterUsage>>({});
const [open, setOpen] = useState(false);
const [editing, setEditing] = useState<AdapterItem | null>(null);
const [form] = Form.useForm();
const [availableTypes, setAvailableTypes] = useState<AdapterTypeMeta[]>([]);
const { t } = useI18n();
const fetchList = useCallback(async () => {
setLoading(true);
try {
const [list, types, usages] = await Promise.all([
adaptersApi.list(),
adaptersApi.available(),
adaptersApi.usage()
]);
setData(list);
setAvailableTypes(types);
setUsageMap(Object.fromEntries(usages.map(item => [item.id, item])));
} catch (e: any) {
message.error(e.message || t('Load failed'));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => { fetchList(); }, [fetchList]);
const openCreate = () => {
setEditing(null);
form.resetFields();
const defaultType = availableTypes[0]?.type || 'local';
const typeMeta = availableTypes.find(t => t.type === defaultType);
const cfgDefaults: Record<string, any> = {};
typeMeta?.config_schema.forEach(f => {
if (f.default !== undefined) cfgDefaults[f.key] = f.default;
});
form.setFieldsValue({
name: '',
type: defaultType,
path: '/',
sub_path: '',
enabled: true,
config: cfgDefaults
});
setOpen(true);
};
const openEdit = (rec: AdapterItem) => {
setEditing(rec);
form.resetFields();
form.setFieldsValue({
name: rec.name,
type: rec.type,
path: rec.path || '/',
sub_path: rec.sub_path || '',
enabled: rec.enabled,
config: rec.config || {}
});
setOpen(true);
};
const submit = async () => {
try {
const values = await form.validateFields();
const cfg = values.config || {};
const typeMeta = availableTypes.find(t => t.type === values.type);
const miss: string[] = [];
typeMeta?.config_schema.forEach(f => {
if (f.required && (cfg[f.key] === undefined || cfg[f.key] === null || cfg[f.key] === '')) {
miss.push(f.label || f.key);
}
});
if (miss.length) {
message.error(t('Missing required config:') + ' ' + miss.join(', '));
return;
}
const body = {
name: values.name.trim(),
type: values.type,
path: values.path || '/',
sub_path: values.sub_path?.trim() || null,
enabled: values.enabled,
config: cfg
};
setLoading(true);
if (editing) {
await adaptersApi.update(editing.id, body as any);
message.success(t('Updated successfully'));
} else {
await adaptersApi.create(body as any);
message.success(t('Created successfully'));
}
setOpen(false);
setEditing(null);
fetchList();
} catch (e: any) {
if (e?.errorFields) return; // 表单校验
message.error(e.message || t('Operation failed'));
} finally {
setLoading(false);
}
};
const doDelete = async (rec: AdapterItem) => {
try {
await adaptersApi.remove(rec.id);
message.success(t('Deleted'));
fetchList();
} catch (e: any) {
message.error(e.message || t('Delete failed'));
}
};
const handleToggleEnabled = async (rec: AdapterItem, checked: boolean) => {
try {
setLoading(true);
await adaptersApi.update(rec.id, { ...rec, enabled: checked });
message.success(t('Status updated'));
fetchList();
} catch (e: any) {
message.error(e.message || t('Update failed'));
} finally {
setLoading(false);
}
};
const renderTypeLabel = useCallback((type?: string) => {
if (!type) return '-';
const key = `adapter.type.${type}`;
const label = t(key);
return label === key ? type : label;
}, [t]);
const columns = [
{ title: t('Name'), dataIndex: 'name' },
{ title: t('Type'), dataIndex: 'type', width: 140, render: (value: string) => renderTypeLabel(value) },
{ title: t('Mount Path'), dataIndex: 'path', width: 140, render: (v: string) => v || '-' },
{ title: t('Sub Path'), dataIndex: 'sub_path', width: 140, render: (v: string) => v || '-' },
{
title: t('Capacity Usage'),
width: 180,
render: (_: any, rec: AdapterItem) => {
return formatUsage(usageMap[rec.id]);
}
},
{
title: t('Enabled'),
dataIndex: 'enabled',
width: 80,
render: (v: boolean, rec: AdapterItem) => (
<Switch
checked={v}
size="small"
loading={loading}
onChange={checked => handleToggleEnabled(rec, checked)}
/>
)
},
{
title: t('Actions'),
width: 160,
render: (_: any, rec: AdapterItem) => (
<Space size="small">
<Button size="small" onClick={() => openEdit(rec)}>{t('Edit')}</Button>
<Popconfirm title={t('Confirm delete?')} onConfirm={() => doDelete(rec)}>
<Button size="small" danger>{t('Delete')}</Button>
</Popconfirm>
</Space>
)
}
];
const selectedType = Form.useWatch('type', form);
const currentTypeMeta = availableTypes.find(t => t.type === selectedType);
function renderConfigFields() {
if (!currentTypeMeta) return <Typography.Text type="secondary">{t('No config fields')}</Typography.Text>;
return currentTypeMeta.config_schema.map(field => {
const rules = field.required ? [{ required: true, message: t('Please input {label}', { label: field.label }) }] : [];
let inputNode: any = <Input placeholder={field.placeholder} />;
let valuePropName: string | undefined;
if (field.type === 'password') inputNode = <Input.Password placeholder={field.placeholder} />;
if (field.type === 'number') inputNode = <Input type="number" placeholder={field.placeholder} />;
if (field.type === 'select') {
inputNode = (
<Select
placeholder={field.placeholder}
options={(field.options || []).map(option => ({ value: option, label: t(option) }))}
/>
);
}
if (field.type === 'boolean') {
inputNode = <Switch />;
valuePropName = 'checked';
}
return (
<Form.Item
key={field.key}
name={['config', field.key]}
label={t(field.label)}
rules={rules}
valuePropName={valuePropName}
>
{inputNode}
</Form.Item>
);
});
}
return (
<PageCard
title={t('Storage Adapters')}
extra={
<Space wrap>
<Button onClick={fetchList} loading={loading}>{t('Refresh')}</Button>
<Button type="primary" onClick={openCreate}>{t('Create Adapter')}</Button>
</Space>
}
>
<Table
rowKey="id"
dataSource={data}
columns={columns as any}
loading={loading}
pagination={false}
scroll={{ x: 'max-content' }}
style={{ marginBottom: 0 }}
/>
<Drawer
title={editing ? `${t('Edit')}: ${editing.name}` : t('Create Adapter')}
width={480}
open={open}
onClose={() => { setOpen(false); setEditing(null); }}
destroyOnHidden
extra={
<Space>
<Button onClick={() => { setOpen(false); setEditing(null); }}>{t('Cancel')}</Button>
<Button type="primary" onClick={submit} loading={loading}>{t('Submit')}</Button>
</Space>
}
>
<Form
form={form}
layout="vertical"
initialValues={{ enabled: true }}
>
<Form.Item name="name" label={t('Name')} rules={[{ required: true, message: t('Please input {label}', { label: t('Name') }) }]}>
<Input placeholder={t('Unique name')} />
</Form.Item>
<Form.Item name="type" label={t('Type')} rules={[{ required: true }]}>
<Select
placeholder={t('Select adapter type')}
options={availableTypes.map(t => ({ value: t.type, label: renderTypeLabel(t.type) }))}
onChange={(value) => {
const t = availableTypes.find(v => v.type === value);
const cfgDefaults: Record<string, any> = {};
t?.config_schema.forEach(f => {
if (f.default !== undefined) cfgDefaults[f.key] = f.default;
});
form.setFieldsValue({ config: cfgDefaults });
}}
/>
</Form.Item>
<Form.Item name="path" label={t('Mount Path')} rules={[{ required: true, message: t('Please input {label}', { label: t('Mount Path') }) }]}>
<Input placeholder={t('/ or /drive')} />
</Form.Item>
<Form.Item name="sub_path" label={t('Sub Path (optional)')}>
<Input placeholder={t('Sub directory inside adapter')} />
</Form.Item>
<Form.Item name="enabled" label={t('Enabled')} valuePropName="checked">
<Switch />
</Form.Item>
<Typography.Title level={5} style={{ marginTop: 8, fontSize: 14 }}>{t('Adapter Config')}</Typography.Title>
{renderConfigFields()}
</Form>
</Drawer>
</PageCard>
);
});
export default AdaptersPage;