diff --git a/atsf_server/web/features/settings/api/settings.ts b/atsf_server/web/features/settings/api/settings.ts index 74320e60..00151b4b 100644 --- a/atsf_server/web/features/settings/api/settings.ts +++ b/atsf_server/web/features/settings/api/settings.ts @@ -1,6 +1,7 @@ import { apiRequest } from '@/lib/api/client'; import type { + BootstrapTokenPayload, OptionItem, SettingsProfile, UpdateSelfPayload, @@ -17,6 +18,16 @@ export function updateOption(key: string, value: string) { }); } +export function getBootstrapToken() { + return apiRequest('/nodes/bootstrap-token'); +} + +export function rotateBootstrapToken() { + return apiRequest('/nodes/bootstrap-token/rotate', { + method: 'POST', + }); +} + export function getSettingsProfile() { return apiRequest('/user/self'); } diff --git a/atsf_server/web/features/settings/components/settings-page.tsx b/atsf_server/web/features/settings/components/settings-page.tsx index 4f911f3e..806feb3d 100644 --- a/atsf_server/web/features/settings/components/settings-page.tsx +++ b/atsf_server/web/features/settings/components/settings-page.tsx @@ -18,12 +18,18 @@ import { bindEmail, bindWeChat, generateAccessToken, + getBootstrapToken, getOptions, getSettingsProfile, + rotateBootstrapToken, updateOption, updateSelf, } from '@/features/settings/api/settings'; -import type { OptionItem, UpdateSelfPayload } from '@/features/settings/types'; +import type { + BootstrapTokenPayload, + OptionItem, + UpdateSelfPayload, +} from '@/features/settings/types'; import { CodeBlock, PrimaryButton, @@ -36,6 +42,8 @@ import { import { formatDateTime } from '@/lib/utils/date'; const settingsQueryKey = ['settings', 'options'] as const; +const installerScriptUrl = + 'https://raw.githubusercontent.com/Rain-kl/ATSFlare/main/scripts/install-agent.sh'; const defaultSystemFields = { ServerAddress: '', @@ -124,6 +132,14 @@ function normalizeServerUrl(value: string) { return value.trim().replace(/\/+$/, ''); } +function getBrowserOrigin() { + if (typeof window === 'undefined') { + return ''; + } + + return normalizeServerUrl(window.location.origin); +} + function formatDurationLabel(value: string) { const milliseconds = Number.parseInt(value, 10); if (Number.isNaN(milliseconds)) { @@ -154,6 +170,14 @@ function formatSecondsLabel(value: string) { return `${seconds} 秒`; } +function buildDiscoveryCommand(serverUrl: string, discoveryToken: string) { + return [ + `curl -fsSL ${installerScriptUrl} | bash -s -- \\`, + ` --server-url ${normalizeServerUrl(serverUrl)} \\`, + ` --discovery-token ${discoveryToken}`, + ].join('\n'); +} + async function copyToClipboard(value: string) { await navigator.clipboard.writeText(value); } @@ -194,6 +218,12 @@ export function SettingsPage() { enabled: isRoot, }); + const bootstrapQuery = useQuery({ + queryKey: ['settings', 'bootstrap-token'], + queryFn: getBootstrapToken, + enabled: isRoot, + }); + useEffect(() => { if (profileQuery.data) { setProfileFields({ @@ -211,9 +241,12 @@ export function SettingsPage() { return; } + const resolvedServerAddress = + publicStatus.server_address || getBrowserOrigin(); + setSystemFields((previous) => ({ ...previous, - ServerAddress: publicStatus.server_address || previous.ServerAddress, + ServerAddress: resolvedServerAddress || previous.ServerAddress, GitHubClientId: publicStatus.github_client_id || previous.GitHubClientId, WeChatAccountQRCodeImageURL: publicStatus.wechat_qrcode || previous.WeChatAccountQRCodeImageURL, @@ -228,7 +261,7 @@ export function SettingsPage() { })); setOperationFields((previous) => ({ ...previous, - ServerAddress: publicStatus.server_address || previous.ServerAddress, + ServerAddress: resolvedServerAddress || previous.ServerAddress, })); }, [publicStatusQuery.data]); @@ -238,9 +271,13 @@ export function SettingsPage() { } const optionMap = optionsToMap(optionsQuery.data); + const resolvedServerAddress = + optionMap.ServerAddress || + publicStatusQuery.data?.server_address || + getBrowserOrigin(); setSystemFields({ - ServerAddress: optionMap.ServerAddress ?? '', + ServerAddress: resolvedServerAddress, PasswordLoginEnabled: toBoolean(optionMap.PasswordLoginEnabled, true), PasswordRegisterEnabled: toBoolean( optionMap.PasswordRegisterEnabled, @@ -282,8 +319,7 @@ export function SettingsPage() { DownloadRateLimitDuration: optionMap.DownloadRateLimitDuration ?? '60', CriticalRateLimitNum: optionMap.CriticalRateLimitNum ?? '100', CriticalRateLimitDuration: optionMap.CriticalRateLimitDuration ?? '1200', - ServerAddress: - optionMap.ServerAddress ?? publicStatusQuery.data?.server_address ?? '', + ServerAddress: resolvedServerAddress, }); setOtherFields({ @@ -295,6 +331,26 @@ export function SettingsPage() { }); }, [optionsQuery.data, publicStatusQuery.data?.server_address]); + const rotateTokenMutation = useMutation({ + mutationFn: rotateBootstrapToken, + onSuccess: async (data: BootstrapTokenPayload) => { + setFeedback({ tone: 'success', message: 'Discovery Token 已重新生成。' }); + await queryClient.invalidateQueries({ + queryKey: ['settings', 'bootstrap-token'], + }); + if (data.discovery_token) { + try { + await copyToClipboard(data.discovery_token); + } catch { + // ignore clipboard errors + } + } + }, + onError: (error) => { + setFeedback({ tone: 'danger', message: getErrorMessage(error) }); + }, + }); + const accessTokenMutation = useMutation({ mutationFn: generateAccessToken, onSuccess: (token) => { @@ -309,6 +365,12 @@ export function SettingsPage() { }, }); + const discoveryToken = bootstrapQuery.data?.discovery_token ?? ''; + const discoveryCommand = + isRoot && operationFields.ServerAddress && discoveryToken + ? buildDiscoveryCommand(operationFields.ServerAddress, discoveryToken) + : ''; + const tabs = useMemo( () => [ { @@ -321,7 +383,7 @@ export function SettingsPage() { { key: 'operation' as const, label: '运维设置', - description: 'Agent 参数、更新仓库与接口限流设置。', + description: 'Agent 参数、Discovery Token 与部署命令。', }, { key: 'system' as const, @@ -925,6 +987,73 @@ export function SettingsPage() { /> + + rotateTokenMutation.mutate()} + disabled={rotateTokenMutation.isPending} + > + {rotateTokenMutation.isPending + ? '生成中...' + : '重新生成 Token'} + + {discoveryCommand ? ( + void copyToClipboard(discoveryCommand)} + > + 复制命令 + + ) : null} + + } + > + {bootstrapQuery.isLoading ? ( + + ) : bootstrapQuery.isError ? ( + + ) : ( +
+ + + setOperationFields((previous) => ({ + ...previous, + ServerAddress: event.target.value, + })) + } + /> + +
+

+ Discovery Token +

+

+ {discoveryToken || '未生成'} +

+
+ + + {discoveryCommand || '请先填写可访问的 Server URL。'} + + +
+ )} +