mirror of
https://github.com/hamster1963/nezha-dash.git
synced 2025-04-24 21:10:45 +08:00
feat: add network chart
This commit is contained in:
parent
439565d60b
commit
d870c494e6
183
app/[locale]/(main)/ClientComponents/NetworkChart.tsx
Normal file
183
app/[locale]/(main)/ClientComponents/NetworkChart.tsx
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
ChartConfig,
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
} from "@/components/ui/chart";
|
||||||
|
import useSWR from "swr";
|
||||||
|
import { ServerMonitorChart } from "../../types/nezha-api";
|
||||||
|
import { formatTime, nezhaFetcher } from "@/lib/utils";
|
||||||
|
import { formatRelativeTime } from "@/lib/utils";
|
||||||
|
import { BackIcon } from "@/components/Icon";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useLocale } from "next-intl";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
export function NetworkChartClient({ server_id }: { server_id: number }) {
|
||||||
|
const t = useTranslations("NetworkChartClient");
|
||||||
|
const { data, error } = useSWR<ServerMonitorChart>(
|
||||||
|
`/api/monitor?server_id=${server_id}`,
|
||||||
|
nezhaFetcher,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error)
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
<p className="text-sm font-medium opacity-40">{error.message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (!data) return null;
|
||||||
|
|
||||||
|
const initChartConfig = {
|
||||||
|
avg_delay: {
|
||||||
|
label: t("avg_delay"),
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
const chartDataKey = Object.keys(data);
|
||||||
|
|
||||||
|
const generateChartConfig = chartDataKey.reduce((config, key, index) => {
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
[key]: {
|
||||||
|
label: key,
|
||||||
|
color: `hsl(var(--chart-${index + 1}))`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, {} as ChartConfig);
|
||||||
|
|
||||||
|
const chartConfig = { ...initChartConfig, ...generateChartConfig };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NetworkChart
|
||||||
|
chartDataKey={chartDataKey}
|
||||||
|
chartConfig={chartConfig}
|
||||||
|
chartData={data}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetworkChart({
|
||||||
|
chartDataKey,
|
||||||
|
chartConfig,
|
||||||
|
chartData,
|
||||||
|
}: {
|
||||||
|
chartDataKey: string[];
|
||||||
|
chartConfig: ChartConfig;
|
||||||
|
chartData: ServerMonitorChart;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("NetworkChart");
|
||||||
|
const router = useRouter();
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
|
const [activeChart, setActiveChart] = React.useState<
|
||||||
|
keyof typeof chartConfig
|
||||||
|
>(chartDataKey[0]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
|
||||||
|
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-4">
|
||||||
|
<CardTitle
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/${locale}/`);
|
||||||
|
}}
|
||||||
|
className="flex cursor-pointer items-center gap-0.5 text-xl"
|
||||||
|
>
|
||||||
|
<BackIcon />
|
||||||
|
{chartData[chartDataKey[0]][0].server_name}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="text-xs">
|
||||||
|
{chartDataKey.length} {t("ServerMonitorCount")}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex">
|
||||||
|
{chartDataKey.map((key, index) => {
|
||||||
|
const chart = key as keyof typeof chartConfig;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
data-active={activeChart === key}
|
||||||
|
className={`relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left data-[active=true]:bg-muted/50 sm:border-l sm:border-t-0 sm:px-6 ${index !== 0 ? "border-l" : ""} `}
|
||||||
|
onClick={() => setActiveChart(key as keyof typeof chartConfig)}
|
||||||
|
>
|
||||||
|
<span className="whitespace-nowrap text-xs text-muted-foreground">
|
||||||
|
{chart}
|
||||||
|
</span>
|
||||||
|
<span className="text-md font-bold leading-none sm:text-lg">
|
||||||
|
{chartData[key][0].avg_delay.toFixed(2)}ms
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-2 sm:p-6">
|
||||||
|
<ChartContainer
|
||||||
|
config={chartConfig}
|
||||||
|
className="aspect-auto h-[250px] w-full"
|
||||||
|
>
|
||||||
|
<LineChart
|
||||||
|
accessibilityLayer
|
||||||
|
data={chartData[activeChart]}
|
||||||
|
margin={{
|
||||||
|
left: 12,
|
||||||
|
right: 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CartesianGrid vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="created_at"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
tickFormatter={(value) => formatRelativeTime(value)}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
dataKey="avg_delay"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
mirror={true}
|
||||||
|
tickMargin={-15}
|
||||||
|
minTickGap={20}
|
||||||
|
tickFormatter={(value) => `${value}ms`}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
indicator={"dot"}
|
||||||
|
className="w-fit"
|
||||||
|
nameKey="avg_delay"
|
||||||
|
labelKey="created_at"
|
||||||
|
labelClassName="text-muted-foreground"
|
||||||
|
labelFormatter={(_, payload) => {
|
||||||
|
return formatTime(payload[0].payload.created_at);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
isAnimationActive={false}
|
||||||
|
strokeWidth={2}
|
||||||
|
type="linear"
|
||||||
|
dot={false}
|
||||||
|
dataKey="avg_delay"
|
||||||
|
stroke={`var(--color-${activeChart})`}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
9
app/[locale]/(main)/[id]/page.tsx
Normal file
9
app/[locale]/(main)/[id]/page.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { NetworkChartClient } from "../ClientComponents/NetworkChart";
|
||||||
|
|
||||||
|
export default function Page({ params }: { params: { id: string } }) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto grid w-full max-w-5xl gap-4 md:gap-6">
|
||||||
|
<NetworkChartClient server_id={Number(params.id)} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -55,3 +55,20 @@ export interface NezhaAPIStatus {
|
|||||||
Temperatures: number;
|
Temperatures: number;
|
||||||
GPU: number;
|
GPU: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ServerMonitorChart = {
|
||||||
|
[key: string]: {
|
||||||
|
server_name: string;
|
||||||
|
created_at: number;
|
||||||
|
avg_delay: number;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface NezhaAPIMonitor {
|
||||||
|
monitor_id: number;
|
||||||
|
monitor_name: string;
|
||||||
|
server_id: number;
|
||||||
|
server_name: string;
|
||||||
|
created_at: number[];
|
||||||
|
avg_delay: number[];
|
||||||
|
}
|
||||||
|
27
app/api/monitor/route.ts
Normal file
27
app/api/monitor/route.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { ServerMonitorChart } from "@/app/[locale]/types/nezha-api";
|
||||||
|
import { GetServerMonitor } from "@/lib/serverFetch";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
interface NezhaDataResponse {
|
||||||
|
error?: string;
|
||||||
|
data?: ServerMonitorChart;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const server_id = searchParams.get("server_id");
|
||||||
|
if (!server_id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "server_id is required" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const response = (await GetServerMonitor({
|
||||||
|
server_id: parseInt(server_id),
|
||||||
|
})) as NezhaDataResponse;
|
||||||
|
if (response.error) {
|
||||||
|
console.log(response.error);
|
||||||
|
return NextResponse.json({ error: response.error }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(response, { status: 200 });
|
||||||
|
}
|
File diff suppressed because one or more lines are too long
@ -1,4 +1,4 @@
|
|||||||
import { useTranslations } from "next-intl";
|
import { useLocale, useTranslations } from "next-intl";
|
||||||
import { NezhaAPISafe } from "../app/[locale]/types/nezha-api";
|
import { NezhaAPISafe } from "../app/[locale]/types/nezha-api";
|
||||||
import ServerUsageBar from "@/components/ServerUsageBar";
|
import ServerUsageBar from "@/components/ServerUsageBar";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
@ -12,6 +12,7 @@ import ServerCardPopover from "./ServerCardPopover";
|
|||||||
|
|
||||||
import { env } from "next-runtime-env";
|
import { env } from "next-runtime-env";
|
||||||
import ServerFlag from "./ServerFlag";
|
import ServerFlag from "./ServerFlag";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
export default function ServerCard({
|
export default function ServerCard({
|
||||||
serverInfo,
|
serverInfo,
|
||||||
@ -19,11 +20,14 @@ export default function ServerCard({
|
|||||||
serverInfo: NezhaAPISafe;
|
serverInfo: NezhaAPISafe;
|
||||||
}) {
|
}) {
|
||||||
const t = useTranslations("ServerCard");
|
const t = useTranslations("ServerCard");
|
||||||
const { name, country_code, online, cpu, up, down, mem, stg, ...props } =
|
const router = useRouter();
|
||||||
|
const { id, name, country_code, online, cpu, up, down, mem, stg, ...props } =
|
||||||
formatNezhaInfo(serverInfo);
|
formatNezhaInfo(serverInfo);
|
||||||
|
|
||||||
const showFlag = env("NEXT_PUBLIC_ShowFlag") === "true";
|
const showFlag = env("NEXT_PUBLIC_ShowFlag") === "true";
|
||||||
|
|
||||||
|
const locale = useLocale();
|
||||||
|
|
||||||
return online ? (
|
return online ? (
|
||||||
<Card
|
<Card
|
||||||
className={
|
className={
|
||||||
@ -49,7 +53,12 @@ export default function ServerCard({
|
|||||||
<ServerCardPopover status={props.status} host={props.host} />
|
<ServerCardPopover status={props.status} host={props.host} />
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<section className={"grid grid-cols-5 items-center gap-3"}>
|
<section
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/${locale}/${id}`);
|
||||||
|
}}
|
||||||
|
className={"grid cursor-pointer grid-cols-5 items-center gap-3"}
|
||||||
|
>
|
||||||
<div className={"flex w-14 flex-col"}>
|
<div className={"flex w-14 flex-col"}>
|
||||||
<p className="text-xs text-muted-foreground">{t("CPU")}</p>
|
<p className="text-xs text-muted-foreground">{t("CPU")}</p>
|
||||||
<div className="flex items-center text-xs font-semibold">
|
<div className="flex items-center text-xs font-semibold">
|
||||||
|
@ -78,9 +78,9 @@ CardFooter.displayName = "CardFooter";
|
|||||||
|
|
||||||
export {
|
export {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
};
|
};
|
||||||
|
365
components/ui/chart.tsx
Normal file
365
components/ui/chart.tsx
Normal file
@ -0,0 +1,365 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as RechartsPrimitive from "recharts";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||||
|
const THEMES = { light: "", dark: ".dark" } as const;
|
||||||
|
|
||||||
|
export type ChartConfig = {
|
||||||
|
[k in string]: {
|
||||||
|
label?: React.ReactNode;
|
||||||
|
icon?: React.ComponentType;
|
||||||
|
} & (
|
||||||
|
| { color?: string; theme?: never }
|
||||||
|
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ChartContextProps = {
|
||||||
|
config: ChartConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartContext = React.createContext<ChartContextProps | null>(null);
|
||||||
|
|
||||||
|
function useChart() {
|
||||||
|
const context = React.useContext(ChartContext);
|
||||||
|
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useChart must be used within a <ChartContainer />");
|
||||||
|
}
|
||||||
|
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChartContainer = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
config: ChartConfig;
|
||||||
|
children: React.ComponentProps<
|
||||||
|
typeof RechartsPrimitive.ResponsiveContainer
|
||||||
|
>["children"];
|
||||||
|
}
|
||||||
|
>(({ id, className, children, config, ...props }, ref) => {
|
||||||
|
const uniqueId = React.useId();
|
||||||
|
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChartContext.Provider value={{ config }}>
|
||||||
|
<div
|
||||||
|
data-chart={chartId}
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChartStyle id={chartId} config={config} />
|
||||||
|
<RechartsPrimitive.ResponsiveContainer>
|
||||||
|
{children}
|
||||||
|
</RechartsPrimitive.ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</ChartContext.Provider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
ChartContainer.displayName = "Chart";
|
||||||
|
|
||||||
|
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||||
|
const colorConfig = Object.entries(config).filter(
|
||||||
|
([_, config]) => config.theme || config.color,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!colorConfig.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<style
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: Object.entries(THEMES)
|
||||||
|
.map(
|
||||||
|
([theme, prefix]) => `
|
||||||
|
${prefix} [data-chart=${id}] {
|
||||||
|
${colorConfig
|
||||||
|
.map(([key, itemConfig]) => {
|
||||||
|
const color =
|
||||||
|
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||||
|
itemConfig.color;
|
||||||
|
return color ? ` --color-${key}: ${color};` : null;
|
||||||
|
})
|
||||||
|
.join("\n")}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("\n"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||||
|
|
||||||
|
const ChartTooltipContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||||
|
React.ComponentProps<"div"> & {
|
||||||
|
hideLabel?: boolean;
|
||||||
|
hideIndicator?: boolean;
|
||||||
|
indicator?: "line" | "dot" | "dashed";
|
||||||
|
nameKey?: string;
|
||||||
|
labelKey?: string;
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{
|
||||||
|
active,
|
||||||
|
payload,
|
||||||
|
className,
|
||||||
|
indicator = "dot",
|
||||||
|
hideLabel = false,
|
||||||
|
hideIndicator = false,
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
labelClassName,
|
||||||
|
formatter,
|
||||||
|
color,
|
||||||
|
nameKey,
|
||||||
|
labelKey,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
const tooltipLabel = React.useMemo(() => {
|
||||||
|
if (hideLabel || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [item] = payload;
|
||||||
|
const key = `${labelKey || item.dataKey || item.name || "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const value =
|
||||||
|
!labelKey && typeof label === "string"
|
||||||
|
? config[label as keyof typeof config]?.label || label
|
||||||
|
: itemConfig?.label;
|
||||||
|
|
||||||
|
if (labelFormatter) {
|
||||||
|
return (
|
||||||
|
<div className={cn("font-medium", labelClassName)}>
|
||||||
|
{labelFormatter(value, payload)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
|
||||||
|
}, [
|
||||||
|
label,
|
||||||
|
labelFormatter,
|
||||||
|
payload,
|
||||||
|
hideLabel,
|
||||||
|
labelClassName,
|
||||||
|
config,
|
||||||
|
labelKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!active || !payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nestLabel = payload.length === 1 && indicator !== "dot";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!nestLabel ? tooltipLabel : null}
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{payload.map((item, index) => {
|
||||||
|
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
const indicatorColor = color || item.payload.fill || item.color;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.dataKey}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||||
|
indicator === "dot" && "items-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatter && item?.value !== undefined && item.name ? (
|
||||||
|
formatter(item.value, item.name, item, index, item.payload)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{itemConfig?.icon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
!hideIndicator && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
|
||||||
|
{
|
||||||
|
"h-2.5 w-2.5": indicator === "dot",
|
||||||
|
"w-1": indicator === "line",
|
||||||
|
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||||
|
indicator === "dashed",
|
||||||
|
"my-0.5": nestLabel && indicator === "dashed",
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--color-bg": indicatorColor,
|
||||||
|
"--color-border": indicatorColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex flex-1 justify-between leading-none",
|
||||||
|
nestLabel ? "items-end" : "items-center",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="grid gap-1.5">
|
||||||
|
{nestLabel ? tooltipLabel : null}
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{itemConfig?.label || item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{item.value && (
|
||||||
|
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||||
|
{item.value.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ChartTooltipContent.displayName = "ChartTooltip";
|
||||||
|
|
||||||
|
const ChartLegend = RechartsPrimitive.Legend;
|
||||||
|
|
||||||
|
const ChartLegendContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.ComponentProps<"div"> &
|
||||||
|
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||||
|
hideIcon?: boolean;
|
||||||
|
nameKey?: string;
|
||||||
|
}
|
||||||
|
>(
|
||||||
|
(
|
||||||
|
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
|
const { config } = useChart();
|
||||||
|
|
||||||
|
if (!payload?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center justify-center gap-4",
|
||||||
|
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{payload.map((item) => {
|
||||||
|
const key = `${nameKey || item.dataKey || "value"}`;
|
||||||
|
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.value}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.icon && !hideIcon ? (
|
||||||
|
<itemConfig.icon />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||||
|
style={{
|
||||||
|
backgroundColor: item.color,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{itemConfig?.label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ChartLegendContent.displayName = "ChartLegend";
|
||||||
|
|
||||||
|
// Helper to extract item config from a payload.
|
||||||
|
function getPayloadConfigFromPayload(
|
||||||
|
config: ChartConfig,
|
||||||
|
payload: unknown,
|
||||||
|
key: string,
|
||||||
|
) {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadPayload =
|
||||||
|
"payload" in payload &&
|
||||||
|
typeof payload.payload === "object" &&
|
||||||
|
payload.payload !== null
|
||||||
|
? payload.payload
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
let configLabelKey: string = key;
|
||||||
|
|
||||||
|
if (
|
||||||
|
key in payload &&
|
||||||
|
typeof payload[key as keyof typeof payload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payload[key as keyof typeof payload] as string;
|
||||||
|
} else if (
|
||||||
|
payloadPayload &&
|
||||||
|
key in payloadPayload &&
|
||||||
|
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||||
|
) {
|
||||||
|
configLabelKey = payloadPayload[
|
||||||
|
key as keyof typeof payloadPayload
|
||||||
|
] as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
return configLabelKey in config
|
||||||
|
? config[configLabelKey]
|
||||||
|
: config[key as keyof typeof config];
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartStyle,
|
||||||
|
};
|
@ -1,6 +1,11 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { NezhaAPI, ServerApi } from "../app/[locale]/types/nezha-api";
|
import {
|
||||||
|
NezhaAPI,
|
||||||
|
NezhaAPIMonitor,
|
||||||
|
ServerApi,
|
||||||
|
ServerMonitorChart,
|
||||||
|
} from "../app/[locale]/types/nezha-api";
|
||||||
import { MakeOptional } from "../app/[locale]/types/utils";
|
import { MakeOptional } from "../app/[locale]/types/utils";
|
||||||
import { unstable_noStore as noStore } from "next/cache";
|
import { unstable_noStore as noStore } from "next/cache";
|
||||||
import getEnv from "./env-entry";
|
import getEnv from "./env-entry";
|
||||||
@ -64,3 +69,61 @@ export async function GetNezhaData() {
|
|||||||
return error;
|
return error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function GetServerMonitor({ server_id }: { server_id: number }) {
|
||||||
|
function transformData(data: NezhaAPIMonitor[]) {
|
||||||
|
const monitorData: ServerMonitorChart = {};
|
||||||
|
|
||||||
|
data.forEach((item) => {
|
||||||
|
const monitorName = item.monitor_name;
|
||||||
|
|
||||||
|
if (!monitorData[monitorName]) {
|
||||||
|
monitorData[monitorName] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < item.created_at.length; i++) {
|
||||||
|
monitorData[monitorName].push({
|
||||||
|
server_name: item.server_name,
|
||||||
|
created_at: item.created_at[i],
|
||||||
|
avg_delay: item.avg_delay[i],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return monitorData;
|
||||||
|
}
|
||||||
|
|
||||||
|
var nezhaBaseUrl = getEnv("NezhaBaseUrl");
|
||||||
|
if (!nezhaBaseUrl) {
|
||||||
|
console.log("NezhaBaseUrl is not set");
|
||||||
|
return { error: "NezhaBaseUrl is not set" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove trailing slash
|
||||||
|
if (nezhaBaseUrl[nezhaBaseUrl.length - 1] === "/") {
|
||||||
|
nezhaBaseUrl = nezhaBaseUrl.slice(0, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
nezhaBaseUrl + `/api/v1/monitor/${server_id}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: getEnv("NezhaAuth") as string,
|
||||||
|
},
|
||||||
|
next: {
|
||||||
|
revalidate: 30,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const resData = await response.json();
|
||||||
|
const monitorData = resData.result;
|
||||||
|
if (!monitorData) {
|
||||||
|
console.log(resData);
|
||||||
|
return { error: "MonitorData fetch failed" };
|
||||||
|
}
|
||||||
|
return transformData(monitorData);
|
||||||
|
} catch (error) {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
29
lib/utils.ts
29
lib/utils.ts
@ -79,3 +79,32 @@ export const nezhaFetcher = (url: string) =>
|
|||||||
console.error(err);
|
console.error(err);
|
||||||
throw err;
|
throw err;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export function formatRelativeTime(timestamp: number): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - timestamp;
|
||||||
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||||
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
|
||||||
|
if (hours > 24) {
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}天前`;
|
||||||
|
} else if (hours > 0) {
|
||||||
|
return `${hours}小时前`;
|
||||||
|
} else if (minutes >= 0) {
|
||||||
|
return `${minutes}分钟前`;
|
||||||
|
} else {
|
||||||
|
return "刚刚";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(timestamp: number): string {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = date.getMonth() + 1;
|
||||||
|
const day = date.getDate();
|
||||||
|
const hours = date.getHours();
|
||||||
|
const minutes = date.getMinutes().toString().padStart(2, "0");
|
||||||
|
const seconds = date.getSeconds().toString().padStart(2, "0");
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
|
}
|
||||||
|
@ -28,6 +28,12 @@
|
|||||||
"Online": "Online",
|
"Online": "Online",
|
||||||
"Offline": "Offline"
|
"Offline": "Offline"
|
||||||
},
|
},
|
||||||
|
"NetworkChartClient": {
|
||||||
|
"avg_delay": "Latency"
|
||||||
|
},
|
||||||
|
"NetworkChart": {
|
||||||
|
"ServerMonitorCount": "Services"
|
||||||
|
},
|
||||||
"ThemeSwitcher": {
|
"ThemeSwitcher": {
|
||||||
"Light": "Light",
|
"Light": "Light",
|
||||||
"Dark": "Dark",
|
"Dark": "Dark",
|
||||||
|
@ -28,6 +28,12 @@
|
|||||||
"Online": "オンライン時間",
|
"Online": "オンライン時間",
|
||||||
"Offline": "オフライン"
|
"Offline": "オフライン"
|
||||||
},
|
},
|
||||||
|
"NetworkChartClient": {
|
||||||
|
"avg_delay": "遅延"
|
||||||
|
},
|
||||||
|
"NetworkChart": {
|
||||||
|
"ServerMonitorCount": "サービス"
|
||||||
|
},
|
||||||
"ThemeSwitcher": {
|
"ThemeSwitcher": {
|
||||||
"Light": "ライト",
|
"Light": "ライト",
|
||||||
"Dark": "ダーク",
|
"Dark": "ダーク",
|
||||||
|
@ -28,6 +28,12 @@
|
|||||||
"Online": "在線時間",
|
"Online": "在線時間",
|
||||||
"Offline": "離線"
|
"Offline": "離線"
|
||||||
},
|
},
|
||||||
|
"NetworkChartClient": {
|
||||||
|
"avg_delay": "延遲"
|
||||||
|
},
|
||||||
|
"NetworkChart": {
|
||||||
|
"ServerMonitorCount": "個監測服務"
|
||||||
|
},
|
||||||
"ThemeSwitcher": {
|
"ThemeSwitcher": {
|
||||||
"Light": "亮色",
|
"Light": "亮色",
|
||||||
"Dark": "暗色",
|
"Dark": "暗色",
|
||||||
|
@ -28,6 +28,12 @@
|
|||||||
"Online": "在线时间",
|
"Online": "在线时间",
|
||||||
"Offline": "离线"
|
"Offline": "离线"
|
||||||
},
|
},
|
||||||
|
"NetworkChartClient": {
|
||||||
|
"avg_delay": "延迟"
|
||||||
|
},
|
||||||
|
"NetworkChart": {
|
||||||
|
"ServerMonitorCount": "个监控服务"
|
||||||
|
},
|
||||||
"ThemeSwitcher": {
|
"ThemeSwitcher": {
|
||||||
"Light": "亮色",
|
"Light": "亮色",
|
||||||
"Dark": "暗色",
|
"Dark": "暗色",
|
||||||
|
@ -12,7 +12,7 @@ export default createMiddleware({
|
|||||||
|
|
||||||
// 'always': This is the default, The home page will also be redirected to the default language, such as www.abc.com to www.abc.com/en
|
// 'always': This is the default, The home page will also be redirected to the default language, such as www.abc.com to www.abc.com/en
|
||||||
// 'as-needed': The default page is not redirected. For example, if you open www.abc.com, it is still www.abc.com
|
// 'as-needed': The default page is not redirected. For example, if you open www.abc.com, it is still www.abc.com
|
||||||
localePrefix: "as-needed",
|
localePrefix: "always",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
17
package.json
17
package.json
@ -21,17 +21,17 @@
|
|||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.0",
|
||||||
"@radix-ui/react-tooltip": "^1.1.3",
|
"@radix-ui/react-tooltip": "^1.1.3",
|
||||||
"@types/luxon": "^3.4.2",
|
"@types/luxon": "^3.4.2",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.8.0",
|
"@typescript-eslint/eslint-plugin": "^8.8.1",
|
||||||
"caniuse-lite": "^1.0.30001667",
|
"caniuse-lite": "^1.0.30001667",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"country-flag-icons": "^1.5.13",
|
"country-flag-icons": "^1.5.13",
|
||||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||||
"flag-icons": "^7.2.3",
|
"flag-icons": "^7.2.3",
|
||||||
"framer-motion": "^11.11.1",
|
"framer-motion": "^11.11.4",
|
||||||
"lucide-react": "^0.414.0",
|
"lucide-react": "^0.451.0",
|
||||||
"luxon": "^3.5.0",
|
"luxon": "^3.5.0",
|
||||||
"next": "^14.2.14",
|
"next": "^14.2.15",
|
||||||
"next-intl": "^3.20.0",
|
"next-intl": "^3.20.0",
|
||||||
"next-runtime-env": "^3.2.2",
|
"next-runtime-env": "^3.2.2",
|
||||||
"next-themes": "^0.3.0",
|
"next-themes": "^0.3.0",
|
||||||
@ -40,6 +40,7 @@
|
|||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-intersection-observer": "^9.13.1",
|
"react-intersection-observer": "^9.13.1",
|
||||||
"react-wrap-balancer": "^1.1.1",
|
"react-wrap-balancer": "^1.1.1",
|
||||||
|
"recharts": "^2.12.7",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.5",
|
||||||
"swr": "^2.2.6-beta.4",
|
"swr": "^2.2.6-beta.4",
|
||||||
"tailwind-merge": "^2.5.3",
|
"tailwind-merge": "^2.5.3",
|
||||||
@ -48,17 +49,17 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint-plugin-turbo": "^2.1.3",
|
"eslint-plugin-turbo": "^2.1.3",
|
||||||
"eslint-plugin-unused-imports": "^4.1.4",
|
"eslint-plugin-unused-imports": "^4.1.4",
|
||||||
"@next/bundle-analyzer": "^14.2.14",
|
"@next/bundle-analyzer": "^14.2.15",
|
||||||
"@types/node": "^22.7.4",
|
"@types/node": "^22.7.5",
|
||||||
"@types/react": "^18.3.11",
|
"@types/react": "^18.3.11",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^9.12.0",
|
"eslint": "^9.12.0",
|
||||||
"eslint-config-next": "^14.2.14",
|
"eslint-config-next": "^14.2.15",
|
||||||
"postcss": "^8.4.47",
|
"postcss": "^8.4.47",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.8",
|
"prettier-plugin-tailwindcss": "^0.6.8",
|
||||||
"tailwindcss": "^3.4.13",
|
"tailwindcss": "^3.4.13",
|
||||||
"typescript": "^5.6.2"
|
"typescript": "^5.6.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,6 +24,11 @@
|
|||||||
--input: 20 5.9% 90%;
|
--input: 20 5.9% 90%;
|
||||||
--ring: 20 14.3% 4.1%;
|
--ring: 20 14.3% 4.1%;
|
||||||
--radius: 1rem;
|
--radius: 1rem;
|
||||||
|
--chart-1: 173 58% 39%;
|
||||||
|
--chart-2: 12 76% 61%;
|
||||||
|
--chart-3: 197 37% 24%;
|
||||||
|
--chart-4: 43 74% 66%;
|
||||||
|
--chart-5: 27 87% 67%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
@ -46,6 +51,11 @@
|
|||||||
--border: 12 6.5% 15.1%;
|
--border: 12 6.5% 15.1%;
|
||||||
--input: 12 6.5% 15.1%;
|
--input: 12 6.5% 15.1%;
|
||||||
--ring: 24 5.7% 82.9%;
|
--ring: 24 5.7% 82.9%;
|
||||||
|
--chart-1: 220 70% 50%;
|
||||||
|
--chart-5: 160 60% 45%;
|
||||||
|
--chart-3: 30 80% 55%;
|
||||||
|
--chart-4: 280 65% 60%;
|
||||||
|
--chart-2: 340 75% 55%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user