mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 23:56:37 +08:00
feat(frontend): 支持 Pages 自动更新交互
在 GitHub latest 来源中提供自动更新开关、检查间隔和运行状态。\n页面按来源到期时间低频刷新,并在自动发布或人工回滚后同步项目与部署历史。
This commit is contained in:
@@ -119,7 +119,7 @@ export function DeploymentHistory({
|
|||||||
mutationFn: (deploymentId: number) =>
|
mutationFn: (deploymentId: number) =>
|
||||||
PagesService.activateDeployment(projectId, deploymentId),
|
PagesService.activateDeployment(projectId, deploymentId),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
toast.success('部署已激活');
|
toast.success('历史部署已激活;自动更新(如已开启)已关闭');
|
||||||
await invalidateDeploymentState();
|
await invalidateDeploymentState();
|
||||||
setPendingAction(null);
|
setPendingAction(null);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import { Skeleton } from '@/components/ui/skeleton';
|
|||||||
import { Spinner } from '@/components/ui/spinner';
|
import { Spinner } from '@/components/ui/spinner';
|
||||||
import { AdminTaskService } from '@/lib/services/admin';
|
import { AdminTaskService } from '@/lib/services/admin';
|
||||||
import {
|
import {
|
||||||
|
type PagesSource,
|
||||||
type PagesSourceActionPayload,
|
type PagesSourceActionPayload,
|
||||||
type PagesSourceActionReceipt,
|
type PagesSourceActionReceipt,
|
||||||
type PagesSourceStatus,
|
type PagesSourceStatus,
|
||||||
@@ -58,6 +59,9 @@ import {
|
|||||||
|
|
||||||
const ACTION_POLL_INTERVAL = 2_000;
|
const ACTION_POLL_INTERVAL = 2_000;
|
||||||
const ACTION_MAX_WAIT = 16 * 60 * 1_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<
|
const SOURCE_STATUS: Record<
|
||||||
PagesSourceStatus,
|
PagesSourceStatus,
|
||||||
@@ -79,6 +83,60 @@ interface ActiveAction {
|
|||||||
startedAt: number;
|
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']) {
|
function sourceActionLabel(action: PagesSourceActionReceipt['action']) {
|
||||||
return action === 'check' ? '检查' : '同步并发布';
|
return action === 'check' ? '检查' : '同步并发布';
|
||||||
}
|
}
|
||||||
@@ -87,6 +145,8 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const handledExecutionID = useRef<string | null>(null);
|
const handledExecutionID = useRef<string | null>(null);
|
||||||
const sourcePollingStartedAt = useRef<number | 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 [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [dialogMode, setDialogMode] = useState<PagesSourceMode>('manual');
|
const [dialogMode, setDialogMode] = useState<PagesSourceMode>('manual');
|
||||||
const [activeAction, setActiveAction] = useState<ActiveAction | null>(null);
|
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')
|
(source.sync_status === 'checking' || source.sync_status === 'syncing')
|
||||||
) {
|
) {
|
||||||
sourcePollingStartedAt.current ??= Date.now();
|
sourcePollingStartedAt.current ??= Date.now();
|
||||||
|
latestOverdueWindow.current = null;
|
||||||
return Date.now() - sourcePollingStartedAt.current < ACTION_MAX_WAIT
|
return Date.now() - sourcePollingStartedAt.current < ACTION_MAX_WAIT
|
||||||
? ACTION_POLL_INTERVAL
|
? ACTION_POLL_INTERVAL
|
||||||
: false;
|
: false;
|
||||||
}
|
}
|
||||||
sourcePollingStartedAt.current = null;
|
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({
|
const executionQuery = useQuery({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
'admin',
|
'admin',
|
||||||
@@ -151,6 +236,21 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
|||||||
return () => window.clearTimeout(timeout);
|
return () => window.clearTimeout(timeout);
|
||||||
}, [actionTimedOut, activeAction]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const execution = executionQuery.data;
|
const execution = executionQuery.data;
|
||||||
if (
|
if (
|
||||||
@@ -163,17 +263,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
|||||||
if (handledExecutionID.current === execution.id) return;
|
if (handledExecutionID.current === execution.id) return;
|
||||||
handledExecutionID.current = execution.id;
|
handledExecutionID.current = execution.id;
|
||||||
|
|
||||||
void Promise.all([
|
void invalidateSourceState();
|
||||||
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 }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const actionLabel = sourceActionLabel(activeAction.receipt.action);
|
const actionLabel = sourceActionLabel(activeAction.receipt.action);
|
||||||
if (execution.status === 'succeeded') {
|
if (execution.status === 'succeeded') {
|
||||||
@@ -183,7 +273,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
|||||||
}
|
}
|
||||||
setActiveAction(null);
|
setActiveAction(null);
|
||||||
setActionTimedOut(false);
|
setActionTimedOut(false);
|
||||||
}, [activeAction, executionQuery.data, projectId, queryClient]);
|
}, [activeAction, executionQuery.data, invalidateSourceState]);
|
||||||
|
|
||||||
const checkMutation = useMutation({
|
const checkMutation = useMutation({
|
||||||
mutationFn: () => PagesService.checkSource(projectId),
|
mutationFn: () => PagesService.checkSource(projectId),
|
||||||
|
|||||||
@@ -98,6 +98,32 @@ export function GitHubSourceDetails({
|
|||||||
<span className='text-xs text-muted-foreground'>Release Asset</span>
|
<span className='text-xs text-muted-foreground'>Release Asset</span>
|
||||||
<code className='truncate text-sm'>{source.asset_name}</code>
|
<code className='truncate text-sm'>{source.asset_name}</code>
|
||||||
</div>
|
</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'>
|
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||||
<span className='text-xs text-muted-foreground'>远端已发现</span>
|
<span className='text-xs text-muted-foreground'>远端已发现</span>
|
||||||
<span className='font-mono text-sm'>
|
<span className='font-mono text-sm'>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { Eye, EyeOff } from 'lucide-react';
|
import { Eye, EyeOff } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@@ -78,6 +78,7 @@ const EMPTY_GITHUB_ERRORS: PagesGitHubSourceFormErrors = {
|
|||||||
repository: '',
|
repository: '',
|
||||||
releaseTag: '',
|
releaseTag: '',
|
||||||
assetName: '',
|
assetName: '',
|
||||||
|
checkInterval: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
function githubRepositoryURL(repository: string) {
|
function githubRepositoryURL(repository: string) {
|
||||||
@@ -106,17 +107,25 @@ export function PagesSourceDialog({
|
|||||||
releaseSelector: 'latest',
|
releaseSelector: 'latest',
|
||||||
releaseTag: '',
|
releaseTag: '',
|
||||||
assetName: DEFAULT_GITHUB_ASSET,
|
assetName: DEFAULT_GITHUB_ASSET,
|
||||||
|
autoUpdateEnabled: false,
|
||||||
|
checkIntervalMinutes: String(DEFAULT_GITHUB_CHECK_INTERVAL),
|
||||||
});
|
});
|
||||||
const [githubErrors, setGitHubErrors] =
|
const [githubErrors, setGitHubErrors] =
|
||||||
useState<PagesGitHubSourceFormErrors>(EMPTY_GITHUB_ERRORS);
|
useState<PagesGitHubSourceFormErrors>(EMPTY_GITHUB_ERRORS);
|
||||||
const [confirmation, setConfirmation] = useState<Confirmation>(null);
|
const [confirmation, setConfirmation] = useState<Confirmation>(null);
|
||||||
|
const initializedForOpen = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
|
initializedForOpen.current = false;
|
||||||
setRemoteURL('');
|
setRemoteURL('');
|
||||||
setRevealRemoteURL(false);
|
setRevealRemoteURL(false);
|
||||||
return;
|
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;
|
const nextMode = initialMode ?? source.source_type;
|
||||||
setMode(nextMode);
|
setMode(nextMode);
|
||||||
setNetworkPolicy(
|
setNetworkPolicy(
|
||||||
@@ -145,6 +154,18 @@ export function PagesSourceDialog({
|
|||||||
source.source_type === 'github_release'
|
source.source_type === 'github_release'
|
||||||
? source.asset_name
|
? source.asset_name
|
||||||
: DEFAULT_GITHUB_ASSET,
|
: 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);
|
setGitHubErrors(EMPTY_GITHUB_ERRORS);
|
||||||
setConfirmation(null);
|
setConfirmation(null);
|
||||||
@@ -243,13 +264,27 @@ export function PagesSourceDialog({
|
|||||||
const nextAssetNameError = validGitHubAssetName(githubForm.assetName)
|
const nextAssetNameError = validGitHubAssetName(githubForm.assetName)
|
||||||
? ''
|
? ''
|
||||||
: 'Asset 文件名须为 1–255 字节,且不能是路径或包含控制、换行、双向文本字符';
|
: 'Asset 文件名须为 1–255 字节,且不能是路径或包含控制、换行、双向文本字符';
|
||||||
|
const checkIntervalMinutes = Number(githubForm.checkIntervalMinutes);
|
||||||
|
const nextCheckIntervalError =
|
||||||
|
githubForm.releaseSelector === 'latest' &&
|
||||||
|
(!Number.isInteger(checkIntervalMinutes) ||
|
||||||
|
checkIntervalMinutes < 5 ||
|
||||||
|
checkIntervalMinutes > 1440)
|
||||||
|
? '检查间隔须为 5–1440 分钟的整数'
|
||||||
|
: '';
|
||||||
|
|
||||||
setGitHubErrors({
|
setGitHubErrors({
|
||||||
repository: nextRepositoryError,
|
repository: nextRepositoryError,
|
||||||
releaseTag: nextReleaseTagError,
|
releaseTag: nextReleaseTagError,
|
||||||
assetName: nextAssetNameError,
|
assetName: nextAssetNameError,
|
||||||
|
checkInterval: nextCheckIntervalError,
|
||||||
});
|
});
|
||||||
if (nextRepositoryError || nextReleaseTagError || nextAssetNameError) {
|
if (
|
||||||
|
nextRepositoryError ||
|
||||||
|
nextReleaseTagError ||
|
||||||
|
nextAssetNameError ||
|
||||||
|
nextCheckIntervalError
|
||||||
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,13 +296,8 @@ export function PagesSourceDialog({
|
|||||||
release_selector: 'latest',
|
release_selector: 'latest',
|
||||||
release_tag: '',
|
release_tag: '',
|
||||||
asset_name: githubForm.assetName,
|
asset_name: githubForm.assetName,
|
||||||
auto_update_enabled: false,
|
auto_update_enabled: githubForm.autoUpdateEnabled,
|
||||||
check_interval_minutes:
|
check_interval_minutes: checkIntervalMinutes,
|
||||||
source.source_type === 'github_release' &&
|
|
||||||
source.release_selector === 'latest' &&
|
|
||||||
source.check_interval_minutes
|
|
||||||
? source.check_interval_minutes
|
|
||||||
: DEFAULT_GITHUB_CHECK_INTERVAL,
|
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
source_type: 'github_release',
|
source_type: 'github_release',
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
|
FieldContent,
|
||||||
FieldDescription,
|
FieldDescription,
|
||||||
|
FieldError,
|
||||||
FieldLabel,
|
FieldLabel,
|
||||||
FieldTitle,
|
FieldTitle,
|
||||||
} from '@/components/ui/field';
|
} from '@/components/ui/field';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||||
import type { PagesGitHubReleaseSelector } from '@/lib/services/openflare';
|
import type { PagesGitHubReleaseSelector } from '@/lib/services/openflare';
|
||||||
|
|
||||||
@@ -13,12 +16,15 @@ export interface PagesGitHubSourceFormValue {
|
|||||||
releaseSelector: PagesGitHubReleaseSelector;
|
releaseSelector: PagesGitHubReleaseSelector;
|
||||||
releaseTag: string;
|
releaseTag: string;
|
||||||
assetName: string;
|
assetName: string;
|
||||||
|
autoUpdateEnabled: boolean;
|
||||||
|
checkIntervalMinutes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PagesGitHubSourceFormErrors {
|
export interface PagesGitHubSourceFormErrors {
|
||||||
repository: string;
|
repository: string;
|
||||||
releaseTag: string;
|
releaseTag: string;
|
||||||
assetName: string;
|
assetName: string;
|
||||||
|
checkInterval: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PagesSourceGitHubFieldsProps {
|
interface PagesSourceGitHubFieldsProps {
|
||||||
@@ -48,15 +54,19 @@ export function PagesSourceGitHubFields({
|
|||||||
placeholder='https://github.com/owner/repo'
|
placeholder='https://github.com/owner/repo'
|
||||||
value={value.repositoryURL}
|
value={value.repositoryURL}
|
||||||
aria-invalid={Boolean(errors.repository)}
|
aria-invalid={Boolean(errors.repository)}
|
||||||
|
aria-describedby='pages-github-repository-description pages-github-repository-error'
|
||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
onChange({ ...value, repositoryURL: event.target.value });
|
onChange({ ...value, repositoryURL: event.target.value });
|
||||||
onErrorsChange({ ...errors, repository: '' });
|
onErrorsChange({ ...errors, repository: '' });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<FieldDescription>
|
<FieldDescription id='pages-github-repository-description'>
|
||||||
{errors.repository || '仅支持公开 github.com 仓库。'}
|
仅支持公开 github.com 仓库。
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
|
<FieldError id='pages-github-repository-error'>
|
||||||
|
{errors.repository}
|
||||||
|
</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field>
|
<Field>
|
||||||
@@ -69,8 +79,17 @@ export function PagesSourceGitHubFields({
|
|||||||
className='grid w-full grid-cols-2'
|
className='grid w-full grid-cols-2'
|
||||||
onValueChange={(selector) => {
|
onValueChange={(selector) => {
|
||||||
if (selector === 'latest' || selector === 'tag') {
|
if (selector === 'latest' || selector === 'tag') {
|
||||||
onChange({ ...value, releaseSelector: selector });
|
onChange({
|
||||||
onErrorsChange({ ...errors, releaseTag: '' });
|
...value,
|
||||||
|
releaseSelector: selector,
|
||||||
|
autoUpdateEnabled:
|
||||||
|
selector === 'latest' ? value.autoUpdateEnabled : false,
|
||||||
|
});
|
||||||
|
onErrorsChange({
|
||||||
|
...errors,
|
||||||
|
releaseTag: '',
|
||||||
|
checkInterval: '',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -82,7 +101,9 @@ export function PagesSourceGitHubFields({
|
|||||||
</ToggleGroupItem>
|
</ToggleGroupItem>
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
<FieldDescription>
|
<FieldDescription>
|
||||||
当前阶段由管理员手动检查并决定是否发布。
|
{value.releaseSelector === 'latest'
|
||||||
|
? '定时检查最新 Release;是否自动发布由下方开关控制。'
|
||||||
|
: '固定 Tag 仅支持管理员手动检查与同步。'}
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
@@ -94,18 +115,74 @@ export function PagesSourceGitHubFields({
|
|||||||
placeholder='v1.2.3'
|
placeholder='v1.2.3'
|
||||||
value={value.releaseTag}
|
value={value.releaseTag}
|
||||||
aria-invalid={Boolean(errors.releaseTag)}
|
aria-invalid={Boolean(errors.releaseTag)}
|
||||||
|
aria-describedby='pages-github-tag-description pages-github-tag-error'
|
||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
onChange({ ...value, releaseTag: event.target.value });
|
onChange({ ...value, releaseTag: event.target.value });
|
||||||
onErrorsChange({ ...errors, releaseTag: '' });
|
onErrorsChange({ ...errors, releaseTag: '' });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<FieldDescription>
|
<FieldDescription id='pages-github-tag-description'>
|
||||||
{errors.releaseTag || '精确检查并同步指定 tag。'}
|
精确检查并同步指定 tag。
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
|
<FieldError id='pages-github-tag-error'>
|
||||||
|
{errors.releaseTag}
|
||||||
|
</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
) : null}
|
) : 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)}>
|
<Field data-invalid={Boolean(errors.assetName)}>
|
||||||
<FieldLabel htmlFor='pages-github-asset'>
|
<FieldLabel htmlFor='pages-github-asset'>
|
||||||
Release Asset 文件名
|
Release Asset 文件名
|
||||||
@@ -115,15 +192,19 @@ export function PagesSourceGitHubFields({
|
|||||||
placeholder={defaultAssetName}
|
placeholder={defaultAssetName}
|
||||||
value={value.assetName}
|
value={value.assetName}
|
||||||
aria-invalid={Boolean(errors.assetName)}
|
aria-invalid={Boolean(errors.assetName)}
|
||||||
|
aria-describedby='pages-github-asset-description pages-github-asset-error'
|
||||||
autoComplete='off'
|
autoComplete='off'
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
onChange({ ...value, assetName: event.target.value });
|
onChange({ ...value, assetName: event.target.value });
|
||||||
onErrorsChange({ ...errors, assetName: '' });
|
onErrorsChange({ ...errors, assetName: '' });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<FieldDescription>
|
<FieldDescription id='pages-github-asset-description'>
|
||||||
{errors.assetName || '按文件名精确匹配已上传的 Release Asset。'}
|
按文件名精确匹配已上传的 Release Asset。
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
|
<FieldError id='pages-github-asset-error'>
|
||||||
|
{errors.assetName}
|
||||||
|
</FieldError>
|
||||||
</Field>
|
</Field>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -252,8 +252,9 @@ function PagesDetailRoute() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PagesSourceCard projectId={projectId} />
|
<PagesSourceCard key={`source-${projectId}`} projectId={projectId} />
|
||||||
<DeploymentHistory
|
<DeploymentHistory
|
||||||
|
key={`deployments-${projectId}`}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
activeDeploymentId={project.active_deployment_id}
|
activeDeploymentId={project.active_deployment_id}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -492,16 +492,29 @@ export interface PagesRemoteURLSource extends PagesSourceRuntimeView {
|
|||||||
remote_network_policy: PagesRemoteNetworkPolicy;
|
remote_network_policy: PagesRemoteNetworkPolicy;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PagesGitHubReleaseSource extends PagesSourceRuntimeView {
|
interface PagesGitHubReleaseSourceBase extends PagesSourceRuntimeView {
|
||||||
source_type: 'github_release';
|
source_type: 'github_release';
|
||||||
github_repository: string;
|
github_repository: string;
|
||||||
release_selector: PagesGitHubReleaseSelector;
|
|
||||||
release_tag?: string;
|
|
||||||
asset_name: string;
|
asset_name: string;
|
||||||
auto_update_enabled?: false;
|
|
||||||
check_interval_minutes?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PagesGitHubLatestReleaseSource extends PagesGitHubReleaseSourceBase {
|
||||||
|
release_selector: 'latest';
|
||||||
|
release_tag?: '';
|
||||||
|
auto_update_enabled: boolean;
|
||||||
|
check_interval_minutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PagesGitHubTagReleaseSource extends PagesGitHubReleaseSourceBase {
|
||||||
|
release_selector: 'tag';
|
||||||
|
release_tag: string;
|
||||||
|
auto_update_enabled: false;
|
||||||
|
check_interval_minutes?: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PagesGitHubReleaseSource =
|
||||||
|
PagesGitHubLatestReleaseSource | PagesGitHubTagReleaseSource;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 部署源使用判别联合,后续仓库构建来源只需增加独立 git_repository variant,
|
* 部署源使用判别联合,后续仓库构建来源只需增加独立 git_repository variant,
|
||||||
* 不需要向 Remote 或 GitHub Release 填入构建字段。
|
* 不需要向 Remote 或 GitHub Release 填入构建字段。
|
||||||
@@ -520,18 +533,19 @@ interface PagesGitHubSourceUpdateBase {
|
|||||||
source_type: 'github_release';
|
source_type: 'github_release';
|
||||||
repository_url: string;
|
repository_url: string;
|
||||||
asset_name: string;
|
asset_name: string;
|
||||||
auto_update_enabled: false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PagesGitHubLatestSourceUpdatePayload extends PagesGitHubSourceUpdateBase {
|
export interface PagesGitHubLatestSourceUpdatePayload extends PagesGitHubSourceUpdateBase {
|
||||||
release_selector: 'latest';
|
release_selector: 'latest';
|
||||||
release_tag: '';
|
release_tag: '';
|
||||||
|
auto_update_enabled: boolean;
|
||||||
check_interval_minutes: number;
|
check_interval_minutes: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PagesGitHubTagSourceUpdatePayload extends PagesGitHubSourceUpdateBase {
|
export interface PagesGitHubTagSourceUpdatePayload extends PagesGitHubSourceUpdateBase {
|
||||||
release_selector: 'tag';
|
release_selector: 'tag';
|
||||||
release_tag: string;
|
release_tag: string;
|
||||||
|
auto_update_enabled: false;
|
||||||
check_interval_minutes: 0;
|
check_interval_minutes: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import { act } from 'react';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { DeploymentHistory } from '@/app/(main)/pages/detail/components/deployment-history';
|
||||||
|
import {
|
||||||
|
getLatestSourceIdlePollingDecision,
|
||||||
|
PagesSourceCard,
|
||||||
|
} from '@/app/(main)/pages/detail/components/pages-source-card';
|
||||||
|
import {
|
||||||
|
deploymentsQueryKey,
|
||||||
|
projectQueryKey,
|
||||||
|
projectsQueryKey,
|
||||||
|
sourceQueryKey,
|
||||||
|
} from '@/app/(main)/pages/components/pages-utils';
|
||||||
|
import {
|
||||||
|
type PagesDeployment,
|
||||||
|
type PagesGitHubReleaseSource,
|
||||||
|
PagesService,
|
||||||
|
} from '@/lib/services/openflare';
|
||||||
|
|
||||||
|
vi.mock('@/lib/services/openflare', async (importOriginal) => {
|
||||||
|
const actual =
|
||||||
|
await importOriginal<typeof import('@/lib/services/openflare')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
PagesService: {
|
||||||
|
getSource: vi.fn(),
|
||||||
|
updateSource: vi.fn(),
|
||||||
|
deleteSource: vi.fn(),
|
||||||
|
checkSource: vi.fn(),
|
||||||
|
syncSource: vi.fn(),
|
||||||
|
listDeployments: vi.fn(),
|
||||||
|
listDeploymentFiles: vi.fn(),
|
||||||
|
activateDeployment: vi.fn(),
|
||||||
|
deleteDeployment: vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderWithQuery(ui: React.ReactNode) {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: { retry: false, gcTime: 0 },
|
||||||
|
mutations: { retry: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const rendered = render(
|
||||||
|
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...rendered,
|
||||||
|
queryClient,
|
||||||
|
rerenderWithQuery: (nextUI: React.ReactNode) =>
|
||||||
|
rendered.rerender(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{nextUI}
|
||||||
|
</QueryClientProvider>,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestSource: PagesGitHubReleaseSource = {
|
||||||
|
source_type: 'github_release',
|
||||||
|
github_repository: 'openflare/site',
|
||||||
|
release_selector: 'latest',
|
||||||
|
asset_name: 'dist.zip',
|
||||||
|
auto_update_enabled: true,
|
||||||
|
check_interval_minutes: 120,
|
||||||
|
sync_status: 'idle',
|
||||||
|
update_available: false,
|
||||||
|
last_seen: {
|
||||||
|
revision: 'b'.repeat(64),
|
||||||
|
label: 'v1.2.3',
|
||||||
|
asset_name: 'dist.zip',
|
||||||
|
},
|
||||||
|
last_applied: {
|
||||||
|
revision: 'a'.repeat(64),
|
||||||
|
label: 'v1.2.2',
|
||||||
|
asset_name: 'dist.zip',
|
||||||
|
},
|
||||||
|
last_checked_at: '2026-07-19T10:00:00Z',
|
||||||
|
last_synced_at: '2026-07-19T09:00:00Z',
|
||||||
|
next_check_at: '2026-07-19T12:00:00Z',
|
||||||
|
last_error: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Pages latest source automatic updates', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(PagesService.getSource).mockReset();
|
||||||
|
vi.mocked(PagesService.updateSource).mockReset();
|
||||||
|
vi.mocked(PagesService.deleteSource).mockReset();
|
||||||
|
vi.mocked(PagesService.checkSource).mockReset();
|
||||||
|
vi.mocked(PagesService.syncSource).mockReset();
|
||||||
|
vi.mocked(PagesService.listDeployments).mockReset();
|
||||||
|
vi.mocked(PagesService.listDeploymentFiles).mockReset();
|
||||||
|
vi.mocked(PagesService.activateDeployment).mockReset();
|
||||||
|
vi.mocked(PagesService.deleteDeployment).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('backfills latest settings and hides them after selecting a fixed tag', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(PagesService.getSource).mockResolvedValue(latestSource);
|
||||||
|
|
||||||
|
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: '编辑来源' }));
|
||||||
|
expect(screen.getByRole('switch', { name: '自动更新' })).toBeChecked();
|
||||||
|
expect(screen.getByLabelText('检查间隔(分钟)')).toHaveValue(120);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('radio', { name: '固定 Tag' }));
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('switch', { name: '自动更新' }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText('检查间隔(分钟)')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show automatic schedule details for a fixed tag source', async () => {
|
||||||
|
vi.mocked(PagesService.getSource).mockResolvedValue({
|
||||||
|
...latestSource,
|
||||||
|
release_selector: 'tag',
|
||||||
|
release_tag: 'v1.2.3',
|
||||||
|
auto_update_enabled: false,
|
||||||
|
check_interval_minutes: 0,
|
||||||
|
next_check_at: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||||
|
|
||||||
|
expect(await screen.findByText('固定 Tag · v1.2.3')).toBeVisible();
|
||||||
|
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('检查间隔')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('下次检查时间')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a latest interval outside 5–1440 minutes', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(PagesService.getSource).mockResolvedValue(latestSource);
|
||||||
|
|
||||||
|
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: '编辑来源' }));
|
||||||
|
const intervalInput = screen.getByLabelText('检查间隔(分钟)');
|
||||||
|
await user.clear(intervalInput);
|
||||||
|
await user.type(intervalInput, '4');
|
||||||
|
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||||
|
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||||
|
'检查间隔须为 5–1440 分钟的整数',
|
||||||
|
);
|
||||||
|
expect(intervalInput).toHaveAttribute(
|
||||||
|
'aria-describedby',
|
||||||
|
'pages-github-check-interval-description pages-github-check-interval-error',
|
||||||
|
);
|
||||||
|
expect(PagesService.updateSource).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps an unsaved source draft when idle polling updates runtime fields', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(PagesService.getSource).mockResolvedValue(latestSource);
|
||||||
|
const { queryClient } = renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: '编辑来源' }));
|
||||||
|
const assetInput = screen.getByLabelText('Release Asset 文件名');
|
||||||
|
const intervalInput = screen.getByLabelText('检查间隔(分钟)');
|
||||||
|
await user.clear(assetInput);
|
||||||
|
await user.type(assetInput, 'draft.zip');
|
||||||
|
await user.clear(intervalInput);
|
||||||
|
await user.type(intervalInput, '30');
|
||||||
|
await user.click(screen.getByRole('switch', { name: '自动更新' }));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
queryClient.setQueryData(sourceQueryKey(9), {
|
||||||
|
...latestSource,
|
||||||
|
last_checked_at: '2026-07-19T10:30:00Z',
|
||||||
|
next_check_at: '2026-07-19T12:30:00Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(assetInput).toHaveValue('draft.zip');
|
||||||
|
expect(intervalInput).toHaveValue(30);
|
||||||
|
expect(
|
||||||
|
screen.getByRole('switch', { name: '自动更新' }),
|
||||||
|
).not.toBeChecked();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('remounts project-scoped source state when the route project changes', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
vi.mocked(PagesService.getSource).mockImplementation(async (projectId) => ({
|
||||||
|
...latestSource,
|
||||||
|
github_repository:
|
||||||
|
projectId === 9 ? 'openflare/project-nine' : 'openflare/project-ten',
|
||||||
|
}));
|
||||||
|
const { rerenderWithQuery } = renderWithQuery(
|
||||||
|
<PagesSourceCard key='source-9' projectId={9} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(await screen.findByRole('button', { name: '编辑来源' }));
|
||||||
|
expect(screen.getByRole('dialog')).toBeVisible();
|
||||||
|
|
||||||
|
rerenderWithQuery(<PagesSourceCard key='source-10' projectId={10} />);
|
||||||
|
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
expect(await screen.findByText('openflare/project-ten')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses low-frequency, near-due and bounded overdue polling', () => {
|
||||||
|
const now = Date.parse('2026-07-19T10:00:00Z');
|
||||||
|
const farSource = {
|
||||||
|
...latestSource,
|
||||||
|
next_check_at: new Date(now + 60 * 60 * 1_000).toISOString(),
|
||||||
|
};
|
||||||
|
expect(getLatestSourceIdlePollingDecision(farSource, now, null)).toEqual({
|
||||||
|
interval: 5 * 60 * 1_000,
|
||||||
|
overdueWindow: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const nearSource = {
|
||||||
|
...latestSource,
|
||||||
|
next_check_at: new Date(now + 60_000).toISOString(),
|
||||||
|
};
|
||||||
|
expect(getLatestSourceIdlePollingDecision(nearSource, now, null)).toEqual({
|
||||||
|
interval: 30_000,
|
||||||
|
overdueWindow: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const overdueSource = {
|
||||||
|
...latestSource,
|
||||||
|
next_check_at: new Date(now - 1).toISOString(),
|
||||||
|
};
|
||||||
|
const overdue = getLatestSourceIdlePollingDecision(
|
||||||
|
overdueSource,
|
||||||
|
now,
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
expect(overdue.interval).toBe(30_000);
|
||||||
|
expect(
|
||||||
|
getLatestSourceIdlePollingDecision(
|
||||||
|
overdueSource,
|
||||||
|
now + 10 * 60 * 1_000,
|
||||||
|
overdue.overdueWindow,
|
||||||
|
).interval,
|
||||||
|
).toBe(false);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getLatestSourceIdlePollingDecision(
|
||||||
|
{
|
||||||
|
...latestSource,
|
||||||
|
release_selector: 'tag',
|
||||||
|
release_tag: 'v1.2.3',
|
||||||
|
auto_update_enabled: false,
|
||||||
|
check_interval_minutes: 0,
|
||||||
|
},
|
||||||
|
now,
|
||||||
|
null,
|
||||||
|
).interval,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes source consumers after a background deployment is applied', async () => {
|
||||||
|
vi.mocked(PagesService.getSource).mockResolvedValue(latestSource);
|
||||||
|
const { queryClient } = renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||||
|
expect(await screen.findByText('openflare/site')).toBeVisible();
|
||||||
|
|
||||||
|
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries');
|
||||||
|
const updatedSource: PagesGitHubReleaseSource = {
|
||||||
|
...latestSource,
|
||||||
|
last_applied: {
|
||||||
|
revision: 'b'.repeat(64),
|
||||||
|
label: 'v1.2.3',
|
||||||
|
asset_name: 'dist.zip',
|
||||||
|
},
|
||||||
|
last_synced_at: '2026-07-19T10:30:00Z',
|
||||||
|
};
|
||||||
|
vi.mocked(PagesService.getSource).mockResolvedValue(updatedSource);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
queryClient.setQueryData(sourceQueryKey(9), updatedSource);
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||||
|
queryKey: sourceQueryKey(9),
|
||||||
|
});
|
||||||
|
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||||
|
queryKey: projectQueryKey(9),
|
||||||
|
});
|
||||||
|
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||||
|
queryKey: deploymentsQueryKey(9),
|
||||||
|
});
|
||||||
|
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||||
|
queryKey: ['openflare', 'pages', 'deployment-files', 9],
|
||||||
|
});
|
||||||
|
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||||
|
queryKey: projectsQueryKey,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns that rollback closes auto update and refreshes four query groups', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const deployment: PagesDeployment = {
|
||||||
|
id: 51,
|
||||||
|
project_id: 9,
|
||||||
|
deployment_number: 7,
|
||||||
|
checksum: 'd'.repeat(64),
|
||||||
|
status: 'uploaded',
|
||||||
|
file_count: 12,
|
||||||
|
total_size: 4_096,
|
||||||
|
created_by: 'user:1',
|
||||||
|
source_type: 'github_release',
|
||||||
|
source_label: 'v1.2.2',
|
||||||
|
trigger_type: 'scheduled_auto_update',
|
||||||
|
created_at: '2026-07-19T10:00:00Z',
|
||||||
|
activated_at: null,
|
||||||
|
};
|
||||||
|
vi.mocked(PagesService.listDeployments).mockResolvedValue([deployment]);
|
||||||
|
vi.mocked(PagesService.activateDeployment).mockResolvedValue({} as never);
|
||||||
|
|
||||||
|
const { queryClient } = renderWithQuery(
|
||||||
|
<DeploymentHistory projectId={9} activeDeploymentId={52} />,
|
||||||
|
);
|
||||||
|
await screen.findByText('GitHub · v1.2.2 · 定时更新');
|
||||||
|
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: '激活' }));
|
||||||
|
const dialog = screen.getByRole('alertdialog');
|
||||||
|
expect(
|
||||||
|
within(dialog).getByText(
|
||||||
|
'激活其它历史部署会终止当前来源任务;若已开启自动更新,将同时关闭自动更新。',
|
||||||
|
),
|
||||||
|
).toBeVisible();
|
||||||
|
await user.click(within(dialog).getByRole('button', { name: '确认' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(PagesService.activateDeployment).toHaveBeenCalledWith(9, 51);
|
||||||
|
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||||
|
queryKey: deploymentsQueryKey(9),
|
||||||
|
});
|
||||||
|
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||||
|
queryKey: projectQueryKey(9),
|
||||||
|
});
|
||||||
|
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||||
|
queryKey: sourceQueryKey(9),
|
||||||
|
});
|
||||||
|
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||||
|
queryKey: projectsQueryKey,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -150,7 +150,7 @@ describe('Pages source UI', () => {
|
|||||||
vi.mocked(AdminTaskService.getTaskExecution).mockReset();
|
vi.mocked(AdminTaskService.getTaskExecution).mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('offers the three Phase 2 source types without future repository build controls', async () => {
|
it('offers the three source types without future repository build controls', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
vi.mocked(PagesService.getSource).mockResolvedValue({
|
vi.mocked(PagesService.getSource).mockResolvedValue({
|
||||||
source_type: 'manual',
|
source_type: 'manual',
|
||||||
@@ -177,11 +177,13 @@ describe('Pages source UI', () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByText('仓库源码构建将在后续作为独立来源类型提供。'),
|
screen.getByText('仓库源码构建将在后续作为独立来源类型提供。'),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
expect(screen.getByRole('switch', { name: '自动更新' })).not.toBeChecked();
|
||||||
|
expect(screen.getByLabelText('检查间隔(分钟)')).toHaveValue(60);
|
||||||
expect(screen.queryByText('构建命令')).not.toBeInTheDocument();
|
expect(screen.queryByText('构建命令')).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText('输出目录')).not.toBeInTheDocument();
|
expect(screen.queryByText('输出目录')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('submits the GitHub latest payload while keeping automatic controls hidden', async () => {
|
it('submits the GitHub latest automatic update settings', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
vi.mocked(PagesService.getSource).mockResolvedValue({
|
vi.mocked(PagesService.getSource).mockResolvedValue({
|
||||||
source_type: 'manual',
|
source_type: 'manual',
|
||||||
@@ -204,8 +206,10 @@ describe('Pages source UI', () => {
|
|||||||
expect(screen.getByLabelText('Release Asset 文件名')).toHaveValue(
|
expect(screen.getByLabelText('Release Asset 文件名')).toHaveValue(
|
||||||
'dist.zip',
|
'dist.zip',
|
||||||
);
|
);
|
||||||
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
|
await user.click(screen.getByRole('switch', { name: '自动更新' }));
|
||||||
expect(screen.queryByText('检查间隔')).not.toBeInTheDocument();
|
const intervalInput = screen.getByLabelText('检查间隔(分钟)');
|
||||||
|
await user.clear(intervalInput);
|
||||||
|
await user.type(intervalInput, '15');
|
||||||
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -215,8 +219,8 @@ describe('Pages source UI', () => {
|
|||||||
release_selector: 'latest',
|
release_selector: 'latest',
|
||||||
release_tag: '',
|
release_tag: '',
|
||||||
asset_name: 'dist.zip',
|
asset_name: 'dist.zip',
|
||||||
auto_update_enabled: false,
|
auto_update_enabled: true,
|
||||||
check_interval_minutes: 60,
|
check_interval_minutes: 15,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -257,7 +261,8 @@ describe('Pages source UI', () => {
|
|||||||
...githubLatestSource,
|
...githubLatestSource,
|
||||||
release_selector: 'tag',
|
release_selector: 'tag',
|
||||||
release_tag: 'v1.2.3',
|
release_tag: 'v1.2.3',
|
||||||
check_interval_minutes: undefined,
|
auto_update_enabled: false,
|
||||||
|
check_interval_minutes: 0,
|
||||||
next_check_at: null,
|
next_check_at: null,
|
||||||
},
|
},
|
||||||
check_task: null,
|
check_task: null,
|
||||||
@@ -274,6 +279,10 @@ describe('Pages source UI', () => {
|
|||||||
'https://github.com/openflare/site',
|
'https://github.com/openflare/site',
|
||||||
);
|
);
|
||||||
await user.click(screen.getByRole('radio', { name: '固定 Tag' }));
|
await user.click(screen.getByRole('radio', { name: '固定 Tag' }));
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('switch', { name: '自动更新' }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText('检查间隔(分钟)')).not.toBeInTheDocument();
|
||||||
await user.type(
|
await user.type(
|
||||||
screen.getByLabelText('Release tag'),
|
screen.getByLabelText('Release tag'),
|
||||||
'release/candidate#1&channel=stable',
|
'release/candidate#1&channel=stable',
|
||||||
@@ -380,7 +389,7 @@ describe('Pages source UI', () => {
|
|||||||
expect(PagesService.syncSource).not.toHaveBeenCalled();
|
expect(PagesService.syncSource).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows GitHub release state and hides Phase 3 controls', async () => {
|
it('shows GitHub latest automatic update state and schedule', async () => {
|
||||||
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
|
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
|
||||||
|
|
||||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||||
@@ -389,9 +398,11 @@ describe('Pages source UI', () => {
|
|||||||
expect(screen.getByText('v1.2.3 · bbbbbbbbbbbb')).toBeVisible();
|
expect(screen.getByText('v1.2.3 · bbbbbbbbbbbb')).toBeVisible();
|
||||||
expect(screen.getByText('v1.2.2 · aaaaaaaaaaaa')).toBeVisible();
|
expect(screen.getByText('v1.2.2 · aaaaaaaaaaaa')).toBeVisible();
|
||||||
expect(screen.getByText('有可用更新')).toBeVisible();
|
expect(screen.getByText('有可用更新')).toBeVisible();
|
||||||
expect(screen.queryByText('下次检查时间')).not.toBeInTheDocument();
|
expect(screen.getByText('下次检查时间')).toBeVisible();
|
||||||
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
|
expect(screen.getByText('自动更新')).toBeVisible();
|
||||||
expect(screen.queryByText('检查间隔')).not.toBeInTheDocument();
|
expect(screen.getByText('已关闭')).toBeVisible();
|
||||||
|
expect(screen.getByText('检查间隔')).toBeVisible();
|
||||||
|
expect(screen.getByText('60 分钟')).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('dispatches a GitHub check and starts TaskExecution polling', async () => {
|
it('dispatches a GitHub check and starts TaskExecution polling', async () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user