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
95 lines
7.6 KiB
TypeScript
95 lines
7.6 KiB
TypeScript
import type { components } from '@pipu/api-client';
|
|
import { unwrap } from '@pipu/core';
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import { useEffect, 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 } from '../../shared/ui/ui';
|
|
import { JobPanel } from './JobPanel';
|
|
import s from './management.module.css';
|
|
|
|
export function ConfigPage() {
|
|
const { t } = useTranslation('admin');
|
|
const session = useSession();
|
|
const errorText = useErrorText();
|
|
const qc = useQueryClient();
|
|
const [draft, setDraft] = useState<Record<string, string | null>>({});
|
|
const [baseRevision, setBaseRevision] = useState<string | null>(null);
|
|
const [search, setSearch] = useState('');
|
|
const [group, setGroup] = useState('all');
|
|
const [confirm, setConfirm] = useState(false);
|
|
const [saved, setSaved] = useState(false);
|
|
const dirty = Object.keys(draft).length > 0;
|
|
const query = useQuery({ queryKey: ['server-config'], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/config')), retry: false });
|
|
const status = useQuery({ queryKey: ['server-updates', 'config'], queryFn: async () => unwrap(await session.client.GET('/api/v1/admin/server/updates')), refetchInterval: 3000, retry: false });
|
|
const running = status.data?.job?.state === 'running';
|
|
useEffect(() => {
|
|
if (!dirty) return;
|
|
const prevent = (e: BeforeUnloadEvent) => { e.preventDefault(); };
|
|
window.addEventListener('beforeunload', prevent);
|
|
return () => window.removeEventListener('beforeunload', prevent);
|
|
}, [dirty]);
|
|
useEffect(() => {
|
|
if (status.data?.job?.state === 'succeeded') void qc.invalidateQueries({ queryKey: ['server-config'] });
|
|
}, [status.data?.job?.id, status.data?.job?.state, qc]);
|
|
const save = useMutation({
|
|
mutationFn: async () => unwrap(await session.client.PATCH('/api/v1/admin/server/config', { body: { revision: baseRevision ?? query.data!.revision, values: draft } })),
|
|
onSuccess: (data) => { qc.setQueryData(['server-config'], data); setDraft({}); setBaseRevision(null); setSaved(true); },
|
|
});
|
|
const apply = useMutation({
|
|
mutationFn: async () => unwrap(await session.client.POST('/api/v1/admin/server/config/apply')),
|
|
onSuccess: (job) => {
|
|
setConfirm(false);
|
|
qc.setQueryData<components['schemas']['ServerUpdates']>(['server-updates', 'config'], (old) => old ? { ...old, job } : old);
|
|
void qc.invalidateQueries({ queryKey: ['server-updates'] });
|
|
},
|
|
});
|
|
const change = (key: string, value: string | null) => {
|
|
const entry = query.data!.entries.find((v) => v.key === key)!;
|
|
const unchanged = value === null ? entry.local === null
|
|
: entry.secret ? value === '' : value === (entry.local ?? '');
|
|
const next = { ...draft };
|
|
if (unchanged) delete next[key]; else next[key] = value;
|
|
setBaseRevision(Object.keys(next).length ? baseRevision ?? query.data!.revision : null);
|
|
setDraft(next); setSaved(false); save.reset();
|
|
};
|
|
if (query.isPending) return <PageSkeleton />;
|
|
const entries = query.data?.entries.filter((e) => (group === 'all' || e.group === group) && `${e.key} ${e.group}`.toLowerCase().includes(search.toLowerCase())) ?? [];
|
|
return <div className={s.page}>
|
|
<header className={s.header}><div><span className={s.eyebrow}>{t('management.server')}</span><h1>{t('management.config')}</h1><p>{t('management.config_intro')}</p></div></header>
|
|
{query.error && <FormError>{errorText(query.error)}</FormError>}
|
|
{query.data && <>
|
|
<section className={s.hero}><div><span className={s.eyebrow}>{t('management.priority')}</span><strong className={s.priority}>ENV → config.local → {t('management.defaults')}</strong></div><p>{t('management.priority_hint')}</p></section>
|
|
<div className={s.toolbar}><Field type="search" label={t('management.search')} value={search} onChange={setSearch} /><label className={s.selectLabel}>{t('management.group')}<select value={group} onChange={(e) => setGroup(e.target.value)}><option value="all">{t('management.all')}</option>{[...new Set(query.data.entries.map((v) => v.group))].map((v) => <option key={v} value={v}>{t(`management.groups.${v}`, { defaultValue: v })}</option>)}</select></label></div>
|
|
<p className={s.note}>{t('management.config_files_hint')}</p>
|
|
<div className={s.configList}>{entries.map((entry) => {
|
|
const changed = Object.hasOwn(draft, entry.key);
|
|
const value = changed ? draft[entry.key] ?? '' : entry.secret ? '' : entry.local ?? '';
|
|
return <section className={s.configRow} key={entry.key} data-changed={changed || undefined}>
|
|
<div className={s.configMeta}><code>{entry.key}</code><span className={s.tag}>{t(`management.groups.${entry.group}`, { defaultValue: entry.group })}</span><span className={s.note}>{t(`management.value_source.${entry.source}`)}</span></div>
|
|
<div className={s.values}><span>{t('management.effective')}: <code>{entry.effective || '—'}</code></span><span>ENV: <code>{entry.env ?? '—'}</code></span></div>
|
|
{entry.readonly ? <p className={s.note}>{t('management.readonly')}</p> : <div className={s.editor}>
|
|
<Field label={`${entry.key} · config.local`} type={entry.secret ? 'password' : 'text'} value={value} disabled={running || save.isPending} placeholder={entry.secret && entry.local ? t('management.secret_saved') : entry.default_value} hint={changed && draft[entry.key] === null ? t('management.will_reset') : entry.env !== null ? t('management.overridden') : entry.secret ? t('management.secret_hint') : `${t('management.default')}: ${entry.default_value || '—'}`} onChange={(v) => change(entry.key, v)} mono />
|
|
<Button disabled={running || save.isPending || (!changed && entry.local === null)} onClick={() => change(entry.key, null)}>{t('management.reset')}</Button>
|
|
</div>}
|
|
</section>;
|
|
})}</div>
|
|
{!entries.length && <p className={s.note}>{t('management.no_results')}</p>}
|
|
{saved && !dirty && <p className={s.note} role="status">{t('management.saved')}</p>}
|
|
{dirty && <section className={s.saveBar} aria-label={t('management.unsaved_panel')}>
|
|
<div role="status">{t('management.unsaved', { count: Object.keys(draft).length })}</div>
|
|
<div className={s.actions}><Button disabled={save.isPending} onClick={() => { setDraft({}); setBaseRevision(null); save.reset(); }}>{t('management.discard')}</Button><Button variant="primary" disabled={running} busy={save.isPending} onClick={() => save.mutate()}>{t('management.save')}</Button></div>
|
|
</section>}
|
|
{!dirty && query.data.restart_required && !running && <section className={s.saveBar} aria-label={t('management.pending_restart')}>
|
|
<div role="status">{t('management.pending_restart')}</div>
|
|
<Button busy={apply.isPending} onClick={() => { apply.reset(); setConfirm(true); }}>{t('management.apply')}</Button>
|
|
</section>}
|
|
{save.error && <FormError>{errorText(save.error)}</FormError>}
|
|
{apply.error && <FormError>{errorText(apply.error)}</FormError>}
|
|
<JobPanel job={status.data?.job} reconnecting={status.isError} />
|
|
</>}
|
|
{confirm && <Dialog title={t('management.apply')} onClose={() => !apply.isPending && setConfirm(false)}><p>{t('management.apply_warning')}</p>{apply.error && <FormError>{errorText(apply.error)}</FormError>}<div className={s.actions}><Button disabled={apply.isPending} onClick={() => setConfirm(false)}>{t('management.cancel')}</Button><Button variant="primary" busy={apply.isPending} onClick={() => apply.mutate()}>{t('management.apply')}</Button></div></Dialog>}
|
|
</div>;
|
|
}
|