mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-04 15:06:37 +08:00
feat(frontend): 支持 Pages 自动更新交互
在 GitHub latest 来源中提供自动更新开关、检查间隔和运行状态。\n页面按来源到期时间低频刷新,并在自动发布或人工回滚后同步项目与部署历史。
This commit is contained in:
@@ -119,7 +119,7 @@ export function DeploymentHistory({
|
||||
mutationFn: (deploymentId: number) =>
|
||||
PagesService.activateDeployment(projectId, deploymentId),
|
||||
onSuccess: async () => {
|
||||
toast.success('部署已激活');
|
||||
toast.success('历史部署已激活;自动更新(如已开启)已关闭');
|
||||
await invalidateDeploymentState();
|
||||
setPendingAction(null);
|
||||
},
|
||||
|
||||
@@ -38,6 +38,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { AdminTaskService } from '@/lib/services/admin';
|
||||
import {
|
||||
type PagesSource,
|
||||
type PagesSourceActionPayload,
|
||||
type PagesSourceActionReceipt,
|
||||
type PagesSourceStatus,
|
||||
@@ -58,6 +59,9 @@ import {
|
||||
|
||||
const ACTION_POLL_INTERVAL = 2_000;
|
||||
const ACTION_MAX_WAIT = 16 * 60 * 1_000;
|
||||
const LATEST_POLL_INTERVAL = 5 * 60 * 1_000;
|
||||
const LATEST_NEAR_DUE_POLL_INTERVAL = 30_000;
|
||||
const LATEST_OVERDUE_MAX_WAIT = 10 * 60 * 1_000;
|
||||
|
||||
const SOURCE_STATUS: Record<
|
||||
PagesSourceStatus,
|
||||
@@ -79,6 +83,60 @@ interface ActiveAction {
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
export interface LatestSourceOverdueWindow {
|
||||
nextCheckAt: string;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
export interface LatestSourcePollingDecision {
|
||||
interval: number | false;
|
||||
overdueWindow: LatestSourceOverdueWindow | null;
|
||||
}
|
||||
|
||||
export function getLatestSourceIdlePollingDecision(
|
||||
source: PagesSource | undefined,
|
||||
now: number,
|
||||
overdueWindow: LatestSourceOverdueWindow | null,
|
||||
): LatestSourcePollingDecision {
|
||||
if (
|
||||
source?.source_type !== 'github_release' ||
|
||||
source.release_selector !== 'latest'
|
||||
) {
|
||||
return { interval: false, overdueWindow: null };
|
||||
}
|
||||
|
||||
const nextCheckAt = source.next_check_at;
|
||||
const nextCheckTime = nextCheckAt ? Date.parse(nextCheckAt) : Number.NaN;
|
||||
if (!nextCheckAt || !Number.isFinite(nextCheckTime)) {
|
||||
return { interval: LATEST_POLL_INTERVAL, overdueWindow: null };
|
||||
}
|
||||
|
||||
const timeUntilCheck = nextCheckTime - now;
|
||||
if (timeUntilCheck > LATEST_POLL_INTERVAL) {
|
||||
return { interval: LATEST_POLL_INTERVAL, overdueWindow: null };
|
||||
}
|
||||
if (timeUntilCheck > 0) {
|
||||
return { interval: LATEST_NEAR_DUE_POLL_INTERVAL, overdueWindow: null };
|
||||
}
|
||||
|
||||
const currentWindow =
|
||||
overdueWindow?.nextCheckAt === nextCheckAt
|
||||
? overdueWindow
|
||||
: { nextCheckAt, startedAt: now };
|
||||
if (now - currentWindow.startedAt >= LATEST_OVERDUE_MAX_WAIT) {
|
||||
return { interval: false, overdueWindow: currentWindow };
|
||||
}
|
||||
return {
|
||||
interval: LATEST_NEAR_DUE_POLL_INTERVAL,
|
||||
overdueWindow: currentWindow,
|
||||
};
|
||||
}
|
||||
|
||||
function sourceDeploymentFingerprint(source: PagesSource) {
|
||||
if (source.source_type === 'manual') return '|';
|
||||
return `${source.last_synced_at ?? ''}|${source.last_applied?.revision ?? ''}`;
|
||||
}
|
||||
|
||||
function sourceActionLabel(action: PagesSourceActionReceipt['action']) {
|
||||
return action === 'check' ? '检查' : '同步并发布';
|
||||
}
|
||||
@@ -87,6 +145,8 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
const queryClient = useQueryClient();
|
||||
const handledExecutionID = useRef<string | null>(null);
|
||||
const sourcePollingStartedAt = useRef<number | null>(null);
|
||||
const latestOverdueWindow = useRef<LatestSourceOverdueWindow | null>(null);
|
||||
const sourceDeploymentState = useRef<string | undefined>(undefined);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogMode, setDialogMode] = useState<PagesSourceMode>('manual');
|
||||
const [activeAction, setActiveAction] = useState<ActiveAction | null>(null);
|
||||
@@ -104,15 +164,40 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
(source.sync_status === 'checking' || source.sync_status === 'syncing')
|
||||
) {
|
||||
sourcePollingStartedAt.current ??= Date.now();
|
||||
latestOverdueWindow.current = null;
|
||||
return Date.now() - sourcePollingStartedAt.current < ACTION_MAX_WAIT
|
||||
? ACTION_POLL_INTERVAL
|
||||
: false;
|
||||
}
|
||||
sourcePollingStartedAt.current = null;
|
||||
return false;
|
||||
const decision = getLatestSourceIdlePollingDecision(
|
||||
source,
|
||||
Date.now(),
|
||||
latestOverdueWindow.current,
|
||||
);
|
||||
latestOverdueWindow.current = decision.overdueWindow;
|
||||
return decision.interval;
|
||||
},
|
||||
});
|
||||
|
||||
const invalidateSourceState = useCallback(
|
||||
() =>
|
||||
Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: sourceQueryKey(projectId) }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: projectQueryKey(projectId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKey(projectId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'pages', 'deployment-files', projectId],
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
|
||||
]),
|
||||
[projectId, queryClient],
|
||||
);
|
||||
|
||||
const executionQuery = useQuery({
|
||||
queryKey: [
|
||||
'admin',
|
||||
@@ -151,6 +236,21 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [actionTimedOut, activeAction]);
|
||||
|
||||
useEffect(() => {
|
||||
const source = sourceQuery.data;
|
||||
if (!source) return;
|
||||
const fingerprint = sourceDeploymentFingerprint(source);
|
||||
const previousFingerprint = sourceDeploymentState.current;
|
||||
sourceDeploymentState.current = fingerprint;
|
||||
if (
|
||||
previousFingerprint === undefined ||
|
||||
previousFingerprint === fingerprint
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void invalidateSourceState();
|
||||
}, [invalidateSourceState, sourceQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
const execution = executionQuery.data;
|
||||
if (
|
||||
@@ -163,17 +263,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
if (handledExecutionID.current === execution.id) return;
|
||||
handledExecutionID.current = execution.id;
|
||||
|
||||
void Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: sourceQueryKey(projectId) }),
|
||||
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKey(projectId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'pages', 'deployment-files', projectId],
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
|
||||
]);
|
||||
void invalidateSourceState();
|
||||
|
||||
const actionLabel = sourceActionLabel(activeAction.receipt.action);
|
||||
if (execution.status === 'succeeded') {
|
||||
@@ -183,7 +273,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
}
|
||||
setActiveAction(null);
|
||||
setActionTimedOut(false);
|
||||
}, [activeAction, executionQuery.data, projectId, queryClient]);
|
||||
}, [activeAction, executionQuery.data, invalidateSourceState]);
|
||||
|
||||
const checkMutation = useMutation({
|
||||
mutationFn: () => PagesService.checkSource(projectId),
|
||||
|
||||
@@ -98,6 +98,32 @@ export function GitHubSourceDetails({
|
||||
<span className='text-xs text-muted-foreground'>Release Asset</span>
|
||||
<code className='truncate text-sm'>{source.asset_name}</code>
|
||||
</div>
|
||||
{source.release_selector === 'latest' ? (
|
||||
<>
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||
<span className='text-xs text-muted-foreground'>自动更新</span>
|
||||
<span className='text-sm font-medium'>
|
||||
{source.auto_update_enabled ? '已开启' : '已关闭'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||
<span className='text-xs text-muted-foreground'>检查间隔</span>
|
||||
<span className='text-sm font-medium'>
|
||||
{source.check_interval_minutes} 分钟
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4 md:col-span-2'>
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
下次检查时间
|
||||
</span>
|
||||
<span className='text-sm font-medium'>
|
||||
{source.next_check_at
|
||||
? formatDateTime(source.next_check_at)
|
||||
: '等待调度'}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||
<span className='text-xs text-muted-foreground'>远端已发现</span>
|
||||
<span className='font-mono text-sm'>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -78,6 +78,7 @@ const EMPTY_GITHUB_ERRORS: PagesGitHubSourceFormErrors = {
|
||||
repository: '',
|
||||
releaseTag: '',
|
||||
assetName: '',
|
||||
checkInterval: '',
|
||||
};
|
||||
|
||||
function githubRepositoryURL(repository: string) {
|
||||
@@ -106,17 +107,25 @@ export function PagesSourceDialog({
|
||||
releaseSelector: 'latest',
|
||||
releaseTag: '',
|
||||
assetName: DEFAULT_GITHUB_ASSET,
|
||||
autoUpdateEnabled: false,
|
||||
checkIntervalMinutes: String(DEFAULT_GITHUB_CHECK_INTERVAL),
|
||||
});
|
||||
const [githubErrors, setGitHubErrors] =
|
||||
useState<PagesGitHubSourceFormErrors>(EMPTY_GITHUB_ERRORS);
|
||||
const [confirmation, setConfirmation] = useState<Confirmation>(null);
|
||||
const initializedForOpen = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
initializedForOpen.current = false;
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
return;
|
||||
}
|
||||
// Runtime polling may replace the source view while the dialog is open.
|
||||
// Initialize only on the open edge so it cannot overwrite an unsaved draft.
|
||||
if (initializedForOpen.current) return;
|
||||
initializedForOpen.current = true;
|
||||
const nextMode = initialMode ?? source.source_type;
|
||||
setMode(nextMode);
|
||||
setNetworkPolicy(
|
||||
@@ -145,6 +154,18 @@ export function PagesSourceDialog({
|
||||
source.source_type === 'github_release'
|
||||
? source.asset_name
|
||||
: DEFAULT_GITHUB_ASSET,
|
||||
autoUpdateEnabled:
|
||||
source.source_type === 'github_release' &&
|
||||
source.release_selector === 'latest'
|
||||
? source.auto_update_enabled
|
||||
: false,
|
||||
checkIntervalMinutes:
|
||||
source.source_type === 'github_release' &&
|
||||
source.release_selector === 'latest'
|
||||
? String(
|
||||
source.check_interval_minutes || DEFAULT_GITHUB_CHECK_INTERVAL,
|
||||
)
|
||||
: String(DEFAULT_GITHUB_CHECK_INTERVAL),
|
||||
});
|
||||
setGitHubErrors(EMPTY_GITHUB_ERRORS);
|
||||
setConfirmation(null);
|
||||
@@ -243,13 +264,27 @@ export function PagesSourceDialog({
|
||||
const nextAssetNameError = validGitHubAssetName(githubForm.assetName)
|
||||
? ''
|
||||
: 'Asset 文件名须为 1–255 字节,且不能是路径或包含控制、换行、双向文本字符';
|
||||
const checkIntervalMinutes = Number(githubForm.checkIntervalMinutes);
|
||||
const nextCheckIntervalError =
|
||||
githubForm.releaseSelector === 'latest' &&
|
||||
(!Number.isInteger(checkIntervalMinutes) ||
|
||||
checkIntervalMinutes < 5 ||
|
||||
checkIntervalMinutes > 1440)
|
||||
? '检查间隔须为 5–1440 分钟的整数'
|
||||
: '';
|
||||
|
||||
setGitHubErrors({
|
||||
repository: nextRepositoryError,
|
||||
releaseTag: nextReleaseTagError,
|
||||
assetName: nextAssetNameError,
|
||||
checkInterval: nextCheckIntervalError,
|
||||
});
|
||||
if (nextRepositoryError || nextReleaseTagError || nextAssetNameError) {
|
||||
if (
|
||||
nextRepositoryError ||
|
||||
nextReleaseTagError ||
|
||||
nextAssetNameError ||
|
||||
nextCheckIntervalError
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -261,13 +296,8 @@ export function PagesSourceDialog({
|
||||
release_selector: 'latest',
|
||||
release_tag: '',
|
||||
asset_name: githubForm.assetName,
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes:
|
||||
source.source_type === 'github_release' &&
|
||||
source.release_selector === 'latest' &&
|
||||
source.check_interval_minutes
|
||||
? source.check_interval_minutes
|
||||
: DEFAULT_GITHUB_CHECK_INTERVAL,
|
||||
auto_update_enabled: githubForm.autoUpdateEnabled,
|
||||
check_interval_minutes: checkIntervalMinutes,
|
||||
}
|
||||
: {
|
||||
source_type: 'github_release',
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
FieldTitle,
|
||||
} from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import type { PagesGitHubReleaseSelector } from '@/lib/services/openflare';
|
||||
|
||||
@@ -13,12 +16,15 @@ export interface PagesGitHubSourceFormValue {
|
||||
releaseSelector: PagesGitHubReleaseSelector;
|
||||
releaseTag: string;
|
||||
assetName: string;
|
||||
autoUpdateEnabled: boolean;
|
||||
checkIntervalMinutes: string;
|
||||
}
|
||||
|
||||
export interface PagesGitHubSourceFormErrors {
|
||||
repository: string;
|
||||
releaseTag: string;
|
||||
assetName: string;
|
||||
checkInterval: string;
|
||||
}
|
||||
|
||||
interface PagesSourceGitHubFieldsProps {
|
||||
@@ -48,15 +54,19 @@ export function PagesSourceGitHubFields({
|
||||
placeholder='https://github.com/owner/repo'
|
||||
value={value.repositoryURL}
|
||||
aria-invalid={Boolean(errors.repository)}
|
||||
aria-describedby='pages-github-repository-description pages-github-repository-error'
|
||||
autoComplete='off'
|
||||
onChange={(event) => {
|
||||
onChange({ ...value, repositoryURL: event.target.value });
|
||||
onErrorsChange({ ...errors, repository: '' });
|
||||
}}
|
||||
/>
|
||||
<FieldDescription>
|
||||
{errors.repository || '仅支持公开 github.com 仓库。'}
|
||||
<FieldDescription id='pages-github-repository-description'>
|
||||
仅支持公开 github.com 仓库。
|
||||
</FieldDescription>
|
||||
<FieldError id='pages-github-repository-error'>
|
||||
{errors.repository}
|
||||
</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
@@ -69,8 +79,17 @@ export function PagesSourceGitHubFields({
|
||||
className='grid w-full grid-cols-2'
|
||||
onValueChange={(selector) => {
|
||||
if (selector === 'latest' || selector === 'tag') {
|
||||
onChange({ ...value, releaseSelector: selector });
|
||||
onErrorsChange({ ...errors, releaseTag: '' });
|
||||
onChange({
|
||||
...value,
|
||||
releaseSelector: selector,
|
||||
autoUpdateEnabled:
|
||||
selector === 'latest' ? value.autoUpdateEnabled : false,
|
||||
});
|
||||
onErrorsChange({
|
||||
...errors,
|
||||
releaseTag: '',
|
||||
checkInterval: '',
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -82,7 +101,9 @@ export function PagesSourceGitHubFields({
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<FieldDescription>
|
||||
当前阶段由管理员手动检查并决定是否发布。
|
||||
{value.releaseSelector === 'latest'
|
||||
? '定时检查最新 Release;是否自动发布由下方开关控制。'
|
||||
: '固定 Tag 仅支持管理员手动检查与同步。'}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
@@ -94,18 +115,74 @@ export function PagesSourceGitHubFields({
|
||||
placeholder='v1.2.3'
|
||||
value={value.releaseTag}
|
||||
aria-invalid={Boolean(errors.releaseTag)}
|
||||
aria-describedby='pages-github-tag-description pages-github-tag-error'
|
||||
autoComplete='off'
|
||||
onChange={(event) => {
|
||||
onChange({ ...value, releaseTag: event.target.value });
|
||||
onErrorsChange({ ...errors, releaseTag: '' });
|
||||
}}
|
||||
/>
|
||||
<FieldDescription>
|
||||
{errors.releaseTag || '精确检查并同步指定 tag。'}
|
||||
<FieldDescription id='pages-github-tag-description'>
|
||||
精确检查并同步指定 tag。
|
||||
</FieldDescription>
|
||||
<FieldError id='pages-github-tag-error'>
|
||||
{errors.releaseTag}
|
||||
</FieldError>
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
{value.releaseSelector === 'latest' ? (
|
||||
<>
|
||||
<Field orientation='horizontal'>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor='pages-github-auto-update'>
|
||||
自动更新
|
||||
</FieldLabel>
|
||||
<FieldDescription>
|
||||
检查到新的 Release 后自动同步并发布。
|
||||
</FieldDescription>
|
||||
</FieldContent>
|
||||
<Switch
|
||||
id='pages-github-auto-update'
|
||||
checked={value.autoUpdateEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...value, autoUpdateEnabled: checked })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.checkInterval)}>
|
||||
<FieldLabel htmlFor='pages-github-check-interval'>
|
||||
检查间隔(分钟)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id='pages-github-check-interval'
|
||||
type='number'
|
||||
min={5}
|
||||
max={1440}
|
||||
step={1}
|
||||
inputMode='numeric'
|
||||
value={value.checkIntervalMinutes}
|
||||
aria-invalid={Boolean(errors.checkInterval)}
|
||||
aria-describedby='pages-github-check-interval-description pages-github-check-interval-error'
|
||||
onChange={(event) => {
|
||||
onChange({
|
||||
...value,
|
||||
checkIntervalMinutes: event.target.value,
|
||||
});
|
||||
onErrorsChange({ ...errors, checkInterval: '' });
|
||||
}}
|
||||
/>
|
||||
<FieldDescription id='pages-github-check-interval-description'>
|
||||
可设置为 5–1440 分钟。
|
||||
</FieldDescription>
|
||||
<FieldError id='pages-github-check-interval-error'>
|
||||
{errors.checkInterval}
|
||||
</FieldError>
|
||||
</Field>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Field data-invalid={Boolean(errors.assetName)}>
|
||||
<FieldLabel htmlFor='pages-github-asset'>
|
||||
Release Asset 文件名
|
||||
@@ -115,15 +192,19 @@ export function PagesSourceGitHubFields({
|
||||
placeholder={defaultAssetName}
|
||||
value={value.assetName}
|
||||
aria-invalid={Boolean(errors.assetName)}
|
||||
aria-describedby='pages-github-asset-description pages-github-asset-error'
|
||||
autoComplete='off'
|
||||
onChange={(event) => {
|
||||
onChange({ ...value, assetName: event.target.value });
|
||||
onErrorsChange({ ...errors, assetName: '' });
|
||||
}}
|
||||
/>
|
||||
<FieldDescription>
|
||||
{errors.assetName || '按文件名精确匹配已上传的 Release Asset。'}
|
||||
<FieldDescription id='pages-github-asset-description'>
|
||||
按文件名精确匹配已上传的 Release Asset。
|
||||
</FieldDescription>
|
||||
<FieldError id='pages-github-asset-error'>
|
||||
{errors.assetName}
|
||||
</FieldError>
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -252,8 +252,9 @@ function PagesDetailRoute() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<PagesSourceCard projectId={projectId} />
|
||||
<PagesSourceCard key={`source-${projectId}`} projectId={projectId} />
|
||||
<DeploymentHistory
|
||||
key={`deployments-${projectId}`}
|
||||
projectId={projectId}
|
||||
activeDeploymentId={project.active_deployment_id}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user