mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 07:26:36 +08:00
[新增] 对接 Uptime Kuma
This commit is contained in:
@@ -31,8 +31,10 @@ import {
|
||||
rotateBootstrapToken,
|
||||
updateOptions,
|
||||
updateSelf,
|
||||
syncUptimeKuma,
|
||||
} from '@/features/settings/api/settings';
|
||||
import { AuthSourceModal } from '@/features/settings/components/auth-source-modal';
|
||||
import { UptimeKumaSiteSelectModal } from './uptimekuma-modal';
|
||||
import type {
|
||||
BootstrapTokenPayload,
|
||||
DatabaseCleanupResult,
|
||||
@@ -87,6 +89,17 @@ const defaultOperationFields = {
|
||||
NodeOfflineThreshold: '120000',
|
||||
AgentUpdateRepo: 'Rain-kl/OpenFlare',
|
||||
GeoIPProvider: 'ipinfo',
|
||||
UptimeKumaEnabled: false,
|
||||
UptimeKumaUrl: '',
|
||||
UptimeKumaUsername: '',
|
||||
UptimeKumaPassword: '',
|
||||
UptimeKumaMonitorScope: 'all',
|
||||
UptimeKumaSelectedSites: '',
|
||||
UptimeKumaSyncInterval: '5',
|
||||
UptimeKumaInterval: '60',
|
||||
UptimeKumaRetry: '0',
|
||||
UptimeKumaRetryInterval: '60',
|
||||
UptimeKumaTimeout: '48',
|
||||
OpenRestyDefaultServerReturnStatus: '421',
|
||||
OpenRestyWorkerProcesses: 'auto',
|
||||
OpenRestyWorkerConnections: '4096',
|
||||
@@ -255,6 +268,7 @@ export function SettingsPage() {
|
||||
const [cleanupModalState, setCleanupModalState] =
|
||||
useState<CleanupModalState | null>(null);
|
||||
const [cleanupRetentionDays, setCleanupRetentionDays] = useState('');
|
||||
const [uptimeKumaModalOpen, setUptimeKumaModalOpen] = useState(false);
|
||||
|
||||
const isRoot = (user?.role ?? 0) >= 100;
|
||||
|
||||
@@ -428,6 +442,17 @@ export function SettingsPage() {
|
||||
GlobalWebRateLimitDuration: optionMap.GlobalWebRateLimitDuration ?? '180',
|
||||
CriticalRateLimitNum: optionMap.CriticalRateLimitNum ?? '100',
|
||||
CriticalRateLimitDuration: optionMap.CriticalRateLimitDuration ?? '1200',
|
||||
UptimeKumaEnabled: toBoolean(optionMap.UptimeKumaEnabled, false),
|
||||
UptimeKumaUrl: optionMap.UptimeKumaUrl ?? '',
|
||||
UptimeKumaUsername: optionMap.UptimeKumaUsername ?? '',
|
||||
UptimeKumaPassword: '',
|
||||
UptimeKumaMonitorScope: optionMap.UptimeKumaMonitorScope ?? 'all',
|
||||
UptimeKumaSelectedSites: optionMap.UptimeKumaSelectedSites ?? '',
|
||||
UptimeKumaSyncInterval: optionMap.UptimeKumaSyncInterval ?? '5',
|
||||
UptimeKumaInterval: optionMap.UptimeKumaInterval ?? '60',
|
||||
UptimeKumaRetry: optionMap.UptimeKumaRetry ?? '0',
|
||||
UptimeKumaRetryInterval: optionMap.UptimeKumaRetryInterval ?? '60',
|
||||
UptimeKumaTimeout: optionMap.UptimeKumaTimeout ?? '48',
|
||||
ServerAddress: resolvedServerAddress,
|
||||
});
|
||||
|
||||
@@ -656,6 +681,64 @@ export function SettingsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleUptimeKumaSave = () => {
|
||||
void runBusyAction('uptimekuma-save', async () => {
|
||||
const syncInt = Number.parseInt(operationFields.UptimeKumaSyncInterval, 10);
|
||||
const interval = Number.parseInt(operationFields.UptimeKumaInterval, 10);
|
||||
const retry = Number.parseInt(operationFields.UptimeKumaRetry, 10);
|
||||
const retryInt = Number.parseInt(operationFields.UptimeKumaRetryInterval, 10);
|
||||
const timeout = Number.parseInt(operationFields.UptimeKumaTimeout, 10);
|
||||
|
||||
if (operationFields.UptimeKumaEnabled) {
|
||||
if (!operationFields.UptimeKumaUrl.trim()) {
|
||||
throw new Error('请输入 Uptime Kuma 地址。');
|
||||
}
|
||||
if (!operationFields.UptimeKumaUsername.trim()) {
|
||||
throw new Error('请输入 Uptime Kuma 用户名。');
|
||||
}
|
||||
}
|
||||
if (Number.isNaN(syncInt) || syncInt <= 0) {
|
||||
throw new Error('同步间隔必须为正整数。');
|
||||
}
|
||||
if (Number.isNaN(interval) || interval <= 0) {
|
||||
throw new Error('心跳间隔必须为正整数。');
|
||||
}
|
||||
if (Number.isNaN(retry) || retry < 0) {
|
||||
throw new Error('重试次数必须为非负整数。');
|
||||
}
|
||||
if (Number.isNaN(retryInt) || retryInt <= 0) {
|
||||
throw new Error('心跳重试间隔必须为正整数。');
|
||||
}
|
||||
if (Number.isNaN(timeout) || timeout <= 0) {
|
||||
throw new Error('请求超时必须为正整数。');
|
||||
}
|
||||
|
||||
await saveOptionEntries(
|
||||
[
|
||||
['UptimeKumaEnabled', String(operationFields.UptimeKumaEnabled)],
|
||||
['UptimeKumaUrl', operationFields.UptimeKumaUrl.trim()],
|
||||
['UptimeKumaUsername', operationFields.UptimeKumaUsername.trim()],
|
||||
['UptimeKumaPassword', operationFields.UptimeKumaPassword],
|
||||
['UptimeKumaMonitorScope', operationFields.UptimeKumaMonitorScope],
|
||||
['UptimeKumaSelectedSites', operationFields.UptimeKumaSelectedSites],
|
||||
['UptimeKumaSyncInterval', String(syncInt)],
|
||||
['UptimeKumaInterval', String(interval)],
|
||||
['UptimeKumaRetry', String(retry)],
|
||||
['UptimeKumaRetryInterval', String(retryInt)],
|
||||
['UptimeKumaTimeout', String(timeout)],
|
||||
],
|
||||
'Uptime Kuma 设置已保存。',
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const handleUptimeKumaSync = () => {
|
||||
void runBusyAction('uptimekuma-sync', async () => {
|
||||
await syncUptimeKuma();
|
||||
setFeedback({ tone: 'success', message: '同步任务已成功执行!' });
|
||||
});
|
||||
};
|
||||
|
||||
const renderTabContent = () => {
|
||||
if (profileQuery.isLoading || publicStatusQuery.isLoading) {
|
||||
return <LoadingState />;
|
||||
@@ -1307,6 +1390,198 @@ export function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
</AppCard>
|
||||
|
||||
<AppCard
|
||||
title="Uptime Kuma 集成"
|
||||
description="通过 API 将 OpenFlare 反代站点同步至 Uptime Kuma 监控。"
|
||||
action={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<SecondaryButton
|
||||
type="button"
|
||||
onClick={handleUptimeKumaSync}
|
||||
disabled={busyKey === 'uptimekuma-sync' || !operationFields.UptimeKumaEnabled}
|
||||
>
|
||||
{busyKey === 'uptimekuma-sync' ? '同步中...' : '立即同步'}
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
onClick={handleUptimeKumaSave}
|
||||
disabled={busyKey === 'uptimekuma-save'}
|
||||
>
|
||||
{busyKey === 'uptimekuma-save' ? '保存中...' : '保存设置'}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<ToggleField
|
||||
label="开启 Uptime Kuma"
|
||||
description="启用后会自动差分同步反代规则站点到 Uptime Kuma 实例。"
|
||||
checked={operationFields.UptimeKumaEnabled}
|
||||
onChange={(checked) =>
|
||||
setOperationFields((previous) => ({
|
||||
...previous,
|
||||
UptimeKumaEnabled: checked,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
{operationFields.UptimeKumaEnabled ? (
|
||||
<div className="space-y-5 border-t border-[var(--border-default)] pt-5">
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<ResourceField label="Uptime Kuma 地址" hint="例如 http://192.168.1.100:3001">
|
||||
<ResourceInput
|
||||
value={operationFields.UptimeKumaUrl}
|
||||
onChange={(event) =>
|
||||
setOperationFields((previous) => ({
|
||||
...previous,
|
||||
UptimeKumaUrl: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="http://localhost:3001"
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField label="Uptime Kuma 用户名">
|
||||
<ResourceInput
|
||||
value={operationFields.UptimeKumaUsername}
|
||||
onChange={(event) =>
|
||||
setOperationFields((previous) => ({
|
||||
...previous,
|
||||
UptimeKumaUsername: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="请输入用户名"
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<ResourceField
|
||||
label="Uptime Kuma 密码"
|
||||
hint="安全原因不显示历史密码,留空表示不更新。"
|
||||
>
|
||||
<ResourceInput
|
||||
type="password"
|
||||
value={operationFields.UptimeKumaPassword}
|
||||
onChange={(event) =>
|
||||
setOperationFields((previous) => ({
|
||||
...previous,
|
||||
UptimeKumaPassword: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="请输入密码(留空表示不更新)"
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="同步间隔 (分钟)">
|
||||
<ResourceInput
|
||||
type="number"
|
||||
value={operationFields.UptimeKumaSyncInterval}
|
||||
onChange={(event) =>
|
||||
setOperationFields((previous) => ({
|
||||
...previous,
|
||||
UptimeKumaSyncInterval: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
<ResourceField label="监控范围">
|
||||
<ResourceSelect
|
||||
value={operationFields.UptimeKumaMonitorScope}
|
||||
onChange={(event) =>
|
||||
setOperationFields((previous) => ({
|
||||
...previous,
|
||||
UptimeKumaMonitorScope: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="all">全部站点</option>
|
||||
<option value="selected">选择站点</option>
|
||||
</ResourceSelect>
|
||||
</ResourceField>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{operationFields.UptimeKumaMonitorScope === 'selected' ? (
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-[var(--foreground-primary)]">已选站点</span>
|
||||
<SecondaryButton type="button" onClick={() => setUptimeKumaModalOpen(true)}>
|
||||
选择监控站点
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--foreground-secondary)] break-all max-h-[80px] overflow-y-auto leading-5">
|
||||
{operationFields.UptimeKumaSelectedSites
|
||||
? operationFields.UptimeKumaSelectedSites.split(',').join(', ')
|
||||
: '未选择任何站点,同步不会执行。'}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="border-t border-[var(--border-default)] pt-5 space-y-4">
|
||||
<p className="text-sm font-medium text-[var(--foreground-primary)]">
|
||||
Uptime Kuma 属性配置
|
||||
</p>
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<ResourceField label="心跳间隔 (检测频率秒)" hint="服务检测频率,单位:秒">
|
||||
<ResourceInput
|
||||
type="number"
|
||||
value={operationFields.UptimeKumaInterval}
|
||||
onChange={(event) =>
|
||||
setOperationFields((previous) => ({
|
||||
...previous,
|
||||
UptimeKumaInterval: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField label="重试次数" hint="服务被标记为故障前的最大重试次数">
|
||||
<ResourceInput
|
||||
type="number"
|
||||
value={operationFields.UptimeKumaRetry}
|
||||
onChange={(event) =>
|
||||
setOperationFields((previous) => ({
|
||||
...previous,
|
||||
UptimeKumaRetry: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField label="心跳重试间隔 (秒)" hint="重试间隔时间,单位:秒">
|
||||
<ResourceInput
|
||||
type="number"
|
||||
value={operationFields.UptimeKumaRetryInterval}
|
||||
onChange={(event) =>
|
||||
setOperationFields((previous) => ({
|
||||
...previous,
|
||||
UptimeKumaRetryInterval: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
|
||||
<ResourceField label="请求超时 (秒)" hint="超时断开时间,单位:秒">
|
||||
<ResourceInput
|
||||
type="number"
|
||||
value={operationFields.UptimeKumaTimeout}
|
||||
onChange={(event) =>
|
||||
setOperationFields((previous) => ({
|
||||
...previous,
|
||||
UptimeKumaTimeout: event.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard title="版本与构建信息">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
|
||||
@@ -2050,6 +2325,22 @@ export function SettingsPage() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<UptimeKumaSiteSelectModal
|
||||
isOpen={uptimeKumaModalOpen}
|
||||
selectedSites={
|
||||
operationFields.UptimeKumaSelectedSites
|
||||
? operationFields.UptimeKumaSelectedSites.split(',')
|
||||
: []
|
||||
}
|
||||
onClose={() => setUptimeKumaModalOpen(false)}
|
||||
onSave={(sites) =>
|
||||
setOperationFields((previous) => ({
|
||||
...previous,
|
||||
UptimeKumaSelectedSites: sites.join(','),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
<AppModal
|
||||
isOpen={cleanupModalState !== null}
|
||||
title={`清理${cleanupModalState?.label ?? ''}`}
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { ErrorState } from '@/components/feedback/error-state';
|
||||
import { LoadingState } from '@/components/feedback/loading-state';
|
||||
import { AppModal } from '@/components/ui/app-modal';
|
||||
import { getProxyRoutes } from '@/features/proxy-routes/api/proxy-routes';
|
||||
import {
|
||||
PrimaryButton,
|
||||
ResourceField,
|
||||
ResourceInput,
|
||||
SecondaryButton,
|
||||
} from '@/features/shared/components/resource-primitives';
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '加载站点列表失败。';
|
||||
}
|
||||
|
||||
export function UptimeKumaSiteSelectModal({
|
||||
isOpen,
|
||||
selectedSites,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
selectedSites: string[];
|
||||
onClose: () => void;
|
||||
onSave: (sites: string[]) => void;
|
||||
}) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [tempSelected, setTempSelected] = useState<Set<string>>(new Set());
|
||||
|
||||
const { data: routes = [], isLoading, error } = useQuery({
|
||||
queryKey: ['proxy-routes'],
|
||||
queryFn: getProxyRoutes,
|
||||
enabled: isOpen,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTempSelected(new Set(selectedSites.map(s => s.trim()).filter(Boolean)));
|
||||
setSearchTerm('');
|
||||
}
|
||||
}, [isOpen, selectedSites]);
|
||||
|
||||
const toggleSite = (siteName: string) => {
|
||||
setTempSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(siteName)) {
|
||||
next.delete(siteName);
|
||||
} else {
|
||||
next.add(siteName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
setTempSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
filteredRoutes.forEach((route) => {
|
||||
next.add(route.site_name);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeselectAll = () => {
|
||||
setTempSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
filteredRoutes.forEach((route) => {
|
||||
next.delete(route.site_name);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(Array.from(tempSelected));
|
||||
onClose();
|
||||
};
|
||||
|
||||
const filteredRoutes = routes.filter(
|
||||
(route) =>
|
||||
route.site_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
route.primary_domain.toLowerCase().includes(searchTerm.toLowerCase()),
|
||||
);
|
||||
|
||||
return (
|
||||
<AppModal
|
||||
isOpen={isOpen}
|
||||
title="选择监控站点"
|
||||
description="请选择要同步到 Uptime Kuma 监控的站点,支持按站点名称和域名搜索。"
|
||||
size="lg"
|
||||
onClose={onClose}
|
||||
footer={
|
||||
<div className="flex justify-end gap-3">
|
||||
<SecondaryButton type="button" onClick={onClose}>
|
||||
取消
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type="button" onClick={handleSave}>
|
||||
保存选择
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<div className="flex-1">
|
||||
<ResourceField label="搜索站点">
|
||||
<ResourceInput
|
||||
value={searchTerm}
|
||||
onChange={(event) => setSearchTerm(event.target.value)}
|
||||
placeholder="按名称或域名搜索..."
|
||||
/>
|
||||
</ResourceField>
|
||||
</div>
|
||||
<div className="flex gap-2 sm:mt-6">
|
||||
<SecondaryButton type="button" onClick={handleSelectAll}>
|
||||
全选过滤项
|
||||
</SecondaryButton>
|
||||
<SecondaryButton type="button" onClick={handleDeselectAll}>
|
||||
清空过滤项
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? <LoadingState /> : null}
|
||||
|
||||
{error ? (
|
||||
<ErrorState
|
||||
title="站点加载失败"
|
||||
description={getErrorMessage(error)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && routes.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-[var(--border-default)] px-5 py-8 text-center text-sm text-[var(--foreground-secondary)]">
|
||||
暂无可用的代理站点。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && routes.length > 0 ? (
|
||||
<div className="max-h-[350px] overflow-y-auto rounded-2xl border border-[var(--border-default)] bg-[var(--surface-base)]">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="sticky top-0 bg-[var(--surface-elevated)] text-xs text-[var(--foreground-secondary)] uppercase">
|
||||
<tr>
|
||||
<th className="w-12 px-4 py-3">选择</th>
|
||||
<th className="px-4 py-3 font-medium">站点名称</th>
|
||||
<th className="px-4 py-3 font-medium">主域名</th>
|
||||
<th className="px-4 py-3 font-medium">状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-[var(--border-default)]">
|
||||
{filteredRoutes.map((route) => {
|
||||
const isChecked = tempSelected.has(route.site_name);
|
||||
return (
|
||||
<tr
|
||||
key={route.id}
|
||||
onClick={() => toggleSite(route.site_name)}
|
||||
className="cursor-pointer hover:bg-[var(--surface-elevated)]"
|
||||
>
|
||||
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
onChange={() => toggleSite(route.site_name)}
|
||||
className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-medium text-[var(--foreground-primary)]">
|
||||
{route.site_name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[var(--foreground-secondary)]">
|
||||
{route.primary_domain}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
route.enabled
|
||||
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{route.enabled ? '启用' : '禁用'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{filteredRoutes.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-[var(--foreground-secondary)]">
|
||||
无匹配的站点
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="text-xs text-[var(--foreground-muted)] text-right">
|
||||
已选择 {tempSelected.size} 个监控站点
|
||||
</div>
|
||||
</div>
|
||||
</AppModal>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user