ci / api / api (push) Successful in 50s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 1m0s
ci / db / db (push) Successful in 58s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m27s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m39s
ci / ts / ts (packages + web) (push) Successful in 2m40s
110 lines
7.8 KiB
TypeScript
110 lines
7.8 KiB
TypeScript
import type { components } from '@pipu/api-client';
|
|
import { unwrap } from '@pipu/core';
|
|
import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useRef, 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 fresh = useRef(false);
|
|
const queryKey = ['server-updates', sourceId, channel, version];
|
|
const query = useQuery({
|
|
queryKey,
|
|
queryFn: async () => {
|
|
const force = fresh.current;
|
|
fresh.current = false;
|
|
return unwrap(await session.client.GET('/api/v1/admin/server/updates', {
|
|
params: { query: { source_id: sourceId || undefined, channel, version: channel === 'version' ? version : undefined, fresh: force } },
|
|
}));
|
|
},
|
|
placeholderData: keepPreviousData,
|
|
refetchInterval: (q) => q.state.data?.job?.state === 'running' ? 3000 : 60000,
|
|
retry: false,
|
|
});
|
|
const source = query.data?.sources.find((v) => v.id === sourceId) ?? query.data?.sources[0];
|
|
const running = query.data?.job?.state === 'running';
|
|
const checking = query.isFetching;
|
|
const availability = query.isError ? 'unavailable' : query.data?.update_state || 'unknown';
|
|
const canInstall = availability === 'available' && !checking && !query.isPlaceholderData && !query.isError && !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, expected_commit: query.data!.target_commit },
|
|
})),
|
|
onSuccess: (job) => {
|
|
setConfirm(false);
|
|
qc.setQueryData<components['schemas']['ServerUpdates']>(queryKey, (old) => old ? { ...old, job } : old);
|
|
void refresh();
|
|
},
|
|
onError: () => { 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={() => { fresh.current = true; void query.refetch(); }} busy={query.isFetching}>{t('management.check_updates')}</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.updateAction}>
|
|
<div role="status" aria-live="polite">
|
|
<strong>{t(`management.availability.${checking ? 'checking' : running ? 'running' : availability}`)}</strong>
|
|
{query.data.target_version && <p className={s.note}>{query.data.target_version} · {query.data.target_commit.slice(0, 12)}</p>}
|
|
</div>
|
|
{canInstall && <Button variant="primary" disabled={!source || !!query.data.source_error} 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: query.data?.target_version || version })}</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" disabled={!canInstall} 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>;
|
|
}
|