mirror of
https://github.com/woodchen-ink/nezha-dash-v1.git
synced 2025-07-18 09:31:55 +08:00
feat: service tracker
This commit is contained in:
parent
d7f0410dcd
commit
2462dfc21b
@ -26,7 +26,6 @@ import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
|||||||
import NetworkChartLoading from "./NetworkChartLoading";
|
import NetworkChartLoading from "./NetworkChartLoading";
|
||||||
import { NezhaMonitor, ServerMonitorChart } from "@/types/nezha-api";
|
import { NezhaMonitor, ServerMonitorChart } from "@/types/nezha-api";
|
||||||
|
|
||||||
|
|
||||||
interface ResultItem {
|
interface ResultItem {
|
||||||
created_at: number;
|
created_at: number;
|
||||||
[key: string]: number | null;
|
[key: string]: number | null;
|
||||||
@ -41,17 +40,15 @@ export function NetworkChart({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const { data: monitorData} = useQuery(
|
const { data: monitorData } = useQuery({
|
||||||
{
|
queryKey: ["monitor", server_id],
|
||||||
queryKey: ["monitor", server_id],
|
queryFn: () => fetchMonitor(server_id),
|
||||||
queryFn: () => fetchMonitor(server_id),
|
enabled: show,
|
||||||
enabled: show,
|
refetchOnMount: true,
|
||||||
refetchOnMount: true,
|
refetchOnWindowFocus: true,
|
||||||
refetchOnWindowFocus: true,
|
refetchInterval: 10000,
|
||||||
refetchInterval: 10000,
|
});
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!monitorData) return <NetworkChartLoading />;
|
if (!monitorData) return <NetworkChartLoading />;
|
||||||
|
|
||||||
if (monitorData?.success && monitorData.data.length === 0) {
|
if (monitorData?.success && monitorData.data.length === 0) {
|
||||||
@ -68,8 +65,6 @@ export function NetworkChart({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const transformedData = transformData(monitorData.data);
|
const transformedData = transformData(monitorData.data);
|
||||||
|
|
||||||
const formattedData = formatData(monitorData.data);
|
const formattedData = formatData(monitorData.data);
|
||||||
|
@ -7,7 +7,11 @@ import { Card } from "./ui/card";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function ServerCard({ serverInfo }: { serverInfo: NezhaServer }) {
|
export default function ServerCard({
|
||||||
|
serverInfo,
|
||||||
|
}: {
|
||||||
|
serverInfo: NezhaServer;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { name, country_code, online, cpu, up, down, mem, stg } =
|
const { name, country_code, online, cpu, up, down, mem, stg } =
|
||||||
|
@ -50,7 +50,11 @@ type connectChartData = {
|
|||||||
udp: number;
|
udp: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ServerDetailChart({server_id}: {server_id: string}) {
|
export default function ServerDetailChart({
|
||||||
|
server_id,
|
||||||
|
}: {
|
||||||
|
server_id: string;
|
||||||
|
}) {
|
||||||
const { lastMessage, readyState } = useWebSocketContext();
|
const { lastMessage, readyState } = useWebSocketContext();
|
||||||
|
|
||||||
if (readyState !== 1) {
|
if (readyState !== 1) {
|
||||||
|
@ -9,10 +9,14 @@ import { NezhaWebsocketResponse } from "@/types/nezha-api";
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
export default function ServerDetailOverview({server_id}: {server_id: string}) {
|
export default function ServerDetailOverview({
|
||||||
|
server_id,
|
||||||
|
}: {
|
||||||
|
server_id: string;
|
||||||
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { lastMessage, readyState } = useWebSocketContext();
|
const { lastMessage, readyState } = useWebSocketContext();
|
||||||
|
|
||||||
if (readyState !== 1) {
|
if (readyState !== 1) {
|
||||||
|
56
src/components/ServiceTracker.tsx
Normal file
56
src/components/ServiceTracker.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ServiceTrackerClient from "./ServiceTrackerClient";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { fetchService } from "@/lib/nezha-api";
|
||||||
|
import { ServiceData } from "@/types/nezha-api";
|
||||||
|
|
||||||
|
export const ServiceTracker: React.FC = () => {
|
||||||
|
const { data: serviceData, isLoading } = useQuery({
|
||||||
|
queryKey: ["service"],
|
||||||
|
queryFn: () => fetchService(),
|
||||||
|
refetchOnMount: true,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
refetchInterval: 10000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const processServiceData = (serviceData: ServiceData) => {
|
||||||
|
const days = serviceData.up.map((up, index) => ({
|
||||||
|
completed: up > serviceData.down[index],
|
||||||
|
date: new Date(Date.now() - (29 - index) * 24 * 60 * 60 * 1000),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const totalUp = serviceData.up.reduce((a, b) => a + b, 0);
|
||||||
|
const totalChecks =
|
||||||
|
serviceData.up.reduce((a, b) => a + b, 0) +
|
||||||
|
serviceData.down.reduce((a, b) => a + b, 0);
|
||||||
|
const uptime = (totalUp / totalChecks) * 100;
|
||||||
|
|
||||||
|
return { days, uptime };
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className="mt-4">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!serviceData?.data?.services) {
|
||||||
|
return <div className="mt-4">No service data available</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-4 w-full mx-auto grid grid-cols-1 md:grid-cols-2 gap-2 md:gap-4">
|
||||||
|
{Object.entries(serviceData.data.services).map(([name, data]) => {
|
||||||
|
const { days, uptime } = processServiceData(data);
|
||||||
|
return (
|
||||||
|
<ServiceTrackerClient
|
||||||
|
key={name}
|
||||||
|
days={days}
|
||||||
|
title={data.service.name}
|
||||||
|
uptime={uptime}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServiceTracker;
|
62
src/components/ServiceTrackerClient.tsx
Normal file
62
src/components/ServiceTrackerClient.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ServiceTrackerProps {
|
||||||
|
days: Array<{
|
||||||
|
completed: boolean;
|
||||||
|
date?: Date;
|
||||||
|
}>;
|
||||||
|
className?: string;
|
||||||
|
title?: string;
|
||||||
|
uptime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ServiceTrackerClient: React.FC<ServiceTrackerProps> = ({
|
||||||
|
days,
|
||||||
|
className,
|
||||||
|
title,
|
||||||
|
uptime = 100,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"w-full space-y-3 bg-white px-4 py-4 dark:bg-black rounded-lg border bg-card text-card-foreground shadow-lg shadow-neutral-200/40 dark:shadow-none",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-5 h-5 rounded-full bg-green-600 flex items-center justify-center">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-white dark:bg-black" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-sm">{title}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-green-600 font-medium text-sm">
|
||||||
|
{uptime.toFixed(1)}% uptime
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-[2px]">
|
||||||
|
{days.map((day, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={cn(
|
||||||
|
"flex-1 h-6 rounded-[5px] transition-colors",
|
||||||
|
day.completed ? "bg-green-600" : "bg-red-500",
|
||||||
|
)}
|
||||||
|
title={
|
||||||
|
day.date ? day.date.toLocaleDateString() : `Day ${index + 1}`
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between text-xs text-stone-500 dark:text-stone-400">
|
||||||
|
<span>30 DAYS AGO</span>
|
||||||
|
<span>TODAY</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServiceTrackerClient;
|
@ -2,7 +2,6 @@ import { cn } from "@/lib/utils";
|
|||||||
import { m } from "framer-motion";
|
import { m } from "framer-motion";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
|
||||||
export default function TabSwitch({
|
export default function TabSwitch({
|
||||||
tabs,
|
tabs,
|
||||||
currentTab,
|
currentTab,
|
||||||
@ -38,7 +37,7 @@ export default function TabSwitch({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="relative z-20 flex items-center gap-1">
|
<div className="relative z-20 flex items-center gap-1">
|
||||||
<p className="whitespace-nowrap">{t("tabSwitch."+tab)}</p>
|
<p className="whitespace-nowrap">{t("tabSwitch." + tab)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
@ -1,30 +0,0 @@
|
|||||||
export const navRouter = [
|
|
||||||
{
|
|
||||||
name: "服务器",
|
|
||||||
path: "/",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "服务(Dev)",
|
|
||||||
path: "/service",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "任务(Dev)",
|
|
||||||
path: "/task",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "告警(Dev)",
|
|
||||||
path: "/alarm",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "内网穿透(Dev)",
|
|
||||||
path: "/intranet",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "用户",
|
|
||||||
path: "/user",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "设置(Dev)",
|
|
||||||
path: "/setting",
|
|
||||||
},
|
|
||||||
];
|
|
@ -1,4 +1,9 @@
|
|||||||
import { LoginUserResponse, MonitorResponse, ServerGroupResponse } from "@/types/nezha-api";
|
import {
|
||||||
|
LoginUserResponse,
|
||||||
|
MonitorResponse,
|
||||||
|
ServerGroupResponse,
|
||||||
|
ServiceResponse,
|
||||||
|
} from "@/types/nezha-api";
|
||||||
|
|
||||||
export const fetchServerGroup = async (): Promise<ServerGroupResponse> => {
|
export const fetchServerGroup = async (): Promise<ServerGroupResponse> => {
|
||||||
const response = await fetch("/api/v1/server-group");
|
const response = await fetch("/api/v1/server-group");
|
||||||
@ -18,12 +23,22 @@ export const fetchLoginUser = async (): Promise<LoginUserResponse> => {
|
|||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchMonitor = async (
|
||||||
export const fetchMonitor = async (server_id: number): Promise<MonitorResponse> => {
|
server_id: number,
|
||||||
|
): Promise<MonitorResponse> => {
|
||||||
const response = await fetch(`/api/v1/service/${server_id}`);
|
const response = await fetch(`/api/v1/service/${server_id}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
throw new Error(data.error);
|
throw new Error(data.error);
|
||||||
}
|
}
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const fetchService = async (): Promise<ServiceResponse> => {
|
||||||
|
const response = await fetch("/api/v1/service");
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.error) {
|
||||||
|
throw new Error(data.error);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
@ -58,11 +58,11 @@
|
|||||||
"pageNotFound": "Page not found",
|
"pageNotFound": "Page not found",
|
||||||
"backToHome": "Back to home"
|
"backToHome": "Back to home"
|
||||||
},
|
},
|
||||||
"tabSwitch":{
|
"tabSwitch": {
|
||||||
"Detail": "Detail",
|
"Detail": "Detail",
|
||||||
"Network": "Network"
|
"Network": "Network"
|
||||||
},
|
},
|
||||||
"monitor":{
|
"monitor": {
|
||||||
"noData": "No server monitor data",
|
"noData": "No server monitor data",
|
||||||
"avgDelay": "Latency",
|
"avgDelay": "Latency",
|
||||||
"monitorCount": "Services"
|
"monitorCount": "Services"
|
||||||
|
@ -10,6 +10,8 @@ import GroupSwitch from "@/components/GroupSwitch";
|
|||||||
import { ServerGroup } from "@/types/nezha-api";
|
import { ServerGroup } from "@/types/nezha-api";
|
||||||
import { useWebSocketContext } from "@/hooks/use-websocket-context";
|
import { useWebSocketContext } from "@/hooks/use-websocket-context";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ChartBarSquareIcon } from "@heroicons/react/20/solid";
|
||||||
|
import { ServiceTracker } from "@/components/ServiceTracker";
|
||||||
|
|
||||||
export default function Servers() {
|
export default function Servers() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -19,6 +21,7 @@ export default function Servers() {
|
|||||||
});
|
});
|
||||||
const { lastMessage, readyState } = useWebSocketContext();
|
const { lastMessage, readyState } = useWebSocketContext();
|
||||||
|
|
||||||
|
const [showServices, setShowServices] = useState(false);
|
||||||
const [currentGroup, setCurrentGroup] = useState<string>("All");
|
const [currentGroup, setCurrentGroup] = useState<string>("All");
|
||||||
|
|
||||||
const groupTabs = [
|
const groupTabs = [
|
||||||
@ -91,13 +94,22 @@ export default function Servers() {
|
|||||||
up={up}
|
up={up}
|
||||||
down={down}
|
down={down}
|
||||||
/>
|
/>
|
||||||
<div className="mt-6">
|
<section className="flex mt-6 items-center gap-2 w-full overflow-hidden">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowServices(!showServices);
|
||||||
|
}}
|
||||||
|
className="rounded-[50px] text-white cursor-pointer [text-shadow:_0_1px_0_rgb(0_0_0_/_20%)] bg-blue-600 hover:bg-blue-500 p-[10px] transition-all shadow-[inset_0_1px_0_rgba(255,255,255,0.2)] hover:shadow-[inset_0_1px_0_rgba(0,0,0,0.2)] "
|
||||||
|
>
|
||||||
|
<ChartBarSquareIcon className="size-[13px]" />
|
||||||
|
</button>
|
||||||
<GroupSwitch
|
<GroupSwitch
|
||||||
tabs={groupTabs}
|
tabs={groupTabs}
|
||||||
currentTab={currentGroup}
|
currentTab={currentGroup}
|
||||||
setCurrentTab={setCurrentGroup}
|
setCurrentTab={setCurrentGroup}
|
||||||
/>
|
/>
|
||||||
</div>
|
</section>
|
||||||
|
{showServices && <ServiceTracker />}
|
||||||
<section className="grid grid-cols-1 gap-2 md:grid-cols-2 mt-6">
|
<section className="grid grid-cols-1 gap-2 md:grid-cols-2 mt-6">
|
||||||
{filteredServers.map((serverInfo) => (
|
{filteredServers.map((serverInfo) => (
|
||||||
<ServerCard key={serverInfo.id} serverInfo={serverInfo} />
|
<ServerCard key={serverInfo.id} serverInfo={serverInfo} />
|
||||||
|
@ -15,10 +15,10 @@ export default function ServerDetail() {
|
|||||||
const { id: server_id } = useParams();
|
const { id: server_id } = useParams();
|
||||||
|
|
||||||
if (!server_id) {
|
if (!server_id) {
|
||||||
navigate('/404');
|
navigate("/404");
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto w-full max-w-5xl px-0 flex flex-col gap-4">
|
<div className="mx-auto w-full max-w-5xl px-0 flex flex-col gap-4">
|
||||||
<ServerDetailOverview server_id={server_id} />
|
<ServerDetailOverview server_id={server_id} />
|
||||||
@ -34,10 +34,10 @@ export default function ServerDetail() {
|
|||||||
<Separator className="flex-1" />
|
<Separator className="flex-1" />
|
||||||
</section>
|
</section>
|
||||||
<div style={{ display: currentTab === tabs[0] ? "block" : "none" }}>
|
<div style={{ display: currentTab === tabs[0] ? "block" : "none" }}>
|
||||||
<ServerDetailChart server_id={server_id} />
|
<ServerDetailChart server_id={server_id} />
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: currentTab === tabs[1] ? "block" : "none" }}>
|
<div style={{ display: currentTab === tabs[1] ? "block" : "none" }}>
|
||||||
<NetworkChart
|
<NetworkChart
|
||||||
server_id={Number(server_id)}
|
server_id={Number(server_id)}
|
||||||
show={currentTab === tabs[1]}
|
show={currentTab === tabs[1]}
|
||||||
/>
|
/>
|
||||||
|
@ -71,7 +71,6 @@ export interface LoginUserResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface MonitorResponse {
|
export interface MonitorResponse {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data: NezhaMonitor[];
|
data: NezhaMonitor[];
|
||||||
@ -92,3 +91,39 @@ export interface NezhaMonitor {
|
|||||||
created_at: number[];
|
created_at: number[];
|
||||||
avg_delay: number[];
|
avg_delay: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServiceResponse {
|
||||||
|
success: boolean;
|
||||||
|
data: {
|
||||||
|
services: {
|
||||||
|
[key: string]: ServiceData;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Service {
|
||||||
|
// created_at: string;
|
||||||
|
// updated_at: string;
|
||||||
|
name: string;
|
||||||
|
// type: number;
|
||||||
|
// target: string;
|
||||||
|
// duration: number;
|
||||||
|
// notification_group_id: number;
|
||||||
|
// cover: number;
|
||||||
|
// fail_trigger_tasks: null | any[];
|
||||||
|
// recover_trigger_tasks: null | any[];
|
||||||
|
// min_latency: number;
|
||||||
|
// max_latency: number;
|
||||||
|
// skip_servers: null | any[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceData {
|
||||||
|
service: Service;
|
||||||
|
current_up: number;
|
||||||
|
current_down: number;
|
||||||
|
total_up: number;
|
||||||
|
total_down: number;
|
||||||
|
delay: number[];
|
||||||
|
up: number[];
|
||||||
|
down: number[];
|
||||||
|
}
|
||||||
|
@ -28,11 +28,15 @@ export default defineConfig({
|
|||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
output: {
|
output: {
|
||||||
manualChunks(id) {
|
manualChunks(id) {
|
||||||
if (id.includes('node_modules')) {
|
if (id.includes("node_modules")) {
|
||||||
return id.toString().split('node_modules/')[1].split('/')[0].toString();
|
return id
|
||||||
|
.toString()
|
||||||
|
.split("node_modules/")[1]
|
||||||
|
.split("/")[0]
|
||||||
|
.toString();
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
Loading…
x
Reference in New Issue
Block a user