feat(admin): manage server updates and local configuration
ci / api / api (push) Successful in 52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 4m6s
ci / db / db (push) Successful in 1m15s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m19s
ci / ts / ts (packages + web) (push) Successful in 2m34s
release / desktop (Linux и Windows в релиз) (push) Successful in 14m27s
release / prune (старые файлы и прогоны) (push) Successful in 6s
ci / api / api (push) Successful in 52s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m9s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m35s
release / release (версия и заметки) (push) Successful in 5s
release / android (APK в релиз) (push) Successful in 4m6s
ci / db / db (push) Successful in 1m15s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m19s
ci / ts / ts (packages + web) (push) Successful in 2m34s
release / desktop (Linux и Windows в релиз) (push) Successful in 14m27s
release / prune (старые файлы и прогоны) (push) Successful in 6s
This commit is contained in:
1 parent
e33abc948c
commit
a29a8b18ad
49 files changed
+3604
-353
No files matched your search
@@ -0,0 +1,92 @@
|
||||
import type { components } from '@pipu/api-client';
|
||||
import { unwrap } from '@pipu/core';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSession } from '../../app/session';
|
||||
import { useErrorText } from '../../shared/lib/errors';
|
||||
import { Button, Dialog, Field, FormError, PageSkeleton, Segmented } from '../../shared/ui/ui';
|
||||
import { JobPanel } from './JobPanel';
|
||||
import s from './management.module.css';
|
||||
|
||||
type Source = components['schemas']['ServerSource'];
|
||||
type Channel = components['schemas']['ServerUpdateRequest']['channel'];
|
||||
|
||||
export function UpdatesPage() {
|
||||
const { t } = useTranslation('admin');
|
||||
const session = useSession();
|
||||
const qc = useQueryClient();
|
||||
const errorText = useErrorText();
|
||||
const [sourceId, setSourceId] = useState('');
|
||||
const [channel, setChannel] = useState<Channel>('latest');
|
||||
const [version, setVersion] = useState('');
|
||||
const [confirm, setConfirm] = useState(false);
|
||||
const [editing, setEditing] = useState<Source[] | null>(null);
|
||||
const query = useQuery({
|
||||
queryKey: ['server-updates', sourceId],
|
||||
queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/updates', {
|
||||
params: { query: { source_id: sourceId || undefined } },
|
||||
})),
|
||||
refetchInterval: 3000,
|
||||
retry: false,
|
||||
});
|
||||
const source = query.data?.sources.find((v) => v.id === sourceId) ?? query.data?.sources[0];
|
||||
const running = query.data?.job?.state === 'running';
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['server-updates'] });
|
||||
const save = useMutation({
|
||||
mutationFn: async (sources: Source[]) => unwrap(await session.client.PUT('/api/v1/admin/server/update-sources', { body: { sources } })),
|
||||
onSuccess: () => { setEditing(null); setSourceId(''); void refresh(); },
|
||||
});
|
||||
const start = useMutation({
|
||||
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/server/updates', {
|
||||
body: { source_id: source!.id, channel, version: channel === 'version' ? version : undefined },
|
||||
})),
|
||||
onSuccess: (job) => {
|
||||
setConfirm(false);
|
||||
qc.setQueryData<components['schemas']['ServerUpdates']>(['server-updates', sourceId], (old) => old ? { ...old, job } : old);
|
||||
void refresh();
|
||||
},
|
||||
});
|
||||
if (query.isPending) return <PageSkeleton />;
|
||||
return <div className={s.page}>
|
||||
<header className={s.header}><div><span className={s.eyebrow}>{t('management.server')}</span><h1>{t('management.updates')}</h1><p>{t('management.updates_intro')}</p></div>
|
||||
<Button onClick={() => void query.refetch()} busy={query.isFetching}>{t('management.refresh')}</Button>
|
||||
</header>
|
||||
{query.error && <FormError>{errorText(query.error)}</FormError>}
|
||||
{query.data && <>
|
||||
<section className={s.hero}><div><span className={s.eyebrow}>{t('management.current')}</span><strong>{query.data.current_version || '—'}</strong></div><p>{t('management.manual')}</p></section>
|
||||
<section className={s.card}>
|
||||
<div className={s.cardHead}><h2>{t('management.update_target')}</h2><Button disabled={running} onClick={() => { save.reset(); setEditing(query.data.sources.map((v) => ({ ...v }))); }}>{t('management.sources')}</Button></div>
|
||||
<label className={s.selectLabel}>{t('management.source')}
|
||||
<select aria-label={t('management.source')} value={source?.id ?? ''} disabled={running || start.isPending} onChange={(e) => { setSourceId(e.target.value); setVersion(''); start.reset(); }}>
|
||||
{query.data.sources.map((v) => <option key={v.id} value={v.id}>{v.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<p className={s.url}>{source?.url}</p>
|
||||
<Segmented label={t('management.channel')} value={channel} options={(['latest', 'version', 'main'] as const).map((value) => ({ value, label: t(`management.channel_${value}`) }))} onChange={(v) => { setChannel(v); start.reset(); }} />
|
||||
<p className={s.note}>{t(`management.hint_${channel}`)}</p>
|
||||
{channel === 'version' && <label className={s.selectLabel}>{t('management.version')}<select aria-label={t('management.version')} value={version} onChange={(e) => setVersion(e.target.value)}><option value="">{t('management.choose_version')}</option>{query.data.versions.map((v) => <option key={v} value={v}>{v}</option>)}</select></label>}
|
||||
{query.data.source_error && <FormError>{t('management.source_unavailable')}</FormError>}
|
||||
{start.error && <FormError>{errorText(start.error)}</FormError>}
|
||||
<div className={s.actions}><Button variant="primary" disabled={!source || running || !!query.data.source_error || (channel === 'version' && !version)} busy={start.isPending} onClick={() => { start.reset(); setConfirm(true); }}>{t('management.install')}</Button></div>
|
||||
</section>
|
||||
<JobPanel job={query.data.job} reconnecting={query.isError} />
|
||||
</>}
|
||||
{confirm && <Dialog title={t('management.confirm_update')} onClose={() => !start.isPending && setConfirm(false)}>
|
||||
<p>{t('management.confirm_update_text', { source: source?.name, target: channel === 'version' ? version : t(`management.channel_${channel}`) })}</p>
|
||||
<p className={s.note}>{t('management.update_warning')}</p>
|
||||
{start.error && <FormError>{errorText(start.error)}</FormError>}
|
||||
<div className={s.actions}><Button disabled={start.isPending} onClick={() => setConfirm(false)}>{t('management.cancel')}</Button><Button variant="primary" busy={start.isPending} onClick={() => start.mutate()}>{t('management.install')}</Button></div>
|
||||
</Dialog>}
|
||||
{editing && <Dialog title={t('management.sources')} onClose={() => !save.isPending && setEditing(null)}>
|
||||
<p className={s.note}>{t('management.sources_hint')}</p>
|
||||
<div className={s.sourceList}>{editing.map((item, index) => <div className={s.sourceEditor} key={item.id}>
|
||||
<Field label={t('management.source_name')} value={item.name} maxLength={100} onChange={(name) => setEditing(editing.map((v, i) => i === index ? { ...v, name } : v))} />
|
||||
<Field label={t('management.repository')} value={item.url} maxLength={2048} mono onChange={(url) => setEditing(editing.map((v, i) => i === index ? { ...v, url } : v))} />
|
||||
<Button disabled={editing.length === 1 || save.isPending} onClick={() => setEditing(editing.filter((v) => v.id !== item.id))}>{t('management.remove_source')}</Button>
|
||||
</div>)}</div>
|
||||
{save.error && <FormError>{errorText(save.error)}</FormError>}
|
||||
<div className={s.actions}><Button disabled={editing.length >= 20 || save.isPending} onClick={() => setEditing([...editing, { id: crypto.randomUUID(), name: '', url: '' }])}>{t('management.add_source')}</Button><Button variant="primary" disabled={editing.some((v) => !v.name.trim() || !v.url.trim())} busy={save.isPending} onClick={() => save.mutate(editing)}>{t('management.save')}</Button></div>
|
||||
</Dialog>}
|
||||
</div>;
|
||||
}
|
||||
Reference in new issue
Block a user