909 lines
31 KiB
TypeScript
909 lines
31 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import {
|
||
Button,
|
||
Card,
|
||
Col,
|
||
Input,
|
||
InputNumber,
|
||
Modal,
|
||
Popconfirm,
|
||
Row,
|
||
Select,
|
||
Space,
|
||
Switch,
|
||
Tag,
|
||
Typography,
|
||
message,
|
||
} from 'antd';
|
||
import { DeleteOutlined, SettingOutlined, UploadOutlined } from '@ant-design/icons';
|
||
import { HttpUtil } from '@/utils';
|
||
import { onNumber } from '@/utils/onNumber';
|
||
|
||
// Item 7 B5: the public status page's settings + theme store. Like
|
||
// the probe-meta section this surface is self-contained — it loads
|
||
// and saves through its own endpoints and every change takes effect
|
||
// immediately (no panel save, no restart). Theme packages are static
|
||
// files served by the panel; installing one never runs its code here.
|
||
interface ThemeConfigField {
|
||
key: string;
|
||
type: string;
|
||
label: string;
|
||
default?: unknown;
|
||
help?: string;
|
||
options?: { value: string; label: string }[];
|
||
}
|
||
|
||
interface ThemeWire {
|
||
name: string;
|
||
short: string;
|
||
description: string;
|
||
version: string;
|
||
author: string;
|
||
url: string;
|
||
config?: ThemeConfigField[];
|
||
selected: boolean;
|
||
builtin: boolean;
|
||
preview: boolean;
|
||
}
|
||
|
||
interface ProbeSettingsWire {
|
||
publicPage: boolean;
|
||
siteName: string;
|
||
site: string;
|
||
githubProxy: string;
|
||
}
|
||
|
||
interface ThemesPayload {
|
||
themes: ThemeWire[];
|
||
settings: ProbeSettingsWire;
|
||
favicon: string;
|
||
touchIcon: string;
|
||
}
|
||
|
||
// Notify settings wire shape (B6) — mirrors probenotify.SettingsView.
|
||
// The webhook URL and headers are secrets: the GET answers only
|
||
// whether they are set, and the PUT omits them unless the admin
|
||
// edited the field (an explicit clear sends "").
|
||
interface NotifySettingsWire {
|
||
notifyGrace: number;
|
||
notifyTraffic: number;
|
||
notifyExpiry: number;
|
||
notifyLogin: boolean;
|
||
notifyTelegramText: string;
|
||
notifyWebhookBody: string;
|
||
telegramConfigured: boolean;
|
||
webhookConfigured: boolean;
|
||
notifyWebhookUrlSet: boolean;
|
||
notifyWebhookHeadersSet: boolean;
|
||
}
|
||
|
||
interface CoverageRow {
|
||
name: string;
|
||
online: boolean;
|
||
hasProbe: boolean;
|
||
probeAt: number;
|
||
}
|
||
|
||
interface NotifyPayload {
|
||
settings: NotifySettingsWire;
|
||
coverage: CoverageRow[];
|
||
}
|
||
|
||
const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } };
|
||
const UPLOAD_CHUNK = 1 << 20;
|
||
const MAX_ICON_BYTES = 20 * 1024;
|
||
|
||
function basePathPrefix(): string {
|
||
const bp = window.X_UI_BASE_PATH;
|
||
return typeof bp === 'string' && bp !== '/' ? bp.replace(/\/$/, '') : '';
|
||
}
|
||
|
||
function readAsDataURL(blob: Blob): Promise<string> {
|
||
return new Promise((resolve, reject) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(String(reader.result));
|
||
reader.onerror = () => reject(reader.error);
|
||
reader.readAsDataURL(blob);
|
||
});
|
||
}
|
||
|
||
// fileToIconDataURL fits a chosen image into the hub's 20 KiB site
|
||
// icon budget: small files pass through untouched (the server judges
|
||
// the bytes, not the declared type); a large raster is scaled down
|
||
// until it fits; a large SVG cannot be scaled and is refused here.
|
||
async function fileToIconDataURL(file: File): Promise<string> {
|
||
if (file.size <= MAX_ICON_BYTES) return readAsDataURL(file);
|
||
if (file.type === 'image/svg+xml' || file.type === 'image/x-icon' || file.type === 'image/gif') {
|
||
throw new Error('tooLarge');
|
||
}
|
||
const bitmap = await createImageBitmap(file);
|
||
try {
|
||
for (const size of [256, 180, 128, 96, 64]) {
|
||
const scale = Math.min(1, size / Math.max(bitmap.width, bitmap.height));
|
||
const canvas = document.createElement('canvas');
|
||
canvas.width = Math.max(1, Math.round(bitmap.width * scale));
|
||
canvas.height = Math.max(1, Math.round(bitmap.height * scale));
|
||
const ctx = canvas.getContext('2d');
|
||
if (!ctx) break;
|
||
ctx.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
|
||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||
if (blob && blob.size <= MAX_ICON_BYTES) return readAsDataURL(blob);
|
||
}
|
||
} finally {
|
||
bitmap.close();
|
||
}
|
||
throw new Error('tooLarge');
|
||
}
|
||
|
||
export default function PublicPageTab() {
|
||
const { t } = useTranslation();
|
||
const [messageApi, messageContextHolder] = message.useMessage();
|
||
const [themes, setThemes] = useState<ThemeWire[]>([]);
|
||
const [settings, setSettings] = useState<ProbeSettingsWire>({
|
||
publicPage: false,
|
||
siteName: 'Monitor',
|
||
site: '',
|
||
githubProxy: '',
|
||
});
|
||
const [favicon, setFavicon] = useState('');
|
||
const [touchIcon, setTouchIcon] = useState('');
|
||
const [saving, setSaving] = useState(false);
|
||
const [installURL, setInstallURL] = useState('');
|
||
const [installing, setInstalling] = useState(false);
|
||
const [uploadPct, setUploadPct] = useState<number | null>(null);
|
||
const [busyShort, setBusyShort] = useState('');
|
||
const [notify, setNotify] = useState<NotifySettingsWire | null>(null);
|
||
const [coverage, setCoverage] = useState<CoverageRow[]>([]);
|
||
const [webhookUrl, setWebhookUrl] = useState('');
|
||
const [webhookHeaders, setWebhookHeaders] = useState('');
|
||
const [urlTouched, setUrlTouched] = useState(false);
|
||
const [headersTouched, setHeadersTouched] = useState(false);
|
||
const [notifySaving, setNotifySaving] = useState(false);
|
||
const [notifyTesting, setNotifyTesting] = useState(false);
|
||
const fileRef = useRef<HTMLInputElement>(null);
|
||
const faviconRef = useRef<HTMLInputElement>(null);
|
||
const touchRef = useRef<HTMLInputElement>(null);
|
||
|
||
const applyPayload = useCallback((payload: ThemesPayload) => {
|
||
setThemes(payload.themes ?? []);
|
||
setSettings(payload.settings);
|
||
setFavicon(payload.favicon ?? '');
|
||
setTouchIcon(payload.touchIcon ?? '');
|
||
}, []);
|
||
|
||
const reload = useCallback(async () => {
|
||
const msg = await HttpUtil.get<ThemesPayload>('/panel/api/probe/themes', { silent: true });
|
||
if (msg?.success && msg.obj) applyPayload(msg.obj);
|
||
}, [applyPayload]);
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
HttpUtil.get<ThemesPayload>('/panel/api/probe/themes', { silent: true }).then((msg) => {
|
||
if (!cancelled && msg?.success && msg.obj) applyPayload(msg.obj);
|
||
});
|
||
HttpUtil.get<NotifyPayload>('/panel/api/probe/notify', { silent: true }).then((msg) => {
|
||
if (!cancelled && msg?.success && msg.obj) {
|
||
setNotify(msg.obj.settings);
|
||
setCoverage(msg.obj.coverage ?? []);
|
||
}
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [applyPayload]);
|
||
|
||
async function saveNotify() {
|
||
if (!notify) return;
|
||
setNotifySaving(true);
|
||
try {
|
||
const body: Record<string, unknown> = {
|
||
notifyGrace: notify.notifyGrace,
|
||
notifyTraffic: notify.notifyTraffic,
|
||
notifyExpiry: notify.notifyExpiry,
|
||
notifyLogin: notify.notifyLogin,
|
||
notifyTelegramText: notify.notifyTelegramText,
|
||
notifyWebhookBody: notify.notifyWebhookBody,
|
||
};
|
||
// Secrets ride along only when the field was edited; an
|
||
// edited-but-empty field clears the stored value server-side.
|
||
if (urlTouched) body.notifyWebhookUrl = webhookUrl;
|
||
if (headersTouched) body.notifyWebhookHeaders = webhookHeaders;
|
||
const msg = await HttpUtil.put<NotifyPayload>('/panel/api/probe/notify', body, {
|
||
silent: true,
|
||
...JSON_HEADERS,
|
||
});
|
||
if (msg?.success && msg.obj) {
|
||
setNotify(msg.obj.settings);
|
||
setCoverage(msg.obj.coverage ?? []);
|
||
setWebhookUrl('');
|
||
setWebhookHeaders('');
|
||
setUrlTouched(false);
|
||
setHeadersTouched(false);
|
||
messageApi.success(t('pages.settings.probeThemes.saved'));
|
||
} else {
|
||
messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
|
||
}
|
||
} finally {
|
||
setNotifySaving(false);
|
||
}
|
||
}
|
||
|
||
async function testNotify() {
|
||
setNotifyTesting(true);
|
||
try {
|
||
const msg = await HttpUtil.post<{ sent: string[] }>('/panel/api/probe/notify/test', undefined, {
|
||
silent: true,
|
||
});
|
||
if (msg?.success) {
|
||
messageApi.success(
|
||
t('pages.settings.probeNotify.testSent', { channels: (msg.obj?.sent ?? []).join('、') }),
|
||
);
|
||
} else {
|
||
messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
|
||
}
|
||
} finally {
|
||
setNotifyTesting(false);
|
||
}
|
||
}
|
||
|
||
async function saveSettings() {
|
||
setSaving(true);
|
||
try {
|
||
const msg = await HttpUtil.put(
|
||
'/panel/api/probe/settings',
|
||
{ ...settings, favicon, touchIcon },
|
||
{ silent: true, ...JSON_HEADERS },
|
||
);
|
||
if (msg?.success) messageApi.success(t('pages.settings.probeThemes.saved'));
|
||
else messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
async function pickIcon(which: 'favicon' | 'touchIcon', file: File | undefined) {
|
||
if (!file) return;
|
||
try {
|
||
const url = await fileToIconDataURL(file);
|
||
if (which === 'favicon') setFavicon(url);
|
||
else setTouchIcon(url);
|
||
} catch {
|
||
messageApi.error(t('pages.settings.probeThemes.iconTooLarge'));
|
||
}
|
||
}
|
||
|
||
async function uploadTheme(file: File | undefined) {
|
||
if (!file) return;
|
||
if (file.size === 0) {
|
||
messageApi.error(t('pages.settings.probeThemes.uploadFailed'));
|
||
return;
|
||
}
|
||
setUploadPct(0);
|
||
try {
|
||
let offset = 0;
|
||
while (offset < file.size) {
|
||
const slice = file.slice(offset, offset + UPLOAD_CHUNK);
|
||
const msg = await HttpUtil.post<{ received?: number; theme?: ThemeWire }>(
|
||
'/panel/api/probe/theme-upload',
|
||
slice,
|
||
{
|
||
silent: true,
|
||
params: { offset, total: file.size },
|
||
headers: { 'Content-Type': 'application/octet-stream' },
|
||
},
|
||
);
|
||
if (!msg?.success) {
|
||
messageApi.error(msg?.msg || t('pages.settings.probeThemes.uploadFailed'));
|
||
return;
|
||
}
|
||
if (msg.obj?.theme) {
|
||
messageApi.success(
|
||
t('pages.settings.probeThemes.installed', { name: msg.obj.theme.name }),
|
||
);
|
||
await reload();
|
||
return;
|
||
}
|
||
offset = msg.obj?.received ?? offset + slice.size;
|
||
setUploadPct(Math.round((offset / file.size) * 100));
|
||
}
|
||
} finally {
|
||
setUploadPct(null);
|
||
if (fileRef.current) fileRef.current.value = '';
|
||
}
|
||
}
|
||
|
||
async function installByURL() {
|
||
setInstalling(true);
|
||
try {
|
||
const msg = await HttpUtil.post<{ theme: ThemeWire }>(
|
||
'/panel/api/probe/theme-install',
|
||
{ url: installURL },
|
||
{ silent: true, ...JSON_HEADERS },
|
||
);
|
||
if (msg?.success && msg.obj?.theme) {
|
||
messageApi.success(t('pages.settings.probeThemes.installed', { name: msg.obj.theme.name }));
|
||
setInstallURL('');
|
||
await reload();
|
||
} else {
|
||
messageApi.error(msg?.msg || t('pages.settings.probeThemes.installFailed'));
|
||
}
|
||
} finally {
|
||
setInstalling(false);
|
||
}
|
||
}
|
||
|
||
async function themeAction(short: string, action: 'select' | 'update' | 'delete') {
|
||
setBusyShort(short);
|
||
try {
|
||
if (action === 'delete') {
|
||
const msg = await HttpUtil.delete(`/panel/api/probe/themes/${short}`, { silent: true });
|
||
if (!msg?.success) {
|
||
messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
|
||
return;
|
||
}
|
||
} else {
|
||
const msg = await HttpUtil.post<{ updated?: boolean; version?: string }>(
|
||
`/panel/api/probe/themes/${short}/${action}`,
|
||
undefined,
|
||
{ silent: true },
|
||
);
|
||
if (!msg?.success) {
|
||
messageApi.error(msg?.msg || t('pages.settings.probeThemes.saveFailed'));
|
||
return;
|
||
}
|
||
if (action === 'update') {
|
||
if (msg.obj?.updated) {
|
||
messageApi.success(
|
||
t('pages.settings.probeThemes.updatedTo', { version: msg.obj.version }),
|
||
);
|
||
} else {
|
||
messageApi.success(t('pages.settings.probeThemes.alreadyLatest'));
|
||
}
|
||
}
|
||
}
|
||
await reload();
|
||
} finally {
|
||
setBusyShort('');
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
{messageContextHolder}
|
||
<Typography.Title level={5}>{t('pages.settings.probeThemes.settingsTitle')}</Typography.Title>
|
||
<Row gutter={[16, 12]}>
|
||
<Col xs={24} md={12}>
|
||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.publicPage')}</div>
|
||
<Switch
|
||
checked={settings.publicPage}
|
||
onChange={(v) => setSettings({ ...settings, publicPage: v })}
|
||
/>
|
||
<Typography.Text type="secondary" style={{ marginLeft: 8 }}>
|
||
{t('pages.settings.probeThemes.publicPageHint')}
|
||
</Typography.Text>
|
||
</Col>
|
||
<Col xs={24} md={12}>
|
||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.siteName')}</div>
|
||
<Input
|
||
value={settings.siteName}
|
||
onChange={(e) => setSettings({ ...settings, siteName: e.target.value })}
|
||
/>
|
||
</Col>
|
||
<Col xs={24} md={12}>
|
||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.site')}</div>
|
||
<Input
|
||
value={settings.site}
|
||
placeholder="https://"
|
||
onChange={(e) => setSettings({ ...settings, site: e.target.value })}
|
||
/>
|
||
</Col>
|
||
<Col xs={24} md={12}>
|
||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.githubProxy')}</div>
|
||
<Input
|
||
value={settings.githubProxy}
|
||
placeholder="https://"
|
||
onChange={(e) => setSettings({ ...settings, githubProxy: e.target.value })}
|
||
/>
|
||
<Typography.Text type="secondary">
|
||
{t('pages.settings.probeThemes.githubProxyHint')}
|
||
</Typography.Text>
|
||
</Col>
|
||
<Col xs={24} md={12}>
|
||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.favicon')}</div>
|
||
<Space>
|
||
{favicon ? <img src={favicon} alt="" style={{ width: 24, height: 24 }} /> : null}
|
||
<Button size="small" onClick={() => faviconRef.current?.click()}>
|
||
{t('pages.settings.probeThemes.chooseImage')}
|
||
</Button>
|
||
{favicon ? (
|
||
<Button size="small" onClick={() => setFavicon('')}>
|
||
{t('clear')}
|
||
</Button>
|
||
) : null}
|
||
</Space>
|
||
<input
|
||
ref={faviconRef}
|
||
type="file"
|
||
accept="image/*"
|
||
hidden
|
||
onChange={(e) => pickIcon('favicon', e.target.files?.[0])}
|
||
/>
|
||
</Col>
|
||
<Col xs={24} md={12}>
|
||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeThemes.touchIcon')}</div>
|
||
<Space>
|
||
{touchIcon ? <img src={touchIcon} alt="" style={{ width: 24, height: 24 }} /> : null}
|
||
<Button size="small" onClick={() => touchRef.current?.click()}>
|
||
{t('pages.settings.probeThemes.chooseImage')}
|
||
</Button>
|
||
{touchIcon ? (
|
||
<Button size="small" onClick={() => setTouchIcon('')}>
|
||
{t('clear')}
|
||
</Button>
|
||
) : null}
|
||
</Space>
|
||
<input
|
||
ref={touchRef}
|
||
type="file"
|
||
accept="image/*"
|
||
hidden
|
||
onChange={(e) => pickIcon('touchIcon', e.target.files?.[0])}
|
||
/>
|
||
<div>
|
||
<Typography.Text type="secondary">
|
||
{t('pages.settings.probeThemes.iconHint')}
|
||
</Typography.Text>
|
||
</div>
|
||
</Col>
|
||
</Row>
|
||
<div style={{ marginTop: 12 }}>
|
||
<Button type="primary" loading={saving} onClick={saveSettings}>
|
||
{t('save')}
|
||
</Button>
|
||
</div>
|
||
|
||
<Typography.Title level={5} style={{ marginTop: 28 }}>
|
||
{t('pages.settings.probeThemes.title')}
|
||
</Typography.Title>
|
||
<Space wrap style={{ marginBottom: 16 }}>
|
||
<Button icon={<UploadOutlined />} onClick={() => fileRef.current?.click()}>
|
||
{uploadPct === null
|
||
? t('pages.settings.probeThemes.uploadInstall')
|
||
: `${t('pages.settings.probeThemes.uploadInstall')} ${uploadPct}%`}
|
||
</Button>
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept=".gz,application/gzip"
|
||
hidden
|
||
onChange={(e) => uploadTheme(e.target.files?.[0])}
|
||
/>
|
||
<Input
|
||
style={{ width: 340 }}
|
||
value={installURL}
|
||
placeholder={t('pages.settings.probeThemes.installByUrlPlaceholder')}
|
||
onChange={(e) => setInstallURL(e.target.value)}
|
||
/>
|
||
<Button loading={installing} disabled={!installURL.trim()} onClick={installByURL}>
|
||
{t('pages.settings.probeThemes.installByUrl')}
|
||
</Button>
|
||
</Space>
|
||
|
||
<Row gutter={[16, 16]}>
|
||
{themes.map((theme) => (
|
||
<Col key={theme.short} xs={24} sm={12} lg={8}>
|
||
<Card
|
||
hoverable
|
||
cover={
|
||
theme.preview ? (
|
||
<img
|
||
alt={theme.name}
|
||
src={`${basePathPrefix()}/panel/api/probe/themes/${theme.short}/preview`}
|
||
style={{ aspectRatio: '16/9', objectFit: 'cover' }}
|
||
/>
|
||
) : undefined
|
||
}
|
||
actions={[
|
||
theme.selected ? (
|
||
<Tag key="on" color="green">
|
||
{t('pages.settings.probeThemes.inUse')}
|
||
</Tag>
|
||
) : (
|
||
<Button
|
||
key="use"
|
||
type="link"
|
||
loading={busyShort === theme.short}
|
||
onClick={() => themeAction(theme.short, 'select')}
|
||
>
|
||
{t('pages.settings.probeThemes.use')}
|
||
</Button>
|
||
),
|
||
<Button
|
||
key="update"
|
||
type="link"
|
||
loading={busyShort === theme.short}
|
||
onClick={() => themeAction(theme.short, 'update')}
|
||
>
|
||
{t('update')}
|
||
</Button>,
|
||
...(theme.config && theme.config.length > 0
|
||
? [
|
||
<ThemeConfigButton
|
||
key="config"
|
||
theme={theme}
|
||
onSaved={() => messageApi.success(t('pages.settings.probeThemes.saved'))}
|
||
onError={(m) => messageApi.error(m)}
|
||
/>,
|
||
]
|
||
: []),
|
||
...(!theme.builtin
|
||
? [
|
||
<Popconfirm
|
||
key="del"
|
||
title={t('pages.settings.probeThemes.deleteConfirm')}
|
||
onConfirm={() => themeAction(theme.short, 'delete')}
|
||
>
|
||
<Button type="link" danger icon={<DeleteOutlined />}>
|
||
{t('delete')}
|
||
</Button>
|
||
</Popconfirm>,
|
||
]
|
||
: []),
|
||
]}
|
||
>
|
||
<Card.Meta
|
||
title={
|
||
<Space>
|
||
{theme.name}
|
||
{theme.builtin ? <Tag>{t('pages.settings.probeThemes.builtin')}</Tag> : null}
|
||
</Space>
|
||
}
|
||
description={
|
||
<div>
|
||
<div>{theme.description}</div>
|
||
<div>
|
||
v{theme.version} · {theme.author}
|
||
</div>
|
||
</div>
|
||
}
|
||
/>
|
||
</Card>
|
||
</Col>
|
||
))}
|
||
</Row>
|
||
|
||
<Typography.Title level={5} style={{ marginTop: 28 }}>
|
||
{t('pages.settings.probeNotify.notifyTitle')}
|
||
</Typography.Title>
|
||
<Typography.Paragraph type="secondary">
|
||
{t('pages.settings.probeNotify.notifyHint')}
|
||
</Typography.Paragraph>
|
||
{notify ? (
|
||
<>
|
||
<Row gutter={[16, 12]}>
|
||
<Col xs={24} md={8}>
|
||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.grace')}</div>
|
||
<InputNumber
|
||
min={1}
|
||
max={30}
|
||
value={notify.notifyGrace}
|
||
onChange={onNumber((v) => setNotify({ ...notify, notifyGrace: v }))}
|
||
/>
|
||
<div>
|
||
<Typography.Text type="secondary">
|
||
{t('pages.settings.probeNotify.graceHint')}
|
||
</Typography.Text>
|
||
</div>
|
||
</Col>
|
||
<Col xs={24} md={8}>
|
||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.trafficPct')}</div>
|
||
<InputNumber
|
||
min={0}
|
||
max={100}
|
||
value={notify.notifyTraffic}
|
||
onChange={onNumber((v) => setNotify({ ...notify, notifyTraffic: v }))}
|
||
/>
|
||
<div>
|
||
<Typography.Text type="secondary">
|
||
{t('pages.settings.probeNotify.trafficHint')}
|
||
</Typography.Text>
|
||
</div>
|
||
</Col>
|
||
<Col xs={24} md={8}>
|
||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.expiryDays')}</div>
|
||
<InputNumber
|
||
min={0}
|
||
max={365}
|
||
value={notify.notifyExpiry}
|
||
onChange={onNumber((v) => setNotify({ ...notify, notifyExpiry: v }))}
|
||
/>
|
||
<div>
|
||
<Typography.Text type="secondary">
|
||
{t('pages.settings.probeNotify.expiryHint')}
|
||
</Typography.Text>
|
||
</div>
|
||
</Col>
|
||
<Col xs={24} md={8}>
|
||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.loginAlert')}</div>
|
||
<Switch
|
||
checked={notify.notifyLogin}
|
||
onChange={(v) => setNotify({ ...notify, notifyLogin: v })}
|
||
/>
|
||
</Col>
|
||
<Col xs={24} md={8}>
|
||
<div style={{ marginBottom: 4 }}>
|
||
{t('pages.settings.probeNotify.telegramChannel')}
|
||
</div>
|
||
{notify.telegramConfigured ? (
|
||
<Tag color="green">{t('pages.settings.probeNotify.configured')}</Tag>
|
||
) : (
|
||
<Tag>{t('pages.settings.probeNotify.notConfigured')}</Tag>
|
||
)}
|
||
<div>
|
||
<Typography.Text type="secondary">
|
||
{t('pages.settings.probeNotify.telegramHint')}
|
||
</Typography.Text>
|
||
</div>
|
||
</Col>
|
||
<Col xs={24} md={8}>
|
||
<div style={{ marginBottom: 4 }}>
|
||
{t('pages.settings.probeNotify.webhookChannel')}
|
||
</div>
|
||
{notify.webhookConfigured ? (
|
||
<Tag color="green">{t('pages.settings.probeNotify.configured')}</Tag>
|
||
) : (
|
||
<Tag>{t('pages.settings.probeNotify.notConfigured')}</Tag>
|
||
)}
|
||
</Col>
|
||
<Col xs={24}>
|
||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.telegramText')}</div>
|
||
<Input.TextArea
|
||
rows={3}
|
||
value={notify.notifyTelegramText}
|
||
placeholder={'{{title}}\n{{message}}'}
|
||
onChange={(e) => setNotify({ ...notify, notifyTelegramText: e.target.value })}
|
||
/>
|
||
</Col>
|
||
<Col xs={24} md={12}>
|
||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.webhookUrl')}</div>
|
||
<Input
|
||
value={webhookUrl}
|
||
placeholder={
|
||
notify.notifyWebhookUrlSet ? t('pages.settings.probeNotify.secretSet') : 'https://'
|
||
}
|
||
onChange={(e) => {
|
||
setWebhookUrl(e.target.value);
|
||
setUrlTouched(true);
|
||
}}
|
||
/>
|
||
{notify.notifyWebhookUrlSet && !urlTouched ? (
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={() => {
|
||
setWebhookUrl('');
|
||
setUrlTouched(true);
|
||
}}
|
||
>
|
||
{t('pages.settings.probeNotify.clearSecret')}
|
||
</Button>
|
||
) : null}
|
||
</Col>
|
||
<Col xs={24} md={12}>
|
||
<div style={{ marginBottom: 4 }}>
|
||
{t('pages.settings.probeNotify.webhookHeaders')}
|
||
</div>
|
||
<Input.TextArea
|
||
rows={2}
|
||
value={webhookHeaders}
|
||
placeholder={
|
||
notify.notifyWebhookHeadersSet
|
||
? t('pages.settings.probeNotify.secretSet')
|
||
: 'Authorization: Bearer …'
|
||
}
|
||
onChange={(e) => {
|
||
setWebhookHeaders(e.target.value);
|
||
setHeadersTouched(true);
|
||
}}
|
||
/>
|
||
{notify.notifyWebhookHeadersSet && !headersTouched ? (
|
||
<Button
|
||
type="link"
|
||
size="small"
|
||
onClick={() => {
|
||
setWebhookHeaders('');
|
||
setHeadersTouched(true);
|
||
}}
|
||
>
|
||
{t('pages.settings.probeNotify.clearSecret')}
|
||
</Button>
|
||
) : null}
|
||
</Col>
|
||
<Col xs={24}>
|
||
<div style={{ marginBottom: 4 }}>{t('pages.settings.probeNotify.webhookBody')}</div>
|
||
<Input.TextArea
|
||
rows={3}
|
||
value={notify.notifyWebhookBody}
|
||
onChange={(e) => setNotify({ ...notify, notifyWebhookBody: e.target.value })}
|
||
/>
|
||
<div>
|
||
<Typography.Text type="secondary">
|
||
{t('pages.settings.probeNotify.templateHint')}
|
||
</Typography.Text>
|
||
</div>
|
||
</Col>
|
||
</Row>
|
||
<Space style={{ marginTop: 12 }}>
|
||
<Button type="primary" loading={notifySaving} onClick={saveNotify}>
|
||
{t('save')}
|
||
</Button>
|
||
<Button loading={notifyTesting} onClick={testNotify}>
|
||
{t('pages.settings.probeNotify.testSend')}
|
||
</Button>
|
||
</Space>
|
||
</>
|
||
) : null}
|
||
|
||
<Typography.Title level={5} style={{ marginTop: 28 }}>
|
||
{t('pages.settings.probeNotify.retireTitle')}
|
||
</Typography.Title>
|
||
<Typography.Paragraph type="secondary">
|
||
{t('pages.settings.probeNotify.retireHint')}
|
||
</Typography.Paragraph>
|
||
<Space direction="vertical">
|
||
<span>
|
||
{t('pages.settings.probeNotify.retirePublicPage')}:
|
||
{settings.publicPage ? (
|
||
<Tag color="green">{t('pages.settings.probeNotify.configured')}</Tag>
|
||
) : (
|
||
<Tag color="orange">{t('pages.settings.probeNotify.retirePublicPageOff')}</Tag>
|
||
)}
|
||
</span>
|
||
{coverage.map((row) => (
|
||
<span key={row.name}>
|
||
{row.name}:
|
||
{row.hasProbe ? (
|
||
<Tag color="green">{t('pages.settings.probeNotify.retireHasProbe')}</Tag>
|
||
) : (
|
||
<Tag color="red">{t('pages.settings.probeNotify.retireNoProbe')}</Tag>
|
||
)}
|
||
{row.online ? (
|
||
<Tag color="green">{t('pages.settings.probeNotify.retireOnline')}</Tag>
|
||
) : (
|
||
<Tag>{t('pages.settings.probeNotify.retireOffline')}</Tag>
|
||
)}
|
||
{row.probeAt > 0 ? (
|
||
<Typography.Text type="secondary">
|
||
{t('pages.settings.probeNotify.retireProbeAt', {
|
||
time: new Date(row.probeAt * 1000).toLocaleString(),
|
||
})}
|
||
</Typography.Text>
|
||
) : null}
|
||
</span>
|
||
))}
|
||
{coverage.length > 0 && coverage.every((row) => row.hasProbe) ? (
|
||
<Typography.Text type="success">
|
||
{t('pages.settings.probeNotify.retireAllReady')}
|
||
</Typography.Text>
|
||
) : (
|
||
<Typography.Text type="warning">
|
||
{t('pages.settings.probeNotify.retireNotReady')}
|
||
</Typography.Text>
|
||
)}
|
||
</Space>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ThemeConfigButton edits one theme's saved config. The form is
|
||
// drawn from the theme.json config declaration (passed through
|
||
// unparsed by the server); values load from and save to the contract
|
||
// endpoint the themes themselves use, and only fields changed from
|
||
// their declared defaults are stored (the hub's semantics).
|
||
function ThemeConfigButton({
|
||
theme,
|
||
onSaved,
|
||
onError,
|
||
}: {
|
||
theme: ThemeWire;
|
||
onSaved: () => void;
|
||
onError: (msg: string) => void;
|
||
}) {
|
||
const { t } = useTranslation();
|
||
const [open, setOpen] = useState(false);
|
||
const [values, setValues] = useState<Record<string, unknown>>({});
|
||
const [saving, setSaving] = useState(false);
|
||
const fields = theme.config ?? [];
|
||
|
||
async function load() {
|
||
let saved: Record<string, unknown> = {};
|
||
try {
|
||
const res = await fetch(`/api/themes/${theme.short}/config`, { credentials: 'same-origin' });
|
||
if (res.ok) saved = (await res.json()) as Record<string, unknown>;
|
||
} catch {
|
||
/* an unreadable config edits from the defaults */
|
||
}
|
||
const merged: Record<string, unknown> = {};
|
||
for (const f of fields) merged[f.key] = saved[f.key] ?? f.default;
|
||
setValues(merged);
|
||
setOpen(true);
|
||
}
|
||
|
||
async function save() {
|
||
setSaving(true);
|
||
try {
|
||
const changed: Record<string, unknown> = {};
|
||
for (const f of fields) {
|
||
if (JSON.stringify(values[f.key]) !== JSON.stringify(f.default)) {
|
||
changed[f.key] = values[f.key];
|
||
}
|
||
}
|
||
const res = await fetch(`/api/themes/${theme.short}/config`, {
|
||
method: 'PUT',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(changed),
|
||
});
|
||
if (res.ok) {
|
||
setOpen(false);
|
||
onSaved();
|
||
} else {
|
||
onError((await res.text()) || t('pages.settings.probeThemes.saveFailed'));
|
||
}
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<Button type="link" icon={<SettingOutlined />} onClick={load}>
|
||
{t('pages.settings.probeThemes.config')}
|
||
</Button>
|
||
<Modal
|
||
open={open}
|
||
title={`${theme.name} · ${t('pages.settings.probeThemes.config')}`}
|
||
onCancel={() => setOpen(false)}
|
||
onOk={save}
|
||
confirmLoading={saving}
|
||
>
|
||
{fields.map((f) => (
|
||
<div key={f.key} style={{ marginBottom: 12 }}>
|
||
<div style={{ marginBottom: 4 }}>{f.label}</div>
|
||
{f.type === 'boolean' ? (
|
||
<Switch
|
||
checked={Boolean(values[f.key])}
|
||
onChange={(v) => setValues({ ...values, [f.key]: v })}
|
||
/>
|
||
) : f.type === 'number' ? (
|
||
<InputNumber
|
||
value={typeof values[f.key] === 'number' ? (values[f.key] as number) : undefined}
|
||
onChange={(v) => setValues({ ...values, [f.key]: v })}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
) : f.type === 'select' && f.options ? (
|
||
<Select
|
||
value={values[f.key] as string}
|
||
options={f.options}
|
||
onChange={(v) => setValues({ ...values, [f.key]: v })}
|
||
style={{ width: '100%' }}
|
||
/>
|
||
) : (
|
||
<Input
|
||
value={typeof values[f.key] === 'string' ? (values[f.key] as string) : ''}
|
||
onChange={(e) => setValues({ ...values, [f.key]: e.target.value })}
|
||
/>
|
||
)}
|
||
{f.help ? (
|
||
<div>
|
||
<Typography.Text type="secondary">{f.help}</Typography.Text>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
))}
|
||
</Modal>
|
||
</>
|
||
);
|
||
}
|