diff --git a/controller/uptime_kuma.go b/controller/uptime_kuma.go new file mode 100644 index 00000000..245cac3f --- /dev/null +++ b/controller/uptime_kuma.go @@ -0,0 +1,176 @@ +package controller + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "one-api/common" + "strings" + "time" + + "github.com/gin-gonic/gin" + "golang.org/x/sync/errgroup" +) + +type UptimeKumaMonitor struct { + ID int `json:"id"` + Name string `json:"name"` + Type string `json:"type"` +} + +type UptimeKumaGroup struct { + ID int `json:"id"` + Name string `json:"name"` + Weight int `json:"weight"` + MonitorList []UptimeKumaMonitor `json:"monitorList"` +} + +type UptimeKumaHeartbeat struct { + Status int `json:"status"` + Time string `json:"time"` + Msg string `json:"msg"` + Ping *float64 `json:"ping"` +} + +type UptimeKumaStatusResponse struct { + PublicGroupList []UptimeKumaGroup `json:"publicGroupList"` +} + +type UptimeKumaHeartbeatResponse struct { + HeartbeatList map[string][]UptimeKumaHeartbeat `json:"heartbeatList"` + UptimeList map[string]float64 `json:"uptimeList"` +} + +type MonitorStatus struct { + Name string `json:"name"` + Uptime float64 `json:"uptime"` + Status int `json:"status"` +} + +var ( + ErrUpstreamNon200 = errors.New("upstream non-200") + ErrTimeout = errors.New("context deadline exceeded") +) + +func getAndDecode(ctx context.Context, client *http.Client, url string, dest interface{}) error { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return err + } + + resp, err := client.Do(req) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return ErrTimeout + } + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return ErrUpstreamNon200 + } + + return json.NewDecoder(resp.Body).Decode(dest) +} + +func GetUptimeKumaStatus(c *gin.Context) { + common.OptionMapRWMutex.RLock() + uptimeKumaUrl := common.OptionMap["UptimeKumaUrl"] + slug := common.OptionMap["UptimeKumaSlug"] + common.OptionMapRWMutex.RUnlock() + + if uptimeKumaUrl == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "未配置 Uptime Kuma URL", + }) + return + } + + if slug == "" { + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "未配置 Uptime Kuma Slug", + }) + return + } + + uptimeKumaUrl = strings.TrimSuffix(uptimeKumaUrl, "/") + + ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) + defer cancel() + + client := &http.Client{} + + statusPageUrl := fmt.Sprintf("%s/api/status-page/%s", uptimeKumaUrl, slug) + heartbeatUrl := fmt.Sprintf("%s/api/status-page/heartbeat/%s", uptimeKumaUrl, slug) + + var ( + statusData UptimeKumaStatusResponse + heartbeatData UptimeKumaHeartbeatResponse + ) + + g, gCtx := errgroup.WithContext(ctx) + + g.Go(func() error { + return getAndDecode(gCtx, client, statusPageUrl, &statusData) + }) + + g.Go(func() error { + return getAndDecode(gCtx, client, heartbeatUrl, &heartbeatData) + }) + + if err := g.Wait(); err != nil { + switch err { + case ErrUpstreamNon200: + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": "上游接口出现问题", + }) + case ErrTimeout: + c.JSON(http.StatusRequestTimeout, gin.H{ + "success": false, + "message": "请求上游接口超时", + }) + default: + c.JSON(http.StatusBadRequest, gin.H{ + "success": false, + "message": err.Error(), + }) + } + return + } + + var monitors []MonitorStatus + for _, group := range statusData.PublicGroupList { + for _, monitor := range group.MonitorList { + monitorStatus := MonitorStatus{ + Name: monitor.Name, + Uptime: 0.0, + Status: 0, + } + + uptimeKey := fmt.Sprintf("%d_24", monitor.ID) + if uptime, exists := heartbeatData.UptimeList[uptimeKey]; exists { + monitorStatus.Uptime = uptime + } + + heartbeatKey := fmt.Sprintf("%d", monitor.ID) + if heartbeats, exists := heartbeatData.HeartbeatList[heartbeatKey]; exists && len(heartbeats) > 0 { + latestHeartbeat := heartbeats[0] + monitorStatus.Status = latestHeartbeat.Status + } + + monitors = append(monitors, monitorStatus) + } + } + + c.JSON(http.StatusOK, gin.H{ + "success": true, + "message": "", + "data": monitors, + }) +} \ No newline at end of file diff --git a/model/option.go b/model/option.go index 7bab819b..42949e8b 100644 --- a/model/option.go +++ b/model/option.go @@ -123,6 +123,8 @@ func InitOptionMap() { common.OptionMap["StreamCacheQueueLength"] = strconv.Itoa(setting.StreamCacheQueueLength) common.OptionMap["AutomaticDisableKeywords"] = operation_setting.AutomaticDisableKeywordsToString() common.OptionMap["ApiInfo"] = "" + common.OptionMap["UptimeKumaUrl"] = "" + common.OptionMap["UptimeKumaSlug"] = "" // 自动添加所有注册的模型配置 modelConfigs := config.GlobalConfig.ExportAllConfigs() diff --git a/router/api-router.go b/router/api-router.go index 6251c8a2..0ab8be7f 100644 --- a/router/api-router.go +++ b/router/api-router.go @@ -16,6 +16,7 @@ func SetApiRouter(router *gin.Engine) { apiRouter.GET("/setup", controller.GetSetup) apiRouter.POST("/setup", controller.PostSetup) apiRouter.GET("/status", controller.GetStatus) + apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus) apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels) apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus) apiRouter.GET("/notice", controller.GetNotice) diff --git a/web/src/components/settings/DashboardSetting.js b/web/src/components/settings/DashboardSetting.js index 25d2ff8c..649e0ffa 100644 --- a/web/src/components/settings/DashboardSetting.js +++ b/web/src/components/settings/DashboardSetting.js @@ -4,12 +4,15 @@ import { API, showError } from '../../helpers'; import SettingsAPIInfo from '../../pages/Setting/Dashboard/SettingsAPIInfo.js'; import SettingsAnnouncements from '../../pages/Setting/Dashboard/SettingsAnnouncements.js'; import SettingsFAQ from '../../pages/Setting/Dashboard/SettingsFAQ.js'; +import SettingsUptimeKuma from '../../pages/Setting/Dashboard/SettingsUptimeKuma.js'; const DashboardSetting = () => { let [inputs, setInputs] = useState({ ApiInfo: '', Announcements: '', FAQ: '', + UptimeKumaUrl: '', + UptimeKumaSlug: '', }); let [loading, setLoading] = useState(false); @@ -63,6 +66,11 @@ const DashboardSetting = () => { + + {/* Uptime Kuma 监控设置 */} + + + ); diff --git a/web/src/i18n/locales/en.json b/web/src/i18n/locales/en.json index ee90e65c..d0077a73 100644 --- a/web/src/i18n/locales/en.json +++ b/web/src/i18n/locales/en.json @@ -941,7 +941,7 @@ "支付中..": "Paying", "查看图片": "View pictures", "并发限制": "Concurrency limit", - "正常": "normal", + "正常": "Normal", "周期": "cycle", "同步频率10-20分钟": "Synchronization frequency 10-20 minutes", "模型调用占比": "Model call proportion", @@ -1589,7 +1589,6 @@ "颜色": "Color", "标识颜色": "Identifier color", "添加API": "Add API", - "保存配置": "Save Configuration", "API信息": "API Information", "暂无API信息": "No API information", "请输入API地址": "Please enter the API address", @@ -1629,5 +1628,22 @@ "系统公告管理,可以发布系统通知和重要消息(最多100个,前端显示最新20条)": "System notice management, you can publish system notices and important messages (maximum 100, display latest 20 on the front end)", "常见问答管理,为用户提供常见问题的答案(最多50个,前端显示最新20条)": "FAQ management, providing answers to common questions for users (maximum 50, display latest 20 on the front end)", "暂无常见问答": "No FAQ", - "显示最新20条": "Display latest 20" + "显示最新20条": "Display latest 20", + "Uptime Kuma 服务地址": "Uptime Kuma service address", + "状态页面 Slug": "Status page slug", + "请输入 Uptime Kuma 服务的完整地址,例如:https://uptime.example.com": "Please enter the complete address of Uptime Kuma, for example: https://uptime.example.com", + "请输入状态页面的 slug 标识符,例如:my-status": "Please enter the slug identifier for the status page, for example: my-status", + "Uptime Kuma 服务地址不能为空": "Uptime Kuma service address cannot be empty", + "请输入有效的 URL 地址": "Please enter a valid URL address", + "状态页面 Slug 不能为空": "Status page slug cannot be empty", + "Slug 只能包含字母、数字、下划线和连字符": "Slug can only contain letters, numbers, underscores, and hyphens", + "请输入 Uptime Kuma 服务地址": "Please enter the Uptime Kuma service address", + "请输入状态页面 Slug": "Please enter the status page slug", + "配置": "Configure", + "服务监控地址,用于展示服务状态信息": "service monitoring address for displaying status information", + "服务可用性": "Service Status", + "可用率": "Availability", + "有异常": "Abnormal", + "暂无监控数据": "No monitoring data", + "请联系管理员在系统设置中配置Uptime": "Please contact the administrator to configure Uptime in the system settings." } \ No newline at end of file diff --git a/web/src/pages/Detail/index.js b/web/src/pages/Detail/index.js index 592f8d37..1292d337 100644 --- a/web/src/pages/Detail/index.js +++ b/web/src/pages/Detail/index.js @@ -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) => { {/* 常见问答卡片 */} @@ -1208,6 +1257,99 @@ const Detail = (props) => { /> + + {/* 服务可用性卡片 */} + +
+ + {t('服务可用性')} +
+
+ {/* 图例 */} +
+ {uptimeLegendData.map((legend, index) => ( +
+
+ {legend.label} +
+ ))} +
+ } + onClick={loadUptimeData} + loading={uptimeLoading} + size="small" + theme="borderless" + className="text-gray-500 hover:text-blue-500 hover:bg-blue-50 !rounded-full" + /> +
+
+ } + > +
+ +
handleCardScroll(uptimeScrollRef, setShowUptimeScrollHint)} + > + {uptimeData.length > 0 ? ( + uptimeData.map((monitor, idx) => ( +
+
+
+
+ {monitor.name} +
+ {((monitor.uptime || 0) * 100).toFixed(2)}% +
+
+ {getUptimeStatusText(monitor.status)} +
+ +
+
+
+ )) + ) : ( +
+ } + darkModeImage={} + title={t('暂无监控数据')} + description={t('请联系管理员在系统设置中配置Uptime')} + style={{ padding: '12px' }} + /> +
+ )} +
+ +
+
+
)} diff --git a/web/src/pages/Setting/Dashboard/SettingsAPIInfo.js b/web/src/pages/Setting/Dashboard/SettingsAPIInfo.js index f4340e6e..4e87c697 100644 --- a/web/src/pages/Setting/Dashboard/SettingsAPIInfo.js +++ b/web/src/pages/Setting/Dashboard/SettingsAPIInfo.js @@ -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('保存设置')} diff --git a/web/src/pages/Setting/Dashboard/SettingsAnnouncements.js b/web/src/pages/Setting/Dashboard/SettingsAnnouncements.js index 7e890ed7..3a9f71ca 100644 --- a/web/src/pages/Setting/Dashboard/SettingsAnnouncements.js +++ b/web/src/pages/Setting/Dashboard/SettingsAnnouncements.js @@ -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('保存设置')} diff --git a/web/src/pages/Setting/Dashboard/SettingsFAQ.js b/web/src/pages/Setting/Dashboard/SettingsFAQ.js index f6cc8ac6..3e1ff805 100644 --- a/web/src/pages/Setting/Dashboard/SettingsFAQ.js +++ b/web/src/pages/Setting/Dashboard/SettingsFAQ.js @@ -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('保存设置')} diff --git a/web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js b/web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js new file mode 100644 index 00000000..3a7b4896 --- /dev/null +++ b/web/src/pages/Setting/Dashboard/SettingsUptimeKuma.js @@ -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 = () => ( +
+
+
+ + + {t('配置')}  + + Uptime Kuma + +  {t('服务监控地址,用于展示服务状态信息')} + +
+ +
+ +
+
+
+ ); + + return ( + +
{ + formApiRef.current = api; + }} + > + + + { + const url = (value || '').trim(); + + if (url && !isValidUrl(url)) { + return Promise.reject(t('请输入有效的 URL 地址')); + } + + return Promise.resolve(); + } + } + ]} + /> + + + + { + const slug = (value || '').trim(); + + if (slug && !/^[a-zA-Z0-9_-]+$/.test(slug)) { + return Promise.reject(t('Slug 只能包含字母、数字、下划线和连字符')); + } + + return Promise.resolve(); + } + } + ]} + /> + + +
+
+ ); +}; + +export default SettingsUptimeKuma; \ No newline at end of file