From 29b0bc68067d51f64c725d9fdb13f1e041f77b5b Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 6 Feb 2025 09:50:48 -0500 Subject: [PATCH] Add status monitoring --- .env | 1 + package-lock.json | 58 +++++++++++++ package.json | 1 + src/App.tsx | 150 +++++++++++++++++++++++++--------- src/components/ui/tooltip.tsx | 30 +++++++ src/hooks/useMetrics.ts | 127 ++++++++++++++++++++++++++++ src/types/metrics.ts | 13 +++ vite.config.ts | 35 ++++++-- 8 files changed, 367 insertions(+), 48 deletions(-) create mode 100644 .env create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/hooks/useMetrics.ts create mode 100644 src/types/metrics.ts diff --git a/.env b/.env new file mode 100644 index 0000000..add1e32 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_UPTIME_API_KEY=uk1_iMTE6cHqoyMJsch2K7607XsfYqtMIDRcemPp039p \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c4580dc..f3b85da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.474.0", @@ -1555,6 +1556,40 @@ } } }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.1.8.tgz", + "integrity": "sha512-YAA2cu48EkJZdAMHC0dqo9kialOcRStbtiY4nJPaht7Ptrhcvpo+eDChaM6BIs8kL6a8Z5l5poiqLnXcNduOkA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.5", + "@radix-ui/react-id": "1.1.0", + "@radix-ui/react-popper": "1.2.2", + "@radix-ui/react-portal": "1.1.4", + "@radix-ui/react-presence": "1.1.2", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-visually-hidden": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-callback-ref": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz", @@ -1657,6 +1692,29 @@ } } }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz", + "integrity": "sha512-1SzA4ns2M1aRlvxErqhLHsBHoS5eI5UUcI2awAMgGUp4LoaoWOKYmvqDY2s/tltuPkh3Yk77YF/r3IRj+Amx4Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/rect": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz", diff --git a/package.json b/package.json index df2c29e..47c49b4 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-slot": "^1.1.2", + "@radix-ui/react-tooltip": "^1.1.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.474.0", diff --git a/src/App.tsx b/src/App.tsx index 2c5da27..b925f70 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,11 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Separator } from "@/components/ui/separator" -import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { Button } from "@/components/ui/button" -import { Moon, Sun } from "lucide-react" +import { Moon, Sun, Monitor, CheckCircle2, XCircle, AlertCircle, Clock } from "lucide-react" import { useTheme } from "@/components/theme-provider" +import { useMetrics } from "./hooks/useMetrics" +import { ServiceStatus } from "./types/metrics" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { Server, Box, @@ -30,6 +32,7 @@ interface ServiceCard { url: string icon: React.ReactNode category: 'system' | 'media' | 'monitoring' | 'tools' | 'acot' | 'home' + monitorName?: string // Optional monitor name that matches Uptime Kuma } const services: ServiceCard[] = [ @@ -59,7 +62,8 @@ const services: ServiceCard[] = [ description: "Synology NAS", url: "https://diskstation.kent.pw", icon: , - category: "system" + category: "system", + monitorName: "Synology Diskstation" }, { name: "Plex", @@ -101,21 +105,24 @@ const services: ServiceCard[] = [ description: "Network Ad Blocking", url: "https://adguard.kent.pw", icon: , - category: "system" + category: "system", + monitorName: "Adguard Home" }, { name: "NocoDB", description: "Database Platform", url: "https://noco.kent.pw", icon: , - category: "tools" + category: "tools", + monitorName: "NocoDB" }, { name: "IT Tools", description: "Developer Utilities", url: "https://ittools.kent.pw", icon: , - category: "tools" + category: "tools", + monitorName: "IT Tools" }, { name: "Firefox", @@ -129,14 +136,16 @@ const services: ServiceCard[] = [ description: "Network Speed Monitor", url: "https://speedtest.kent.pw", icon: , - category: "monitoring" + category: "monitoring", + monitorName: "Speedtest Tracker" }, { name: "Drive", description: "File Storage", url: "https://drive.kent.pw", icon: , - category: "tools" + category: "tools", + monitorName: "Synology Drive" }, { name: "Dashboard", @@ -169,33 +178,82 @@ const services: ServiceCard[] = [ ] function ThemeToggle() { - const { setTheme } = useTheme() + const { theme, setTheme } = useTheme() + + const cycleTheme = () => { + if (theme === 'light') setTheme('dark') + else if (theme === 'dark') setTheme('system') + else setTheme('light') + } return ( - - - - - - setTheme("light")}> - Light - - setTheme("dark")}> - Dark - - setTheme("system")}> - System - - - + ) } -function ServiceSection({ title, services }: { title: string, services: ServiceCard[] }) { +function StatusIndicator({ status, responseTime, certDaysRemaining }: { + status: ServiceStatus, + responseTime: number, + certDaysRemaining: number +}) { + const getStatusIcon = () => { + switch (status) { + case 'up': + return + case 'down': + return + case 'pending': + return + case 'maintenance': + return + } + } + + const getStatusText = () => { + const statusText = status.charAt(0).toUpperCase() + status.slice(1) + const responseTimeText = `Response time: ${responseTime}ms` + const certText = certDaysRemaining > 0 + ? `SSL cert expires in ${certDaysRemaining} days` + : 'SSL cert expired' + + return `${statusText} • ${responseTimeText} • ${certText}` + } + + return ( + + + + {getStatusIcon()} + + +

{getStatusText()}

+
+
+
+ ) +} + +function ServiceSection({ title, services, metrics }: { + title: string, + services: ServiceCard[], + metrics: Record +}) { return (
@@ -204,6 +262,12 @@ function ServiceSection({ title, services }: { title: string, services: ServiceC
{services.map((service) => { + const serviceMetrics = metrics[service.monitorName || service.name] || { + status: 'pending', + responseTime: 0, + certDaysRemaining: 0 + } + // Get background color based on service const bgColor = (() => { switch (service.name) { @@ -283,9 +347,16 @@ function ServiceSection({ title, services }: { title: string, services: ServiceC className="transition-transform hover:scale-105" > - -
{service.icon}
- {service.name} + +
+
{service.icon}
+ {service.name} +
+
{service.description} @@ -300,6 +371,7 @@ function ServiceSection({ title, services }: { title: string, services: ServiceC } function App() { + const { metrics, loading, error } = useMetrics(import.meta.env.VITE_UPTIME_API_KEY) const systemServices = services.filter(s => s.category === 'system') const mediaServices = services.filter(s => s.category === 'media') const monitoringServices = services.filter(s => s.category === 'monitoring') @@ -313,12 +385,12 @@ function App() {

Kent.pw Homepage

- - - - - - + + + + + +
) } diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..218d183 --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -0,0 +1,30 @@ +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "@/lib/utils" + +const TooltipProvider = TooltipPrimitive.Provider + +const Tooltip = TooltipPrimitive.Root + +const TooltipTrigger = TooltipPrimitive.Trigger + +const TooltipContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +TooltipContent.displayName = TooltipPrimitive.Content.displayName + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/src/hooks/useMetrics.ts b/src/hooks/useMetrics.ts new file mode 100644 index 0000000..0798d02 --- /dev/null +++ b/src/hooks/useMetrics.ts @@ -0,0 +1,127 @@ +import { useState, useEffect } from 'react' +import { MetricsData, ServiceMetrics } from '../types/metrics' + +const parseMetricsData = (data: string): MetricsData => { + const metrics: MetricsData = {} + const lines = data.split('\n') + + // Helper to extract value from a metrics line + const getValue = (line: string) => { + const match = line.match(/\} (\d+(\.\d+)?)$/) + return match ? parseFloat(match[1]) : 0 + } + + // Helper to extract monitor name from a metrics line + const getMonitorName = (line: string) => { + const match = line.match(/monitor_name="([^"]+)"/) + return match ? match[1] : '' + } + + lines.forEach(line => { + if (line.startsWith('#') || !line.trim()) return + + if (line.includes('monitor_status{')) { + const name = getMonitorName(line) + const status = getValue(line) + + if (!metrics[name]) { + metrics[name] = { + name, + status: status === 1 ? 'up' : status === 0 ? 'down' : status === 2 ? 'pending' : 'maintenance', + responseTime: 0, + certDaysRemaining: 0, + certValid: false + } + } else { + metrics[name].status = status === 1 ? 'up' : status === 0 ? 'down' : status === 2 ? 'pending' : 'maintenance' + } + } + + if (line.includes('monitor_response_time{')) { + const name = getMonitorName(line) + const responseTime = getValue(line) + + if (!metrics[name]) { + metrics[name] = { + name, + status: 'pending', + responseTime, + certDaysRemaining: 0, + certValid: false + } + } else { + metrics[name].responseTime = responseTime + } + } + + if (line.includes('monitor_cert_days_remaining{')) { + const name = getMonitorName(line) + const days = getValue(line) + + if (!metrics[name]) { + metrics[name] = { + name, + status: 'pending', + responseTime: 0, + certDaysRemaining: days, + certValid: false + } + } else { + metrics[name].certDaysRemaining = days + } + } + + if (line.includes('monitor_cert_is_valid{')) { + const name = getMonitorName(line) + const isValid = getValue(line) === 1 + + if (!metrics[name]) { + metrics[name] = { + name, + status: 'pending', + responseTime: 0, + certDaysRemaining: 0, + certValid: isValid + } + } else { + metrics[name].certValid = isValid + } + } + }) + + return metrics +} + +export function useMetrics(apiKey: string) { + const [metrics, setMetrics] = useState({}) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + const fetchMetrics = async () => { + try { + const response = await fetch('/api/metrics') + + if (!response.ok) { + throw new Error('Failed to fetch metrics') + } + + const data = await response.text() + const parsedMetrics = parseMetricsData(data) + setMetrics(parsedMetrics) + setError(null) + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error') + } finally { + setLoading(false) + } + } + + fetchMetrics() + const interval = setInterval(fetchMetrics, 30000) // Refresh every 30 seconds + + return () => clearInterval(interval) + }, []) // Remove apiKey dependency since we're using it in the proxy + + return { metrics, loading, error } +} \ No newline at end of file diff --git a/src/types/metrics.ts b/src/types/metrics.ts new file mode 100644 index 0000000..8e27da1 --- /dev/null +++ b/src/types/metrics.ts @@ -0,0 +1,13 @@ +export interface ServiceMetrics { + name: string + status: 'up' | 'down' | 'pending' | 'maintenance' + responseTime: number + certDaysRemaining: number + certValid: boolean +} + +export interface MetricsData { + [serviceName: string]: ServiceMetrics +} + +export type ServiceStatus = 'up' | 'down' | 'pending' | 'maintenance' \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index ee14539..6b37344 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,15 +1,32 @@ import path from "path" import react from "@vitejs/plugin-react" -import { defineConfig } from "vite" +import { defineConfig, loadEnv } from "vite" -export default defineConfig({ - plugins: [react()], - resolve: { - alias: { - "@": path.resolve(__dirname, "./src"), +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + const apiKey = env.VITE_UPTIME_API_KEY + + return { + plugins: [react()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, }, - }, - server: { - port: 4444, + server: { + port: 4444, + proxy: { + '/api/metrics': { + target: 'https://uptime.kent.pw', + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ''), + configure: (proxy, options) => { + proxy.on('proxyReq', (proxyReq, req, res) => { + proxyReq.setHeader('Authorization', `Basic ${Buffer.from(':' + apiKey).toString('base64')}`) + }) + } + } + } + } } })