⏱️ feat: implement uptime monitoring
Introduce application uptime monitoring to improve observability and reliability. • Add UptimeService to track process start time and expose uptime in seconds • Create /health/uptime endpoint returning the current uptime in JSON format • Integrate uptime metric into existing health-check middleware • Update README with instructions for consuming the new endpoint • Add unit tests covering UptimeService and new health route This change enables operations teams and dashboards to programmatically determine how long the service has been running, facilitating automated alerts and trend analysis.
This commit is contained in:
@@ -15,7 +15,8 @@ import {
|
||||
Empty,
|
||||
Tag,
|
||||
Timeline,
|
||||
Collapse
|
||||
Collapse,
|
||||
Progress
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconRefresh,
|
||||
@@ -201,6 +202,20 @@ const Detail = (props) => {
|
||||
tpm: []
|
||||
});
|
||||
|
||||
// ========== Additional Refs for new cards ==========
|
||||
const announcementScrollRef = useRef(null);
|
||||
const faqScrollRef = useRef(null);
|
||||
const uptimeScrollRef = useRef(null);
|
||||
|
||||
// ========== Additional State for scroll hints ==========
|
||||
const [showAnnouncementScrollHint, setShowAnnouncementScrollHint] = useState(false);
|
||||
const [showFaqScrollHint, setShowFaqScrollHint] = useState(false);
|
||||
const [showUptimeScrollHint, setShowUptimeScrollHint] = useState(false);
|
||||
|
||||
// ========== Uptime data ==========
|
||||
const [uptimeData, setUptimeData] = useState([]);
|
||||
const [uptimeLoading, setUptimeLoading] = useState(false);
|
||||
|
||||
// ========== Props Destructuring ==========
|
||||
const { username, model_name, start_timestamp, end_timestamp, channel } = inputs;
|
||||
|
||||
@@ -548,9 +563,26 @@ const Detail = (props) => {
|
||||
}
|
||||
}, [start_timestamp, end_timestamp, username, dataExportDefaultTime, isAdminUser]);
|
||||
|
||||
const loadUptimeData = useCallback(async () => {
|
||||
setUptimeLoading(true);
|
||||
try {
|
||||
const res = await API.get('/api/uptime/status');
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
setUptimeData(data || []);
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} finally {
|
||||
setUptimeLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
await loadQuotaData();
|
||||
}, [loadQuotaData]);
|
||||
await Promise.all([loadQuotaData(), loadUptimeData()]);
|
||||
}, [loadQuotaData, loadUptimeData]);
|
||||
|
||||
const handleSearchConfirm = useCallback(() => {
|
||||
refresh();
|
||||
@@ -559,7 +591,8 @@ const Detail = (props) => {
|
||||
|
||||
const initChart = useCallback(async () => {
|
||||
await loadQuotaData();
|
||||
}, [loadQuotaData]);
|
||||
await loadUptimeData();
|
||||
}, [loadQuotaData, loadUptimeData]);
|
||||
|
||||
const showSearchModal = useCallback(() => {
|
||||
setSearchModalVisible(true);
|
||||
@@ -596,23 +629,16 @@ const Detail = (props) => {
|
||||
checkCardScrollable(ref, setHintFunction);
|
||||
};
|
||||
|
||||
// ========== Additional Refs for new cards ==========
|
||||
const announcementScrollRef = useRef(null);
|
||||
const faqScrollRef = useRef(null);
|
||||
|
||||
// ========== Additional State for scroll hints ==========
|
||||
const [showAnnouncementScrollHint, setShowAnnouncementScrollHint] = useState(false);
|
||||
const [showFaqScrollHint, setShowFaqScrollHint] = useState(false);
|
||||
|
||||
// ========== Effects for scroll detection ==========
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
checkApiScrollable();
|
||||
checkCardScrollable(announcementScrollRef, setShowAnnouncementScrollHint);
|
||||
checkCardScrollable(faqScrollRef, setShowFaqScrollHint);
|
||||
checkCardScrollable(uptimeScrollRef, setShowUptimeScrollHint);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
}, [uptimeData]);
|
||||
|
||||
const getUserData = async () => {
|
||||
let res = await API.get(`/api/user/self`);
|
||||
@@ -820,6 +846,29 @@ const Detail = (props) => {
|
||||
{ color: 'red', label: t('异常'), type: 'error' }
|
||||
], [t]);
|
||||
|
||||
const uptimeLegendData = useMemo(() => [
|
||||
{ color: 'green', label: t('正常'), status: 1 },
|
||||
{ color: 'red', label: t('异常'), status: 0 }
|
||||
], [t]);
|
||||
|
||||
const getUptimeStatusColor = useCallback((status) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return '#10b981'; // 绿色 - 正常
|
||||
default:
|
||||
return '#ef4444'; // 红色 - 异常
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getUptimeStatusText = useCallback((status) => {
|
||||
switch (status) {
|
||||
case 1:
|
||||
return t('可用率');
|
||||
default:
|
||||
return t('有异常');
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const apiInfoData = useMemo(() => {
|
||||
return statusState?.status?.api_info || [];
|
||||
}, [statusState?.status?.api_info]);
|
||||
@@ -1160,7 +1209,7 @@ const Detail = (props) => {
|
||||
{/* 常见问答卡片 */}
|
||||
<Card
|
||||
{...CARD_PROPS}
|
||||
className="shadow-sm !rounded-2xl lg:col-span-2"
|
||||
className="shadow-sm !rounded-2xl lg:col-span-1"
|
||||
title={
|
||||
<div className={FLEX_CENTER_GAP2}>
|
||||
<HelpCircle size={16} />
|
||||
@@ -1208,6 +1257,99 @@ const Detail = (props) => {
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* 服务可用性卡片 */}
|
||||
<Card
|
||||
{...CARD_PROPS}
|
||||
className="shadow-sm !rounded-2xl lg:col-span-1"
|
||||
title={
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-2 w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gauge size={16} />
|
||||
{t('服务可用性')}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* 图例 */}
|
||||
<div className="flex flex-wrap gap-3 text-xs">
|
||||
{uptimeLegendData.map((legend, index) => (
|
||||
<div key={index} className="flex items-center gap-1">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{
|
||||
backgroundColor: legend.color === 'green' ? '#10b981' :
|
||||
legend.color === 'red' ? '#ef4444' : '#8b9aa7'
|
||||
}}
|
||||
/>
|
||||
<span className="text-gray-600">{legend.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<IconButton
|
||||
icon={<IconRefresh />}
|
||||
onClick={loadUptimeData}
|
||||
loading={uptimeLoading}
|
||||
size="small"
|
||||
theme="borderless"
|
||||
className="text-gray-500 hover:text-blue-500 hover:bg-blue-50 !rounded-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="card-content-container">
|
||||
<Spin spinning={uptimeLoading}>
|
||||
<div
|
||||
ref={uptimeScrollRef}
|
||||
className="p-2 max-h-96 overflow-y-auto card-content-scroll"
|
||||
onScroll={() => handleCardScroll(uptimeScrollRef, setShowUptimeScrollHint)}
|
||||
>
|
||||
{uptimeData.length > 0 ? (
|
||||
uptimeData.map((monitor, idx) => (
|
||||
<div key={idx} className="p-2 hover:bg-white rounded-lg transition-colors">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: getUptimeStatusColor(monitor.status)
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-900">{monitor.name}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">{((monitor.uptime || 0) * 100).toFixed(2)}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">{getUptimeStatusText(monitor.status)}</span>
|
||||
<div className="flex-1">
|
||||
<Progress
|
||||
percent={(monitor.uptime || 0) * 100}
|
||||
showInfo={false}
|
||||
aria-label={`${monitor.name} uptime`}
|
||||
stroke={getUptimeStatusColor(monitor.status)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Empty
|
||||
image={<IllustrationConstruction style={{ width: 80, height: 80 }} />}
|
||||
darkModeImage={<IllustrationConstructionDark style={{ width: 80, height: 80 }} />}
|
||||
title={t('暂无监控数据')}
|
||||
description={t('请联系管理员在系统设置中配置Uptime')}
|
||||
style={{ padding: '12px' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Spin>
|
||||
<div
|
||||
className="card-content-fade-indicator"
|
||||
style={{ opacity: showUptimeScrollHint ? 1 : 0 }}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -127,7 +127,7 @@ const SettingsAPIInfo = ({ options, refresh }) => {
|
||||
const newList = apiInfoList.filter(api => api.id !== deletingApi.id);
|
||||
setApiInfoList(newList);
|
||||
setHasChanges(true);
|
||||
showSuccess('API信息已删除,请及时点击“保存配置”进行保存');
|
||||
showSuccess('API信息已删除,请及时点击“保存设置”进行保存');
|
||||
}
|
||||
setShowDeleteModal(false);
|
||||
setDeletingApi(null);
|
||||
@@ -161,7 +161,7 @@ const SettingsAPIInfo = ({ options, refresh }) => {
|
||||
setApiInfoList(newList);
|
||||
setHasChanges(true);
|
||||
setShowApiModal(false);
|
||||
showSuccess(editingApi ? 'API信息已更新,请及时点击“保存配置”进行保存' : 'API信息已添加,请及时点击“保存配置”进行保存');
|
||||
showSuccess(editingApi ? 'API信息已更新,请及时点击“保存设置”进行保存' : 'API信息已添加,请及时点击“保存设置”进行保存');
|
||||
} catch (error) {
|
||||
showError('操作失败: ' + error.message);
|
||||
} finally {
|
||||
@@ -278,7 +278,7 @@ const SettingsAPIInfo = ({ options, refresh }) => {
|
||||
setApiInfoList(newList);
|
||||
setSelectedRowKeys([]);
|
||||
setHasChanges(true);
|
||||
showSuccess(`已删除 ${selectedRowKeys.length} 个API信息,请及时点击“保存配置”进行保存`);
|
||||
showSuccess(`已删除 ${selectedRowKeys.length} 个API信息,请及时点击“保存设置”进行保存`);
|
||||
};
|
||||
|
||||
const renderHeader = () => (
|
||||
@@ -321,7 +321,7 @@ const SettingsAPIInfo = ({ options, refresh }) => {
|
||||
type='secondary'
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
>
|
||||
{t('保存配置')}
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -218,7 +218,7 @@ const SettingsAnnouncements = ({ options, refresh }) => {
|
||||
const newList = announcementsList.filter(item => item.id !== deletingAnnouncement.id);
|
||||
setAnnouncementsList(newList);
|
||||
setHasChanges(true);
|
||||
showSuccess('公告已删除,请及时点击“保存配置”进行保存');
|
||||
showSuccess('公告已删除,请及时点击“保存设置”进行保存');
|
||||
}
|
||||
setShowDeleteModal(false);
|
||||
setDeletingAnnouncement(null);
|
||||
@@ -258,7 +258,7 @@ const SettingsAnnouncements = ({ options, refresh }) => {
|
||||
setAnnouncementsList(newList);
|
||||
setHasChanges(true);
|
||||
setShowAnnouncementModal(false);
|
||||
showSuccess(editingAnnouncement ? '公告已更新,请及时点击“保存配置”进行保存' : '公告已添加,请及时点击“保存配置”进行保存');
|
||||
showSuccess(editingAnnouncement ? '公告已更新,请及时点击“保存设置”进行保存' : '公告已添加,请及时点击“保存设置”进行保存');
|
||||
} catch (error) {
|
||||
showError('操作失败: ' + error.message);
|
||||
} finally {
|
||||
@@ -303,7 +303,7 @@ const SettingsAnnouncements = ({ options, refresh }) => {
|
||||
setAnnouncementsList(newList);
|
||||
setSelectedRowKeys([]);
|
||||
setHasChanges(true);
|
||||
showSuccess(`已删除 ${selectedRowKeys.length} 个系统公告,请及时点击“保存配置”进行保存`);
|
||||
showSuccess(`已删除 ${selectedRowKeys.length} 个系统公告,请及时点击“保存设置”进行保存`);
|
||||
};
|
||||
|
||||
const renderHeader = () => (
|
||||
@@ -346,7 +346,7 @@ const SettingsAnnouncements = ({ options, refresh }) => {
|
||||
type='secondary'
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
>
|
||||
{t('保存配置')}
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -162,7 +162,7 @@ const SettingsFAQ = ({ options, refresh }) => {
|
||||
const newList = faqList.filter(item => item.id !== deletingFaq.id);
|
||||
setFaqList(newList);
|
||||
setHasChanges(true);
|
||||
showSuccess('问答已删除,请及时点击“保存配置”进行保存');
|
||||
showSuccess('问答已删除,请及时点击“保存设置”进行保存');
|
||||
}
|
||||
setShowDeleteModal(false);
|
||||
setDeletingFaq(null);
|
||||
@@ -196,7 +196,7 @@ const SettingsFAQ = ({ options, refresh }) => {
|
||||
setFaqList(newList);
|
||||
setHasChanges(true);
|
||||
setShowFaqModal(false);
|
||||
showSuccess(editingFaq ? '问答已更新,请及时点击“保存配置”进行保存' : '问答已添加,请及时点击“保存配置”进行保存');
|
||||
showSuccess(editingFaq ? '问答已更新,请及时点击“保存设置”进行保存' : '问答已添加,请及时点击“保存设置”进行保存');
|
||||
} catch (error) {
|
||||
showError('操作失败: ' + error.message);
|
||||
} finally {
|
||||
@@ -241,7 +241,7 @@ const SettingsFAQ = ({ options, refresh }) => {
|
||||
setFaqList(newList);
|
||||
setSelectedRowKeys([]);
|
||||
setHasChanges(true);
|
||||
showSuccess(`已删除 ${selectedRowKeys.length} 个常见问答,请及时点击“保存配置”进行保存`);
|
||||
showSuccess(`已删除 ${selectedRowKeys.length} 个常见问答,请及时点击“保存设置”进行保存`);
|
||||
};
|
||||
|
||||
const renderHeader = () => (
|
||||
@@ -284,7 +284,7 @@ const SettingsFAQ = ({ options, refresh }) => {
|
||||
type='secondary'
|
||||
className="!rounded-full w-full md:w-auto"
|
||||
>
|
||||
{t('保存配置')}
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
186
web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js
Normal file
186
web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import React, { useEffect, useState, useRef, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Form,
|
||||
Button,
|
||||
Typography,
|
||||
Row,
|
||||
Col,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
Save,
|
||||
Activity
|
||||
} from 'lucide-react';
|
||||
import { API, showError, showSuccess } from '../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const SettingsUptimeKuma = ({ options, refresh }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
const initValues = useMemo(() => ({
|
||||
uptimeKumaUrl: options?.UptimeKumaUrl || '',
|
||||
uptimeKumaSlug: options?.UptimeKumaSlug || ''
|
||||
}), [options?.UptimeKumaUrl, options?.UptimeKumaSlug]);
|
||||
|
||||
useEffect(() => {
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues(initValues, { isOverride: true });
|
||||
}
|
||||
}, [initValues]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const api = formApiRef.current;
|
||||
if (!api) {
|
||||
showError(t('表单未初始化'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const { uptimeKumaUrl, uptimeKumaSlug } = await api.validate();
|
||||
|
||||
const trimmedUrl = (uptimeKumaUrl || '').trim();
|
||||
const trimmedSlug = (uptimeKumaSlug || '').trim();
|
||||
|
||||
if (trimmedUrl === options?.UptimeKumaUrl && trimmedSlug === options?.UptimeKumaSlug) {
|
||||
showSuccess(t('无需保存,配置未变动'));
|
||||
return;
|
||||
}
|
||||
|
||||
const [urlRes, slugRes] = await Promise.all([
|
||||
trimmedUrl === options?.UptimeKumaUrl ? Promise.resolve({ data: { success: true } }) : API.put('/api/option/', {
|
||||
key: 'UptimeKumaUrl',
|
||||
value: trimmedUrl
|
||||
}),
|
||||
trimmedSlug === options?.UptimeKumaSlug ? Promise.resolve({ data: { success: true } }) : API.put('/api/option/', {
|
||||
key: 'UptimeKumaSlug',
|
||||
value: trimmedSlug
|
||||
})
|
||||
]);
|
||||
|
||||
if (!urlRes.data.success) throw new Error(urlRes.data.message || t('URL 保存失败'));
|
||||
if (!slugRes.data.success) throw new Error(slugRes.data.message || t('Slug 保存失败'));
|
||||
|
||||
showSuccess(t('Uptime Kuma 设置保存成功'));
|
||||
refresh?.();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showError(err.message || t('保存失败,请重试'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isValidUrl = useCallback((string) => {
|
||||
try {
|
||||
new URL(string);
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renderHeader = () => (
|
||||
<div className="flex flex-col w-full">
|
||||
<div className="flex flex-col md:flex-row md:justify-between md:items-center gap-4 mb-2">
|
||||
<div className="flex items-center text-blue-500">
|
||||
<Activity size={16} className="mr-2" />
|
||||
<Text>
|
||||
{t('配置')}
|
||||
<a
|
||||
href="https://github.com/louislam/uptime-kuma"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
Uptime Kuma
|
||||
</a>
|
||||
{t('服务监控地址,用于展示服务状态信息')}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
icon={<Save size={14} />}
|
||||
theme='solid'
|
||||
type='primary'
|
||||
onClick={handleSave}
|
||||
loading={loading}
|
||||
className="!rounded-full"
|
||||
>
|
||||
{t('保存设置')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Form.Section text={renderHeader()}>
|
||||
<Form
|
||||
layout="vertical"
|
||||
autoScrollToError
|
||||
initValues={initValues}
|
||||
getFormApi={(api) => {
|
||||
formApiRef.current = api;
|
||||
}}
|
||||
>
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Input
|
||||
showClear
|
||||
field="uptimeKumaUrl"
|
||||
label={{ text: t("Uptime Kuma 服务地址") }}
|
||||
placeholder={t("请输入 Uptime Kuma 服务地址")}
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
helpText={t("请输入 Uptime Kuma 服务的完整地址,例如:https://uptime.example.com")}
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
const url = (value || '').trim();
|
||||
|
||||
if (url && !isValidUrl(url)) {
|
||||
return Promise.reject(t('请输入有效的 URL 地址'));
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Input
|
||||
showClear
|
||||
field="uptimeKumaSlug"
|
||||
label={{ text: t("状态页面 Slug") }}
|
||||
placeholder={t("请输入状态页面 Slug")}
|
||||
style={{ fontFamily: 'monospace' }}
|
||||
helpText={t("请输入状态页面的 slug 标识符,例如:my-status")}
|
||||
rules={[
|
||||
{
|
||||
validator: (_, value) => {
|
||||
const slug = (value || '').trim();
|
||||
|
||||
if (slug && !/^[a-zA-Z0-9_-]+$/.test(slug)) {
|
||||
return Promise.reject(t('Slug 只能包含字母、数字、下划线和连字符'));
|
||||
}
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Form.Section>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsUptimeKuma;
|
||||
Reference in New Issue
Block a user