| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| import { useState, useEffect, useMemo, useRef } from 'react';
|
| import { useTranslation } from 'react-i18next';
|
| import { API, showError, showSuccess } from '../../helpers';
|
| import { ITEMS_PER_PAGE } from '../../constants';
|
| import { useTableCompactMode } from '../common/useTableCompactMode';
|
|
|
| export const useDeploymentsData = () => {
|
| const { t } = useTranslation();
|
| const [compactMode, setCompactMode] = useTableCompactMode('deployments');
|
| const requestSeq = useRef(0);
|
|
|
|
|
| const [deployments, setDeployments] = useState([]);
|
| const [loading, setLoading] = useState(true);
|
| const [activePage, setActivePage] = useState(1);
|
| const [pageSize, setPageSize] = useState(ITEMS_PER_PAGE);
|
| const [searching, setSearching] = useState(false);
|
| const [deploymentCount, setDeploymentCount] = useState(0);
|
| const [query, setQuery] = useState({ keyword: '', status: '' });
|
|
|
|
|
| const [showEdit, setShowEdit] = useState(false);
|
| const [editingDeployment, setEditingDeployment] = useState({
|
| id: undefined,
|
| });
|
|
|
|
|
| const [selectedKeys, setSelectedKeys] = useState([]);
|
| const rowSelection = {
|
| getCheckboxProps: (record) => ({
|
| name: record.deployment_name,
|
| }),
|
| selectedRowKeys: selectedKeys.map((deployment) => deployment.id),
|
| onChange: (selectedRowKeys, selectedRows) => {
|
| setSelectedKeys(selectedRows);
|
| },
|
| };
|
|
|
|
|
| const formInitValues = {
|
| searchKeyword: '',
|
| searchStatus: '',
|
| };
|
|
|
|
|
|
|
| const extractItems = (payload) => {
|
| const items = payload?.items || payload || [];
|
| return Array.isArray(items) ? items : [];
|
| };
|
|
|
|
|
| const [formApi, setFormApi] = useState(null);
|
|
|
|
|
| const getFormValues = () => formApi?.getValues() || formInitValues;
|
|
|
|
|
| const closeEdit = () => {
|
| setShowEdit(false);
|
| setTimeout(() => {
|
| setEditingDeployment({ id: undefined });
|
| }, 500);
|
| };
|
|
|
| const normalizeQuery = (terms) => {
|
| const keyword = (terms?.searchKeyword ?? '').trim();
|
| const status = (terms?.searchStatus ?? '').trim();
|
| return { keyword, status };
|
| };
|
|
|
|
|
| const COLUMN_KEYS = useMemo(
|
| () => ({
|
| id: 'id',
|
| status: 'status',
|
| provider: 'provider',
|
| container_name: 'container_name',
|
| time_remaining: 'time_remaining',
|
| hardware_info: 'hardware_info',
|
| created_at: 'created_at',
|
| actions: 'actions',
|
|
|
| deployment_name: 'deployment_name',
|
| model_name: 'model_name',
|
| instance_count: 'instance_count',
|
| resource_config: 'resource_config',
|
| updated_at: 'updated_at',
|
| }),
|
| [],
|
| );
|
|
|
| const ensureRequiredColumns = (columns = {}) => {
|
| const normalized = {
|
| ...columns,
|
| [COLUMN_KEYS.container_name]: true,
|
| [COLUMN_KEYS.actions]: true,
|
| };
|
|
|
| if (normalized[COLUMN_KEYS.provider] === undefined) {
|
| normalized[COLUMN_KEYS.provider] = true;
|
| }
|
|
|
| return normalized;
|
| };
|
|
|
| const [visibleColumns, setVisibleColumnsState] = useState(() => {
|
| const saved = localStorage.getItem('deployments_visible_columns');
|
| if (saved) {
|
| try {
|
| const parsed = JSON.parse(saved);
|
| return ensureRequiredColumns(parsed);
|
| } catch (e) {
|
| console.error('Failed to parse saved column visibility:', e);
|
| }
|
| }
|
| return ensureRequiredColumns({
|
| [COLUMN_KEYS.container_name]: true,
|
| [COLUMN_KEYS.status]: true,
|
| [COLUMN_KEYS.provider]: true,
|
| [COLUMN_KEYS.time_remaining]: true,
|
| [COLUMN_KEYS.hardware_info]: true,
|
| [COLUMN_KEYS.created_at]: true,
|
| [COLUMN_KEYS.actions]: true,
|
|
|
| [COLUMN_KEYS.deployment_name]: false,
|
| [COLUMN_KEYS.model_name]: false,
|
| [COLUMN_KEYS.instance_count]: false,
|
| [COLUMN_KEYS.resource_config]: false,
|
| [COLUMN_KEYS.updated_at]: false,
|
| });
|
| });
|
|
|
|
|
| const [showColumnSelector, setShowColumnSelector] = useState(false);
|
|
|
|
|
| const saveColumnVisibility = (newVisibleColumns) => {
|
| const normalized = ensureRequiredColumns(newVisibleColumns);
|
| localStorage.setItem(
|
| 'deployments_visible_columns',
|
| JSON.stringify(normalized),
|
| );
|
| setVisibleColumnsState(normalized);
|
| };
|
|
|
| const applyDeploymentsData = ({ data, page }) => {
|
| const items = extractItems(data);
|
| setActivePage(data?.page ?? page);
|
| setDeploymentCount(data?.total ?? items.length);
|
| setSelectedKeys([]);
|
| setDeployments(
|
| items.map((deployment) => ({ ...deployment, key: deployment.id })),
|
| );
|
| };
|
|
|
| const fetchDeployments = async ({ page, size, keyword, status }) => {
|
| const seq = ++requestSeq.current;
|
| const isSearchMode = Boolean(keyword) || Boolean(status);
|
|
|
| if (isSearchMode) {
|
| setSearching(true);
|
| } else {
|
| setLoading(true);
|
| }
|
|
|
| try {
|
| let url;
|
| if (isSearchMode) {
|
| const params = new URLSearchParams({
|
| p: String(page),
|
| page_size: String(size),
|
| });
|
|
|
| if (keyword) params.append('keyword', keyword);
|
| if (status) params.append('status', status);
|
|
|
| url = `/api/deployments/search?${params.toString()}`;
|
| } else {
|
| url = `/api/deployments/?p=${page}&page_size=${size}`;
|
| }
|
|
|
| const res = await API.get(url);
|
| if (seq !== requestSeq.current) return;
|
|
|
| const { success, message, data } = res.data;
|
| if (!success) {
|
| showError(message);
|
| setDeployments([]);
|
| setDeploymentCount(0);
|
| return;
|
| }
|
|
|
| applyDeploymentsData({ data, page });
|
| } catch (error) {
|
| if (seq !== requestSeq.current) return;
|
| console.error(error);
|
| showError(isSearchMode ? t('搜索失败') : t('获取部署列表失败'));
|
| setDeployments([]);
|
| setDeploymentCount(0);
|
| } finally {
|
| if (seq !== requestSeq.current) return;
|
| setLoading(false);
|
| setSearching(false);
|
| }
|
| };
|
|
|
|
|
| const refresh = async (page = activePage) => {
|
| await fetchDeployments({
|
| page,
|
| size: pageSize,
|
| keyword: query.keyword,
|
| status: query.status,
|
| });
|
| };
|
|
|
|
|
| const handlePageChange = (page) => {
|
| setActivePage(page);
|
| fetchDeployments({
|
| page,
|
| size: pageSize,
|
| keyword: query.keyword,
|
| status: query.status,
|
| });
|
| };
|
|
|
|
|
| const handlePageSizeChange = (size) => {
|
| setPageSize(size);
|
| setActivePage(1);
|
| fetchDeployments({
|
| page: 1,
|
| size,
|
| keyword: query.keyword,
|
| status: query.status,
|
| });
|
| };
|
|
|
| const loadDeployments = async (page = 1, size = pageSize) => {
|
| await fetchDeployments({
|
| page,
|
| size,
|
| keyword: query.keyword,
|
| status: query.status,
|
| });
|
| };
|
|
|
|
|
| const searchDeployments = async (searchTerms) => {
|
| const nextQuery = normalizeQuery(searchTerms);
|
| setQuery(nextQuery);
|
| setActivePage(1);
|
| await fetchDeployments({
|
| page: 1,
|
| size: pageSize,
|
| keyword: nextQuery.keyword,
|
| status: nextQuery.status,
|
| });
|
| };
|
|
|
|
|
| const startDeployment = async (deploymentId) => {
|
| try {
|
| const res = await API.post(`/api/deployments/${deploymentId}/start`);
|
| if (res.data.success) {
|
| showSuccess(t('部署启动成功'));
|
| await refresh();
|
| } else {
|
| showError(res.data.message);
|
| }
|
| } catch (error) {
|
| console.error(error);
|
| showError(t('启动部署失败'));
|
| }
|
| };
|
|
|
| const restartDeployment = async (deploymentId) => {
|
| try {
|
| const res = await API.post(`/api/deployments/${deploymentId}/restart`);
|
| if (res.data.success) {
|
| showSuccess(t('部署重启成功'));
|
| await refresh();
|
| } else {
|
| showError(res.data.message);
|
| }
|
| } catch (error) {
|
| console.error(error);
|
| showError(t('重启部署失败'));
|
| }
|
| };
|
|
|
| const deleteDeployment = async (deploymentId) => {
|
| try {
|
| const res = await API.delete(`/api/deployments/${deploymentId}`);
|
| if (res.data.success) {
|
| showSuccess(t('部署删除成功'));
|
| await refresh();
|
| } else {
|
| showError(res.data.message);
|
| }
|
| } catch (error) {
|
| console.error(error);
|
| showError(t('删除部署失败'));
|
| }
|
| };
|
|
|
| const syncDeploymentToChannel = async (deployment) => {
|
| if (!deployment?.id) {
|
| showError(t('同步渠道失败:缺少部署信息'));
|
| return;
|
| }
|
|
|
| try {
|
| const containersResp = await API.get(
|
| `/api/deployments/${deployment.id}/containers`,
|
| );
|
| if (!containersResp.data?.success) {
|
| showError(containersResp.data?.message || t('获取容器信息失败'));
|
| return;
|
| }
|
|
|
| const containers = containersResp.data?.data?.containers || [];
|
| const activeContainer = containers.find((ctr) => ctr?.public_url);
|
|
|
| if (!activeContainer?.public_url) {
|
| showError(t('未找到可用的容器访问地址'));
|
| return;
|
| }
|
|
|
| const rawUrl = String(activeContainer.public_url).trim();
|
| const baseUrl = rawUrl.replace(/\/+$/, '');
|
| if (!baseUrl) {
|
| showError(t('容器访问地址无效'));
|
| return;
|
| }
|
|
|
| const baseName =
|
| deployment.container_name ||
|
| deployment.deployment_name ||
|
| deployment.name ||
|
| deployment.id;
|
| const safeName = String(baseName || 'ionet').slice(0, 60);
|
| const channelName = `[IO.NET] ${safeName}`;
|
|
|
| let randomKey;
|
| try {
|
| randomKey =
|
| typeof crypto !== 'undefined' && crypto.randomUUID
|
| ? `ionet-${crypto.randomUUID().replace(/-/g, '')}`
|
| : null;
|
| } catch (err) {
|
| randomKey = null;
|
| }
|
| if (!randomKey) {
|
| randomKey = `ionet-${Math.random().toString(36).slice(2)}${Math.random().toString(36).slice(2)}`;
|
| }
|
|
|
| const otherInfo = {
|
| source: 'ionet',
|
| deployment_id: deployment.id,
|
| deployment_name: safeName,
|
| container_id: activeContainer.container_id || null,
|
| public_url: baseUrl,
|
| };
|
|
|
| const payload = {
|
| mode: 'single',
|
| channel: {
|
| name: channelName,
|
| type: 4,
|
| key: randomKey,
|
| base_url: baseUrl,
|
| group: 'default',
|
| tag: 'ionet',
|
| remark: `[IO.NET] Auto-synced from deployment ${deployment.id}`,
|
| other_info: JSON.stringify(otherInfo),
|
| },
|
| };
|
|
|
| const createResp = await API.post('/api/channel/', payload);
|
| if (createResp.data?.success) {
|
| showSuccess(t('已同步到渠道'));
|
| } else {
|
| showError(createResp.data?.message || t('同步渠道失败'));
|
| }
|
| } catch (error) {
|
| console.error(error);
|
| showError(t('同步渠道失败'));
|
| }
|
| };
|
|
|
| const updateDeploymentName = async (deploymentId, newName) => {
|
| try {
|
| const res = await API.put(`/api/deployments/${deploymentId}/name`, {
|
| name: newName,
|
| });
|
| if (res.data.success) {
|
| showSuccess(t('部署名称更新成功'));
|
| await refresh();
|
| return true;
|
| } else {
|
| showError(res.data.message);
|
| return false;
|
| }
|
| } catch (error) {
|
| console.error(error);
|
| showError(t('更新部署名称失败'));
|
| return false;
|
| }
|
| };
|
|
|
|
|
| const batchDeleteDeployments = async () => {
|
| if (selectedKeys.length === 0) return;
|
|
|
| try {
|
| const ids = selectedKeys.map((deployment) => deployment.id);
|
| const res = await API.post('/api/deployments/batch_delete', { ids });
|
| if (res.data.success) {
|
| showSuccess(t('批量删除成功'));
|
| setSelectedKeys([]);
|
| await refresh();
|
| } else {
|
| showError(res.data.message);
|
| }
|
| } catch (error) {
|
| console.error(error);
|
| showError(t('批量删除失败'));
|
| }
|
| };
|
|
|
|
|
| const handleRow = (record) => ({
|
| onClick: () => {
|
|
|
| },
|
| });
|
|
|
|
|
| useEffect(() => {
|
| loadDeployments();
|
| }, []);
|
|
|
| return {
|
|
|
| deployments,
|
| loading,
|
| searching,
|
| activePage,
|
| pageSize,
|
| deploymentCount,
|
| compactMode,
|
| setCompactMode,
|
|
|
|
|
| selectedKeys,
|
| setSelectedKeys,
|
| rowSelection,
|
|
|
|
|
| showEdit,
|
| setShowEdit,
|
| editingDeployment,
|
| setEditingDeployment,
|
| closeEdit,
|
|
|
|
|
| visibleColumns,
|
| setVisibleColumns: saveColumnVisibility,
|
| showColumnSelector,
|
| setShowColumnSelector,
|
| COLUMN_KEYS,
|
|
|
|
|
| formInitValues,
|
| formApi,
|
| setFormApi,
|
| getFormValues,
|
|
|
|
|
| loadDeployments,
|
| searchDeployments,
|
| refresh,
|
| handlePageChange,
|
| handlePageSizeChange,
|
| handleRow,
|
|
|
|
|
| startDeployment,
|
| restartDeployment,
|
| deleteDeployment,
|
| updateDeploymentName,
|
| syncDeploymentToChannel,
|
|
|
|
|
| batchDeleteDeployments,
|
|
|
|
|
| t,
|
| };
|
| };
|
|
|