mirror of
https://github.com/hamster1963/nezha-dash.git
synced 2025-04-24 21:10:45 +08:00
Merge branch 'main' into cloudflare
This commit is contained in:
commit
64a3c6cb97
185
app/[locale]/(main)/ClientComponents/NetworkChart.tsx
Normal file
185
app/[locale]/(main)/ClientComponents/NetworkChart.tsx
Normal file
@ -0,0 +1,185 @@
|
||||
"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";
|
||||
import NetworkChartLoading from "./NetworkChartLoading";
|
||||
|
||||
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 <NetworkChartLoading />;
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
30
app/[locale]/(main)/ClientComponents/NetworkChartLoading.tsx
Normal file
30
app/[locale]/(main)/ClientComponents/NetworkChartLoading.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { BackIcon } from "@/components/Icon";
|
||||
import { Loader } from "@/components/loading/Loader";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
|
||||
export default function NetworkChartLoading() {
|
||||
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-5">
|
||||
<CardTitle className="flex items-center gap-0.5 text-xl">
|
||||
<BackIcon />
|
||||
<Loader visible={true} />
|
||||
</CardTitle>
|
||||
<CardDescription className="text-xs opacity-0">
|
||||
loading...
|
||||
</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-2 sm:p-6">
|
||||
<div className="aspect-auto h-[250px] w-full flex-col items-center justify-center"></div>
|
||||
</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;
|
||||
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 ServerUsageBar from "@/components/ServerUsageBar";
|
||||
import { Card } from "@/components/ui/card";
|
||||
@ -12,6 +12,7 @@ import ServerCardPopover from "./ServerCardPopover";
|
||||
|
||||
import { env } from "next-runtime-env";
|
||||
import ServerFlag from "./ServerFlag";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function ServerCard({
|
||||
serverInfo,
|
||||
@ -19,11 +20,14 @@ export default function ServerCard({
|
||||
serverInfo: NezhaAPISafe;
|
||||
}) {
|
||||
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);
|
||||
|
||||
const showFlag = env("NEXT_PUBLIC_ShowFlag") === "true";
|
||||
|
||||
const locale = useLocale();
|
||||
|
||||
return online ? (
|
||||
<Card
|
||||
className={
|
||||
@ -49,7 +53,12 @@ export default function ServerCard({
|
||||
<ServerCardPopover status={props.status} host={props.host} />
|
||||
</PopoverContent>
|
||||
</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"}>
|
||||
<p className="text-xs text-muted-foreground">{t("CPU")}</p>
|
||||
<div className="flex items-center text-xs font-semibold">
|
||||
|
@ -78,9 +78,9 @@ CardFooter.displayName = "CardFooter";
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
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";
|
||||
|
||||
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 { unstable_noStore as noStore } from "next/cache";
|
||||
import getEnv from "./env-entry";
|
||||
@ -64,3 +69,61 @@ export async function GetNezhaData() {
|
||||
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);
|
||||
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",
|
||||
"Offline": "Offline"
|
||||
},
|
||||
"NetworkChartClient": {
|
||||
"avg_delay": "Latency"
|
||||
},
|
||||
"NetworkChart": {
|
||||
"ServerMonitorCount": "Services"
|
||||
},
|
||||
"ThemeSwitcher": {
|
||||
"Light": "Light",
|
||||
"Dark": "Dark",
|
||||
|
@ -28,6 +28,12 @@
|
||||
"Online": "オンライン時間",
|
||||
"Offline": "オフライン"
|
||||
},
|
||||
"NetworkChartClient": {
|
||||
"avg_delay": "遅延"
|
||||
},
|
||||
"NetworkChart": {
|
||||
"ServerMonitorCount": "サービス"
|
||||
},
|
||||
"ThemeSwitcher": {
|
||||
"Light": "ライト",
|
||||
"Dark": "ダーク",
|
||||
|
@ -28,6 +28,12 @@
|
||||
"Online": "在線時間",
|
||||
"Offline": "離線"
|
||||
},
|
||||
"NetworkChartClient": {
|
||||
"avg_delay": "延遲"
|
||||
},
|
||||
"NetworkChart": {
|
||||
"ServerMonitorCount": "個監測服務"
|
||||
},
|
||||
"ThemeSwitcher": {
|
||||
"Light": "亮色",
|
||||
"Dark": "暗色",
|
||||
|
@ -28,6 +28,12 @@
|
||||
"Online": "在线时间",
|
||||
"Offline": "离线"
|
||||
},
|
||||
"NetworkChartClient": {
|
||||
"avg_delay": "延迟"
|
||||
},
|
||||
"NetworkChart": {
|
||||
"ServerMonitorCount": "个监控服务"
|
||||
},
|
||||
"ThemeSwitcher": {
|
||||
"Light": "亮色",
|
||||
"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
|
||||
// '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 = {
|
||||
|
17
package.json
17
package.json
@ -21,17 +21,17 @@
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"@types/luxon": "^3.4.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.8.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.8.1",
|
||||
"caniuse-lite": "^1.0.30001667",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"country-flag-icons": "^1.5.13",
|
||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||
"flag-icons": "^7.2.3",
|
||||
"framer-motion": "^11.11.1",
|
||||
"lucide-react": "^0.414.0",
|
||||
"framer-motion": "^11.11.4",
|
||||
"lucide-react": "^0.451.0",
|
||||
"luxon": "^3.5.0",
|
||||
"next": "^14.2.14",
|
||||
"next": "^14.2.15",
|
||||
"next-intl": "^3.20.0",
|
||||
"next-runtime-env": "^3.2.2",
|
||||
"next-themes": "^0.3.0",
|
||||
@ -40,6 +40,7 @@
|
||||
"react-dom": "^18.3.1",
|
||||
"react-intersection-observer": "^9.13.1",
|
||||
"react-wrap-balancer": "^1.1.1",
|
||||
"recharts": "^2.12.7",
|
||||
"sharp": "^0.33.5",
|
||||
"swr": "^2.2.6-beta.4",
|
||||
"tailwind-merge": "^2.5.3",
|
||||
@ -48,17 +49,17 @@
|
||||
"devDependencies": {
|
||||
"eslint-plugin-turbo": "^2.1.3",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"@next/bundle-analyzer": "^14.2.14",
|
||||
"@types/node": "^22.7.4",
|
||||
"@next/bundle-analyzer": "^14.2.15",
|
||||
"@types/node": "^22.7.5",
|
||||
"@types/react": "^18.3.11",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.12.0",
|
||||
"eslint-config-next": "^14.2.14",
|
||||
"eslint-config-next": "^14.2.15",
|
||||
"postcss": "^8.4.47",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.8",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"typescript": "^5.6.2"
|
||||
"typescript": "^5.6.3"
|
||||
}
|
||||
}
|
||||
|
@ -24,6 +24,11 @@
|
||||
--input: 20 5.9% 90%;
|
||||
--ring: 20 14.3% 4.1%;
|
||||
--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 {
|
||||
@ -46,6 +51,11 @@
|
||||
--border: 12 6.5% 15.1%;
|
||||
--input: 12 6.5% 15.1%;
|
||||
--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