v3.1
This commit is contained in:
parent
63c93fa7e4
commit
b2d2b8b2f3
11 changed files with 258 additions and 171 deletions
|
|
@ -1,13 +1,13 @@
|
|||
'use client';
|
||||
|
||||
import { ServiceCategory, ServiceStatus } from '@/lib/data';
|
||||
import { ServiceStatus } from '@/src/types/service';
|
||||
|
||||
interface FilterBarProps {
|
||||
categories: ServiceCategory[];
|
||||
categories: string[];
|
||||
statuses: ServiceStatus[];
|
||||
selectedCategories: ServiceCategory[];
|
||||
selectedCategories: string[];
|
||||
selectedStatuses: ServiceStatus[];
|
||||
onCategoryChange: (category: ServiceCategory) => void;
|
||||
onCategoryChange: (category: string) => void;
|
||||
onStatusChange: (status: ServiceStatus) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
'use client';
|
||||
|
||||
import { Service } from '@/lib/data';
|
||||
import { Service, ServiceCheckResult } from '@/src/types/service';
|
||||
|
||||
interface ServiceCardProps {
|
||||
service: Service;
|
||||
result?: ServiceCheckResult;
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
|
|
@ -25,14 +26,22 @@ const STATUS_CONFIG = {
|
|||
dotColor: 'bg-red-500',
|
||||
label: 'Offline',
|
||||
},
|
||||
unknown: {
|
||||
bgColor: 'bg-slate-50 dark:bg-slate-950',
|
||||
textColor: 'text-slate-700 dark:text-slate-300',
|
||||
dotColor: 'bg-slate-500',
|
||||
label: 'Unbekannt',
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* ServiceCard - Zeigt einen einzelnen Service mit Status, Icon und Öffnen-Button
|
||||
* @param service - Das Service-Objekt mit Name, Beschreibung, Status, etc.
|
||||
* @param service - Das Service-Objekt mit Name, Beschreibung, etc.
|
||||
* @param result - Optionale Status-Check-Ergebnisse
|
||||
*/
|
||||
export function ServiceCard({ service }: ServiceCardProps) {
|
||||
const config = STATUS_CONFIG[service.status];
|
||||
export function ServiceCard({ service, result }: ServiceCardProps) {
|
||||
const status = result?.status || 'unknown';
|
||||
const config = STATUS_CONFIG[status];
|
||||
|
||||
return (
|
||||
<a
|
||||
|
|
@ -43,22 +52,15 @@ export function ServiceCard({ service }: ServiceCardProps) {
|
|||
>
|
||||
<div className="h-full bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-sm hover:shadow-md transition-all duration-300 overflow-hidden hover:border-slate-300 dark:hover:border-slate-600">
|
||||
<div className="p-6 flex flex-col gap-4 h-full">
|
||||
{/* Header mit Icon, Name, Kategorie und Status */}
|
||||
{/* Header mit Name, Kategorie und Status */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 flex items-center gap-3">
|
||||
{service.icon && (
|
||||
<span className="text-2xl flex-shrink-0" aria-hidden="true">
|
||||
{service.icon}
|
||||
</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors truncate">
|
||||
{service.name}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 truncate">
|
||||
{service.category}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors truncate">
|
||||
{service.name}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400 truncate">
|
||||
{service.category}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
|
|
@ -66,7 +68,7 @@ export function ServiceCard({ service }: ServiceCardProps) {
|
|||
className={`flex items-center gap-2 px-3 py-1 rounded-full whitespace-nowrap flex-shrink-0 ${config.bgColor}`}
|
||||
>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${config.dotColor} animate-pulse`}
|
||||
className={`w-2 h-2 rounded-full ${config.dotColor} ${status !== 'unknown' ? 'animate-pulse' : ''}`}
|
||||
/>
|
||||
<span className={`text-xs font-medium ${config.textColor}`}>
|
||||
{config.label}
|
||||
|
|
@ -74,6 +76,18 @@ export function ServiceCard({ service }: ServiceCardProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Details */}
|
||||
{result && (
|
||||
<div className="text-xs text-slate-500 dark:text-slate-400 space-y-1">
|
||||
{result.httpStatus && (
|
||||
<div>HTTP: {result.httpStatus}</div>
|
||||
)}
|
||||
{result.responseTimeMs !== null && (
|
||||
<div>Zeit: {result.responseTimeMs}ms</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Beschreibung */}
|
||||
<p className="text-sm text-slate-600 dark:text-slate-400 flex-grow line-clamp-2">
|
||||
{service.description}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { Service } from '@/lib/data';
|
||||
import { Service } from '@/src/types/service';
|
||||
import { ServiceCard } from './ServiceCard';
|
||||
import { EmptyState } from './EmptyState';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,84 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Service, ServiceCategory, ServiceStatus } from '@/lib/data';
|
||||
import { FilterBar } from './FilterBar';
|
||||
import { ServiceGrid } from './ServiceGrid';
|
||||
|
||||
interface ServiceSectionProps {
|
||||
services: Service[];
|
||||
}
|
||||
|
||||
/**
|
||||
* ServiceSection - Verwaltet Filterlogik und rendert FilterBar + ServiceGrid
|
||||
* @param services - Array aller verfügbaren Services
|
||||
*/
|
||||
export function ServiceSection({ services }: ServiceSectionProps) {
|
||||
const [selectedCategories, setSelectedCategories] = useState<
|
||||
ServiceCategory[]
|
||||
>([]);
|
||||
const [selectedStatuses, setSelectedStatuses] = useState<ServiceStatus[]>([]);
|
||||
|
||||
// Extrahiere eindeutige Kategorien und Status aus Services
|
||||
const categories = useMemo(
|
||||
() => Array.from(new Set(services.map((s) => s.category))).sort(),
|
||||
[services]
|
||||
);
|
||||
|
||||
const statuses = useMemo(
|
||||
() =>
|
||||
(['online', 'warning', 'offline'] as ServiceStatus[]).filter((status) =>
|
||||
services.some((s) => s.status === status)
|
||||
),
|
||||
[services]
|
||||
);
|
||||
|
||||
// Filtere Services basierend auf ausgewählten Filtern
|
||||
const filteredServices = useMemo(() => {
|
||||
return services.filter((service) => {
|
||||
const categoryMatch =
|
||||
selectedCategories.length === 0 ||
|
||||
selectedCategories.includes(service.category);
|
||||
|
||||
const statusMatch =
|
||||
selectedStatuses.length === 0 ||
|
||||
selectedStatuses.includes(service.status);
|
||||
|
||||
return categoryMatch && statusMatch;
|
||||
});
|
||||
}, [services, selectedCategories, selectedStatuses]);
|
||||
|
||||
const handleCategoryChange = (category: ServiceCategory) => {
|
||||
setSelectedCategories((prev) =>
|
||||
prev.includes(category)
|
||||
? prev.filter((c) => c !== category)
|
||||
: [...prev, category]
|
||||
);
|
||||
};
|
||||
|
||||
const handleStatusChange = (status: ServiceStatus) => {
|
||||
setSelectedStatuses((prev) =>
|
||||
prev.includes(status) ? prev.filter((s) => s !== status) : [...prev, status]
|
||||
);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSelectedCategories([]);
|
||||
setSelectedStatuses([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FilterBar
|
||||
categories={categories}
|
||||
statuses={statuses}
|
||||
selectedCategories={selectedCategories}
|
||||
selectedStatuses={selectedStatuses}
|
||||
onCategoryChange={handleCategoryChange}
|
||||
onStatusChange={handleStatusChange}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
<ServiceGrid services={filteredServices} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
105
app/components/dashboard-client.tsx
Normal file
105
app/components/dashboard-client.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ServiceCheckResult } from '@/src/types/service';
|
||||
import { services } from '@/src/data/services';
|
||||
import { ServiceCard } from './ServiceCard';
|
||||
|
||||
export function DashboardClient() {
|
||||
const [results, setResults] = useState<ServiceCheckResult[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const response = await fetch('/api/status');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch status');
|
||||
}
|
||||
const data: ServiceCheckResult[] = await response.json();
|
||||
setResults(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
const interval = setInterval(fetchStatus, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const getResultForService = (serviceId: string): ServiceCheckResult | undefined => {
|
||||
return results.find(result => result.id === serviceId);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||
{/* Header */}
|
||||
<header className="bg-gradient-to-r from-slate-900 to-slate-800 border-b border-slate-700 shadow-lg">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold text-white tracking-tight">
|
||||
Homelab Dashboard
|
||||
</h1>
|
||||
<div className="text-sm text-slate-400">
|
||||
{loading ? (
|
||||
'Lade Status...'
|
||||
) : (
|
||||
'Automatische Aktualisierung: 30s'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Live-Status aller verwalteten Dienste
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
{error && (
|
||||
<div className="mb-8 p-4 bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-5 h-5 text-red-600 dark:text-red-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-red-800 dark:text-red-200">
|
||||
Fehler beim Laden der Status-Daten: {error}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Services Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{services.map((service) => (
|
||||
<ServiceCard
|
||||
key={service.id}
|
||||
service={service}
|
||||
result={getResultForService(service.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,6 +4,6 @@
|
|||
export { Header } from './Header';
|
||||
export { ServiceCard } from './ServiceCard';
|
||||
export { ServiceGrid } from './ServiceGrid';
|
||||
export { ServiceSection } from './ServiceSection';
|
||||
export { FilterBar } from './FilterBar';
|
||||
export { EmptyState } from './EmptyState';
|
||||
export { DashboardClient } from './dashboard-client';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { Metadata as NextMetadata } from 'next';
|
||||
|
||||
export const metadata: NextMetadata = {
|
||||
|
|
|
|||
15
app/page.tsx
15
app/page.tsx
|
|
@ -1,21 +1,12 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { Header, ServiceSection } from './components';
|
||||
import { services } from '@/lib/data';
|
||||
import { DashboardClient } from './components/dashboard-client';
|
||||
import './globals.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Homelab Dashboard',
|
||||
description: 'Verwaltungs-Dashboard für dein Homelab',
|
||||
description: 'Live-Monitoring-Dashboard für dein Homelab',
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 dark:bg-slate-900">
|
||||
<Header />
|
||||
|
||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<ServiceSection services={services} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
return <DashboardClient />;
|
||||
}
|
||||
|
|
|
|||
67
src/app/api/status/route.ts
Normal file
67
src/app/api/status/route.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { NextResponse } from 'next/server';
|
||||
import { services } from '@/src/data/services';
|
||||
import { ServiceCheckResult } from '@/src/types/service';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
async function checkService(service: typeof services[0]): Promise<ServiceCheckResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 second timeout
|
||||
|
||||
const response = await fetch(service.monitorUrl, {
|
||||
method: 'HEAD', // Use HEAD to minimize data transfer
|
||||
cache: 'no-store',
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
let status: ServiceCheckResult['status'];
|
||||
|
||||
if (response.ok) {
|
||||
status = responseTime <= 2000 ? 'online' : 'warning';
|
||||
} else if (response.status >= 400 && response.status < 500) {
|
||||
status = 'warning';
|
||||
} else {
|
||||
status = 'offline';
|
||||
}
|
||||
|
||||
return {
|
||||
id: service.id,
|
||||
status,
|
||||
responseTimeMs: responseTime,
|
||||
httpStatus: response.status,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
} catch (error) {
|
||||
const responseTime = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
id: service.id,
|
||||
status: 'offline',
|
||||
responseTimeMs: responseTime,
|
||||
httpStatus: null,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
services.map(service => checkService(service))
|
||||
);
|
||||
|
||||
return NextResponse.json(results);
|
||||
} catch (error) {
|
||||
console.error('Error checking services:', error);
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to check services' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,15 +1,4 @@
|
|||
export type ServiceCategory = 'Infrastruktur' | 'Smart Home' | 'Medien' | 'Dokumente';
|
||||
export type ServiceStatus = 'online' | 'warning' | 'offline';
|
||||
|
||||
export interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: ServiceCategory;
|
||||
status: ServiceStatus;
|
||||
url: string;
|
||||
icon?: string;
|
||||
}
|
||||
import { Service } from '@/src/types/service';
|
||||
|
||||
export const services: Service[] = [
|
||||
// Infrastruktur
|
||||
|
|
@ -17,37 +6,33 @@ export const services: Service[] = [
|
|||
id: 'unraid',
|
||||
name: 'Unraid',
|
||||
description: 'NAS und Server Management Platform',
|
||||
category: 'Infrastruktur',
|
||||
status: 'online',
|
||||
url: 'http://localhost',
|
||||
icon: '🖥️',
|
||||
monitorUrl: 'http://localhost',
|
||||
category: 'Infrastruktur',
|
||||
},
|
||||
{
|
||||
id: 'unifi',
|
||||
name: 'UniFi',
|
||||
description: 'Netzwerk-Management und WLAN-Controller',
|
||||
category: 'Infrastruktur',
|
||||
status: 'online',
|
||||
url: 'http://localhost:8443',
|
||||
icon: '🌐',
|
||||
monitorUrl: 'http://localhost:8443',
|
||||
category: 'Infrastruktur',
|
||||
},
|
||||
{
|
||||
id: 'checkmk',
|
||||
name: 'Checkmk',
|
||||
description: 'Monitoring und Alerting System',
|
||||
category: 'Infrastruktur',
|
||||
status: 'online',
|
||||
url: 'http://localhost/checkmk',
|
||||
icon: '📊',
|
||||
monitorUrl: 'http://localhost/checkmk',
|
||||
category: 'Infrastruktur',
|
||||
},
|
||||
{
|
||||
id: 'nginx-proxy',
|
||||
name: 'Nginx Proxy Manager',
|
||||
description: 'Reverse Proxy und SSL-Verwaltung',
|
||||
category: 'Infrastruktur',
|
||||
status: 'online',
|
||||
url: 'http://localhost:81',
|
||||
icon: '⚙️',
|
||||
monitorUrl: 'http://localhost:81',
|
||||
category: 'Infrastruktur',
|
||||
},
|
||||
|
||||
// Smart Home
|
||||
|
|
@ -55,10 +40,9 @@ export const services: Service[] = [
|
|||
id: 'home-assistant',
|
||||
name: 'Home Assistant',
|
||||
description: 'Smart Home Automation und Integration',
|
||||
category: 'Smart Home',
|
||||
status: 'offline',
|
||||
url: 'http://localhost:8123',
|
||||
icon: '🏠',
|
||||
monitorUrl: 'http://localhost:8123',
|
||||
category: 'Smart Home',
|
||||
},
|
||||
|
||||
// Medien
|
||||
|
|
@ -66,46 +50,41 @@ export const services: Service[] = [
|
|||
id: 'plex',
|
||||
name: 'Plex',
|
||||
description: 'Medienserver und Streaming-Dienst',
|
||||
category: 'Medien',
|
||||
status: 'online',
|
||||
url: 'http://localhost:32400',
|
||||
icon: '🎬',
|
||||
monitorUrl: 'http://localhost:32400',
|
||||
category: 'Medien',
|
||||
},
|
||||
{
|
||||
id: 'jellyfin',
|
||||
name: 'Jellyfin',
|
||||
description: 'Open-Source Medienserver',
|
||||
category: 'Medien',
|
||||
status: 'online',
|
||||
url: 'http://localhost:8096',
|
||||
icon: '📺',
|
||||
monitorUrl: 'http://localhost:8096',
|
||||
category: 'Medien',
|
||||
},
|
||||
{
|
||||
id: 'radarr',
|
||||
name: 'Radarr',
|
||||
description: 'Automatisches Filmmanagement',
|
||||
category: 'Medien',
|
||||
status: 'online',
|
||||
url: 'http://localhost:7878',
|
||||
icon: '🎥',
|
||||
monitorUrl: 'http://localhost:7878',
|
||||
category: 'Medien',
|
||||
},
|
||||
{
|
||||
id: 'sonarr',
|
||||
name: 'Sonarr',
|
||||
description: 'Automatisches Serienmanagement',
|
||||
category: 'Medien',
|
||||
status: 'online',
|
||||
url: 'http://localhost:8989',
|
||||
icon: '📺',
|
||||
monitorUrl: 'http://localhost:8989',
|
||||
category: 'Medien',
|
||||
},
|
||||
{
|
||||
id: 'prowlarr',
|
||||
name: 'Prowlarr',
|
||||
description: 'Indexer und Proxy Manager',
|
||||
category: 'Medien',
|
||||
status: 'warning',
|
||||
url: 'http://localhost:9696',
|
||||
icon: '🔍',
|
||||
monitorUrl: 'http://localhost:9696',
|
||||
category: 'Medien',
|
||||
},
|
||||
|
||||
// Dokumente
|
||||
|
|
@ -113,18 +92,16 @@ export const services: Service[] = [
|
|||
id: 'seafile',
|
||||
name: 'Seafile',
|
||||
description: 'Dateifreigabe und Cloud Storage',
|
||||
category: 'Dokumente',
|
||||
status: 'online',
|
||||
url: 'http://localhost:8000',
|
||||
icon: '☁️',
|
||||
monitorUrl: 'http://localhost:8000',
|
||||
category: 'Dokumente',
|
||||
},
|
||||
{
|
||||
id: 'forgejo',
|
||||
name: 'Forgejo',
|
||||
description: 'Self-hosted Git und Code Repository',
|
||||
category: 'Dokumente',
|
||||
status: 'online',
|
||||
url: 'http://localhost:3000',
|
||||
icon: '💾',
|
||||
monitorUrl: 'http://localhost:3000',
|
||||
category: 'Dokumente',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
18
src/types/service.ts
Normal file
18
src/types/service.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
export type ServiceStatus = 'online' | 'warning' | 'offline' | 'unknown';
|
||||
|
||||
export interface Service {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
url: string;
|
||||
monitorUrl: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
export interface ServiceCheckResult {
|
||||
id: string;
|
||||
status: ServiceStatus;
|
||||
responseTimeMs: number | null;
|
||||
httpStatus: number | null;
|
||||
checkedAt: string;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue