feat(update): Testing/Stable-Update-Kanäle wie enconf (Suite=trixie, Komponente=Kanal)
Installer (EDGEGUARD_CHANNEL), Kanal-Lesen/-Schreiben ohne DB-State (sources.list ist Quelle der Wahrheit), Cluster-Endpoints für Kanalwechsel mit mTLS-Peer-Propagation + Drift-Erkennung, --allow-downgrades für testing→stable-Downgrades über den bestehenden sicheren Rolling-Update- Flow, Settings-UI mit Bestätigung, neues scripts/release.sh (Testing-Push datumsbasiert YYYY.MM.DD.NN, Stable-Promotion mit Verify-Gate + Git-Tag), publish.sh/cleanup-old.sh kanalfähig mit Stable-Tag-Schutz. Migriert Bestandsnodes automatisch von der alten "main"-Komponente auf "stable" (postinst, idempotent) — ohne das würden vor diesem Release installierte Nodes stillschweigend keine Updates mehr sehen, sobald main nicht mehr bespielt wird. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Alert, Button, Card, Descriptions, Form, Input, InputNumber, Popconfirm, Select, Space, Spin, Switch, Tooltip, Typography, message } from 'antd'
|
||||
import { ApartmentOutlined, CloudDownloadOutlined, CloudSyncOutlined, CodeOutlined, CopyOutlined, DatabaseOutlined, DownloadOutlined, ExclamationCircleOutlined, FileSearchOutlined, GlobalOutlined, LockOutlined, MailOutlined, ReloadOutlined, SettingOutlined, StopOutlined, ToolOutlined } from '@ant-design/icons'
|
||||
import { ApartmentOutlined, BranchesOutlined, CloudDownloadOutlined, CloudSyncOutlined, CodeOutlined, CopyOutlined, DatabaseOutlined, DownloadOutlined, ExclamationCircleOutlined, FileSearchOutlined, GlobalOutlined, LockOutlined, MailOutlined, ReloadOutlined, SettingOutlined, StopOutlined, ToolOutlined } from '@ant-design/icons'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -342,6 +342,30 @@ export default function SettingsPage() {
|
||||
},
|
||||
})
|
||||
|
||||
const [channelDraft, setChannelDraft] = useState<string | null>(null)
|
||||
const { data: updateChannel } = useQuery({
|
||||
queryKey: ['cluster', 'update-channel'],
|
||||
queryFn: async () => {
|
||||
const r = await apiClient.get('/cluster/update-channel')
|
||||
return isEnvelope(r.data)
|
||||
? (r.data.data as { channel: string; peer_channel?: string; peer_reached: boolean; peer_drifted: boolean })
|
||||
: { channel: 'stable', peer_reached: false, peer_drifted: false }
|
||||
},
|
||||
})
|
||||
const setUpdateChannel = useMutation({
|
||||
mutationFn: async (channel: string) => {
|
||||
const r = await apiClient.post('/cluster/update-channel', { channel })
|
||||
return r.data
|
||||
},
|
||||
onSuccess: () => {
|
||||
msg.success(t('settings.updateChannelSaved'))
|
||||
void qc.invalidateQueries({ queryKey: ['cluster', 'update-channel'] })
|
||||
void qc.invalidateQueries({ queryKey: ['system', 'package-versions'] })
|
||||
},
|
||||
onError: (e: Error) => msg.error(t('settings.updateChannelFailed') + ': ' + e.message),
|
||||
onSettled: () => setChannelDraft(null),
|
||||
})
|
||||
|
||||
const { data: ipv6 } = useQuery({
|
||||
queryKey: ['system', 'ipv6'],
|
||||
queryFn: async () => {
|
||||
@@ -765,6 +789,55 @@ export default function SettingsPage() {
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={<><BranchesOutlined /> {t('settings.updateChannelCardTitle')}</>}
|
||||
className="mb-12"
|
||||
size="small"
|
||||
>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
<Space>
|
||||
<Select
|
||||
value={channelDraft ?? updateChannel?.channel ?? 'stable'}
|
||||
style={{ width: 160 }}
|
||||
disabled={isViewer}
|
||||
options={[
|
||||
{ value: 'stable', label: t('settings.updateChannelStable') },
|
||||
{ value: 'testing', label: t('settings.updateChannelTesting') },
|
||||
]}
|
||||
onChange={setChannelDraft}
|
||||
/>
|
||||
{channelDraft && channelDraft !== (updateChannel?.channel ?? 'stable') && (
|
||||
<Popconfirm
|
||||
title={t('settings.updateChannelConfirmTitle')}
|
||||
description={
|
||||
channelDraft === 'testing'
|
||||
? t('settings.updateChannelSwitchTestingWarn')
|
||||
: t('settings.updateChannelSwitchStableWarn')
|
||||
}
|
||||
okText={t('settings.updateChannelApply')}
|
||||
cancelText={t('common.cancel')}
|
||||
onConfirm={() => setUpdateChannel.mutate(channelDraft)}
|
||||
onCancel={() => setChannelDraft(null)}
|
||||
>
|
||||
<Button size="small" type="primary" loading={setUpdateChannel.isPending}>
|
||||
{t('settings.updateChannelApply')}
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
{updateChannel?.peer_drifted && (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message={t('settings.updateChannelDrift', { peer: updateChannel?.peer_channel ?? '?' })}
|
||||
/>
|
||||
)}
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('settings.updateChannelHint')}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title={<><GlobalOutlined /> {t('settings.ipv6CardTitle')}</>}
|
||||
className="mb-12"
|
||||
|
||||
Reference in New Issue
Block a user