Files
aiops/frontend/src/components/cloud/CloudAccountManager.tsx
T

309 lines
9.5 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Plus, Search, Key, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { cloudApi } from '@/api';
import { CloudAccountDetailSheet } from './CloudAccountDetailSheet';
import { CloudServerImport } from '@/components/server/CloudServerImport';
import { PageShell } from '@/components/layout/PageShell';
import { PageLoading } from '@/components/layout/PageLoading';
import { PageEmpty } from '@/components/layout/PageEmpty';
import { StatStrip } from '@/components/ui/stat-strip';
import type { JsonData } from '@/types/common';
import {
type AccountFormData,
type ActionLoading,
getInitialFormData,
buildFormDataFromAccount,
computeAccountStats,
filterAccounts,
getProviderLabels,
} from './cloud-account/types';
import { AccountCard } from './cloud-account/AccountCard';
import { AccountFormDialog } from './cloud-account/AccountFormDialog';
export function CloudAccountManager() {
const { t } = useTranslation();
const [accounts, setAccounts] = useState<JsonData[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [selectedProvider, setSelectedProvider] = useState<string>('all');
// 创建/编辑账号表单状态
const [formOpen, setFormOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [editingAccount, setEditingAccount] = useState<JsonData | null>(null);
const [formData, setFormData] = useState<AccountFormData>(getInitialFormData());
const [detailAccount, setDetailAccount] = useState<JsonData | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [importAccountId, setImportAccountId] = useState<number | string | undefined>();
const [actionLoading, setActionLoading] = useState<ActionLoading>({});
const providerLabels = getProviderLabels(t);
const setCardAction = (accountId: string | number, action: string | null) => {
setActionLoading((prev) => {
const next = { ...prev };
const key = String(accountId);
if (action) next[key] = action;
else delete next[key];
return next;
});
};
const openDetail = (account: JsonData) => {
setDetailAccount(account);
setDetailOpen(true);
};
const openImport = (account: JsonData) => {
setImportAccountId(account.id);
setImportOpen(true);
};
const handleQuickSyncRegions = async (account: JsonData) => {
setCardAction(account.id, 'regions');
try {
await cloudApi.syncRegions(account.id);
toast.success(t('cloud.syncRegionsSuccess'));
loadData();
} catch (e) {
toast.error(t('cloud.syncRegionsFailed'), {
description: e instanceof Error ? e.message : String(e),
});
} finally {
setCardAction(account.id, null);
}
};
const handleQuickSyncBilling = async (account: JsonData) => {
setCardAction(account.id, 'billing');
try {
const res = await cloudApi.syncBilling(account.id);
const errs = res?.errors || [];
if (errs.length > 0) {
toast.warning(t('cloud.syncBillingPartial'), { description: errs.join('\n') });
} else {
toast.success(t('cloud.syncBillingSuccess'), {
description: t('cloud.syncBillingResult', {
date: res.billing_date,
created: res.created,
updated: res.updated,
}),
});
}
loadData();
} catch (e) {
toast.error(t('cloud.syncBillingFailed'), {
description: e instanceof Error ? e.message : String(e),
});
} finally {
setCardAction(account.id, null);
}
};
const resetForm = () => {
setEditingAccount(null);
setFormData(getInitialFormData());
};
const handleOpenCreate = () => {
resetForm();
setFormOpen(true);
};
const handleOpenEdit = (account: JsonData) => {
setEditingAccount(account);
setFormData(buildFormDataFromAccount(account));
setFormOpen(true);
};
const handleSubmit = async () => {
if (!formData.name.trim() || !formData.provider.trim()) return;
try {
setSubmitting(true);
const payload: JsonData = {
name: formData.name.trim(),
provider: formData.provider,
access_key: formData.access_key.trim(),
secret_key: formData.secret_key.trim(),
monthly_cost: Number(formData.monthly_cost) || 0,
status: formData.status,
};
if (editingAccount) {
await cloudApi.updateAccount(editingAccount.id, payload);
} else {
await cloudApi.createAccount(payload);
}
setFormOpen(false);
loadData();
} catch (e) {
console.error(editingAccount ? 'update account' : 'create account', e);
} finally {
setSubmitting(false);
}
};
const loadData = async () => {
try {
setLoading(true);
const res: JsonData = await cloudApi.getAccounts();
setAccounts(res?.list || []);
} catch (_e) {
setAccounts([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleDelete = async (id: string) => {
try {
await cloudApi.deleteAccount(id);
loadData();
} catch (_e) {
// silently ignored
}
};
const filteredAccounts = filterAccounts(accounts, searchQuery, selectedProvider);
const stats = computeAccountStats(accounts);
const toolbar = (
<div className="flex items-center gap-4 flex-wrap">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 dg-text-muted" />
<Input
placeholder={t('cloud.searchAccount')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 h-9 dg-input"
/>
</div>
<select
value={selectedProvider}
onChange={(e) => setSelectedProvider(e.target.value)}
className="px-3 py-2 h-9 rounded-md text-sm dg-input"
>
<option value="all">{t('cloud.allProviders')}</option>
<option value="aws">AWS</option>
<option value="aliyun">{t('cloud.aliyun')}</option>
<option value="tencent">{t('cloud.tencent')}</option>
<option value="huawei">{t('cloud.huawei')}</option>
<option value="azure">Azure</option>
</select>
</div>
);
return (
<PageShell
variant="list"
title={t('cloud.cloudAccounts')}
subtitle={t('cloud.accountsSubtitle')}
icon={<Key className="h-5 w-5" />}
metrics={
!loading ? (
<StatStrip
items={[
{ label: t('common.total'), value: stats.total, color: 'blue' },
{ label: t('cloud.normal'), value: stats.active, color: 'green' },
{ label: t('cloud.disabled'), value: stats.inactive, color: 'muted' },
{
label: t('cloud.error'),
value: stats.error,
color: stats.error > 0 ? 'red' : 'muted',
},
]}
/>
) : undefined
}
toolbar={toolbar}
actions={
<>
<Button variant="outline" size="sm" onClick={loadData} className="dg-btn-secondary">
<RefreshCw className={`h-4 w-4 mr-1 ${loading ? 'animate-spin' : ''}`} />
{t('common.refresh')}
</Button>
<Button size="sm" className="dg-btn-primary" onClick={handleOpenCreate}>
<Plus className="h-4 w-4 mr-1" />
{t('cloud.addAccount')}
</Button>
</>
}
>
{loading ? (
<PageLoading />
) : filteredAccounts.length === 0 ? (
<PageEmpty
icon={<Key className="h-12 w-12" />}
title={t('cloud.noAccountData')}
action={
<Button size="sm" className="dg-btn-primary" onClick={handleOpenCreate}>
<Plus className="h-4 w-4 mr-1" />
{t('cloud.addAccount')}
</Button>
}
/>
) : (
<div className="grid grid-cols-2 gap-4">
{filteredAccounts.map((account) => (
<AccountCard
key={account.id}
account={account}
providerLabels={providerLabels}
actionLoading={actionLoading}
onEdit={handleOpenEdit}
onDelete={handleDelete}
onDetail={openDetail}
onSyncRegions={handleQuickSyncRegions}
onSyncBilling={handleQuickSyncBilling}
onImport={openImport}
/>
))}
</div>
)}
<AccountFormDialog
open={formOpen}
onOpenChange={setFormOpen}
editingAccount={editingAccount}
formData={formData}
onFormDataChange={setFormData}
submitting={submitting}
onSubmit={handleSubmit}
/>
<CloudAccountDetailSheet
account={detailAccount}
open={detailOpen}
onOpenChange={setDetailOpen}
providerLabels={providerLabels}
onSyncRegions={loadData}
onSyncBilling={loadData}
onImportHosts={() => {
if (detailAccount) {
setDetailOpen(false);
openImport(detailAccount);
}
}}
/>
<CloudServerImport
open={importOpen}
initialAccountId={importAccountId}
onClose={() => {
setImportOpen(false);
setImportAccountId(undefined);
loadData();
}}
/>
</PageShell>
);
}