feat(settings): add panel update management UI
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 10s
All checks were successful
Deploy to Production / Trigger production update (push) Successful in 10s
Expand the About tab into a full update manager over the existing backend endpoints: edit the update repository URL, check for updates (showing changelog/errors), trigger an update with a strong confirmation, and show a live progress banner with a reset action while an update runs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
useSecuritySettings, useUpdateSecuritySettings,
|
||||
useNotificationChannels, useUpdateSMTP, useUpdateTelegram,
|
||||
useAppVersion, useCheckUpdates,
|
||||
useUpdateRepository, useSetUpdateRepository, useUpdateStatus, usePerformUpdate, useResetUpdate,
|
||||
} from '@/hooks/use-settings';
|
||||
import { SSHKeysManager } from './SSHKeysManager';
|
||||
|
||||
@@ -218,20 +219,102 @@ function AboutTab() {
|
||||
const { t } = useTranslation();
|
||||
const { data: version } = useAppVersion();
|
||||
const checkUpdates = useCheckUpdates();
|
||||
const { data: repo } = useUpdateRepository();
|
||||
const setRepo = useSetUpdateRepository();
|
||||
const performUpdate = usePerformUpdate();
|
||||
const resetUpdate = useResetUpdate();
|
||||
|
||||
const check = checkUpdates.data;
|
||||
const updating = check?.update_available ?? false;
|
||||
// Poll the update status only while an update is running.
|
||||
const { data: status } = useUpdateStatus(performUpdate.isSuccess);
|
||||
|
||||
const [repoUrl, setRepoUrl] = useState('');
|
||||
const [repoEdit, setRepoEdit] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (repo?.repository_url) setRepoUrl(repo.repository_url);
|
||||
}, [repo?.repository_url]);
|
||||
|
||||
const handleSaveRepo = () => {
|
||||
setRepo.mutate(repoUrl, {
|
||||
onSuccess: () => { toast.success(t('settings.repo_saved')); setRepoEdit(false); },
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
const handlePerform = () => {
|
||||
if (!confirm(t('settings.update_confirm'))) return;
|
||||
performUpdate.mutate(undefined, {
|
||||
onSuccess: (r) => r.success ? toast.success(r.message || t('settings.update_started')) : toast.error(r.error || 'Error'),
|
||||
onError: (e: Error) => toast.error(e.message),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-sm">{t('settings.about')}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{t('settings.version')}:</span>
|
||||
<Badge variant="outline">{version?.version || '—'}</Badge>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => checkUpdates.mutate()}>
|
||||
{t('settings.check_updates')}
|
||||
</Button>
|
||||
{checkUpdates.data && (
|
||||
<p className="text-sm">{checkUpdates.data.has_update ? `${t('settings.update_available')}: ${checkUpdates.data.latest_version}` : t('settings.up_to_date')}</p>
|
||||
|
||||
{/* Repository */}
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">{t('settings.update_repository')}</Label>
|
||||
{repoEdit ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input value={repoUrl} onChange={e => setRepoUrl(e.target.value)} placeholder="https://git.example.com/owner/repo" />
|
||||
<Button size="sm" onClick={handleSaveRepo} disabled={setRepo.isPending || !repoUrl}>{t('common.save')}</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => { setRepoEdit(false); setRepoUrl(repo?.repository_url || ''); }}>{t('common.cancel')}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="rounded bg-muted px-2 py-1 text-xs">{repo?.repository_url || '—'}</code>
|
||||
<Button size="sm" variant="ghost" onClick={() => setRepoEdit(true)}>{t('common.edit', 'Edit')}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Check */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => checkUpdates.mutate()} disabled={checkUpdates.isPending}>
|
||||
{t('settings.check_updates')}
|
||||
</Button>
|
||||
{updating && (
|
||||
<Button size="sm" onClick={handlePerform} disabled={performUpdate.isPending || status?.is_updating}>
|
||||
{t('settings.perform_update')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{check && (
|
||||
<div className="space-y-1 text-sm">
|
||||
{check.error ? (
|
||||
<p className="text-destructive">{check.error}</p>
|
||||
) : check.update_available ? (
|
||||
<p className="font-medium text-amber-600 dark:text-amber-500">
|
||||
{t('settings.update_available')}: {check.latest_version}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-muted-foreground">{t('settings.up_to_date')}</p>
|
||||
)}
|
||||
{check.changelog && (
|
||||
<pre className="mt-2 max-h-48 overflow-auto rounded bg-muted p-3 text-xs whitespace-pre-wrap">{check.changelog}</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Update in progress banner */}
|
||||
{status?.is_updating && (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
|
||||
<p className="font-medium">{t('settings.update_in_progress')}</p>
|
||||
<p className="text-xs text-muted-foreground">{status.stage} — {status.progress}%</p>
|
||||
<Button size="sm" variant="ghost" className="mt-2" onClick={() => resetUpdate.mutate()} disabled={resetUpdate.isPending}>
|
||||
{t('settings.update_reset')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -7,8 +7,32 @@ export const settingsKeys = {
|
||||
security: ['settings-security'] as const,
|
||||
channels: ['notification-channels'] as const,
|
||||
version: ['app-version'] as const,
|
||||
updateRepository: ['update-repository'] as const,
|
||||
updateStatus: ['update-status'] as const,
|
||||
};
|
||||
|
||||
export interface CheckUpdateResult {
|
||||
current_version: string;
|
||||
latest_version: string | null;
|
||||
update_available: boolean;
|
||||
changelog: string | null;
|
||||
error: string | null;
|
||||
git_available: boolean;
|
||||
project_mounted: boolean;
|
||||
disabled: boolean;
|
||||
repository_url?: string;
|
||||
commits_behind?: number;
|
||||
}
|
||||
|
||||
export interface UpdateStatus {
|
||||
is_updating: boolean;
|
||||
started_at: string | null;
|
||||
stage: string | null;
|
||||
progress: number;
|
||||
error: string | null;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
interface ProfileResponse {
|
||||
id: number;
|
||||
username: string;
|
||||
@@ -117,6 +141,50 @@ export function useAppVersion() {
|
||||
|
||||
export function useCheckUpdates() {
|
||||
return useMutation({
|
||||
mutationFn: () => apiClient.get<Record<string, unknown>>('/settings/api/updates/check'),
|
||||
mutationFn: () => apiClient.get<CheckUpdateResult>('/settings/api/updates/check'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRepository() {
|
||||
return useQuery({
|
||||
queryKey: settingsKeys.updateRepository,
|
||||
queryFn: () => apiClient.get<{ repository_url: string }>('/settings/api/updates/repository'),
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetUpdateRepository() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
// The endpoint takes repository_url as a query parameter, not a JSON body.
|
||||
mutationFn: (repository_url: string) =>
|
||||
apiClient.put<{ message: string; repository_url: string }>(
|
||||
`/settings/api/updates/repository?repository_url=${encodeURIComponent(repository_url)}`,
|
||||
{},
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.updateRepository }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateStatus(poll = false) {
|
||||
return useQuery({
|
||||
queryKey: settingsKeys.updateStatus,
|
||||
queryFn: () => apiClient.get<UpdateStatus>('/settings/api/updates/status'),
|
||||
refetchInterval: poll ? 3000 : false,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePerformUpdate() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => apiClient.post<{ success: boolean; message?: string; error?: string }>('/settings/api/updates/perform', {}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.updateStatus }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useResetUpdate() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => apiClient.post<{ success: boolean; message: string }>('/settings/api/updates/reset', {}),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: settingsKeys.updateStatus }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -554,7 +554,14 @@
|
||||
"version": "Version",
|
||||
"check_updates": "Check for Updates",
|
||||
"update_available": "Update available",
|
||||
"up_to_date": "You are up to date"
|
||||
"up_to_date": "You are up to date",
|
||||
"update_repository": "Update repository",
|
||||
"repo_saved": "Repository URL saved",
|
||||
"perform_update": "Update now",
|
||||
"update_confirm": "Start the update? The panel will pull the latest version, rebuild and restart — it will be briefly unavailable.",
|
||||
"update_started": "Update started",
|
||||
"update_in_progress": "Update in progress",
|
||||
"update_reset": "Reset status"
|
||||
},
|
||||
"ssh_keys": {
|
||||
"title": "SSH Keys",
|
||||
|
||||
@@ -554,7 +554,14 @@
|
||||
"version": "Версия",
|
||||
"check_updates": "Проверить обновления",
|
||||
"update_available": "Доступно обновление",
|
||||
"up_to_date": "Установлена последняя версия"
|
||||
"up_to_date": "Установлена последняя версия",
|
||||
"update_repository": "Репозиторий обновлений",
|
||||
"repo_saved": "URL репозитория сохранён",
|
||||
"perform_update": "Обновить сейчас",
|
||||
"update_confirm": "Запустить обновление? Панель скачает последнюю версию, пересоберётся и перезапустится — на короткое время будет недоступна.",
|
||||
"update_started": "Обновление запущено",
|
||||
"update_in_progress": "Идёт обновление",
|
||||
"update_reset": "Сбросить статус"
|
||||
},
|
||||
"ssh_keys": {
|
||||
"title": "SSH ключи",
|
||||
|
||||
Reference in New Issue
Block a user