feat: add detail card

This commit is contained in:
hamster1963 2024-10-18 16:46:29 +08:00
parent 7ce2415d75
commit d7f36ce144
7 changed files with 369 additions and 86 deletions

View File

@ -1,26 +1,44 @@
"use client"; "use client";
import useSWR from "swr";
import { NezhaAPISafe } from "@/app/[locale]/types/nezha-api"; import { NezhaAPISafe } from "@/app/[locale]/types/nezha-api";
import { nezhaFetcher } from "@/lib/utils";
import getEnv from "@/lib/env-entry";
import { useRouter } from "next/navigation";
import { useLocale } from "next-intl";
import { BackIcon } from "@/components/Icon"; import { BackIcon } from "@/components/Icon";
import { Card, CardContent } from "@/components/ui/card"; import AnimatedCircularProgressBar from "@/components/ui/animated-circular-progress-bar";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import getEnv from "@/lib/env-entry";
import { cn, nezhaFetcher } from "@/lib/utils";
import { useLocale } from "next-intl";
import { useRouter } from "next/navigation";
import useSWR from "swr";
export default function ServerDetailClient({ server_id }: { server_id: number }) { export default function ServerDetailClient({
server_id,
}: {
server_id: number;
}) {
const router = useRouter(); const router = useRouter();
const locale = useLocale(); const locale = useLocale();
const { data, error } = useSWR<NezhaAPISafe>( const { data, error } = useSWR<NezhaAPISafe>(
`/api/detail?server_id=${server_id}`, `/api/detail?server_id=${server_id}`,
nezhaFetcher, nezhaFetcher,
{ {
refreshInterval: refreshInterval: Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 5000,
Number(getEnv("NEXT_PUBLIC_NezhaFetchInterval")) || 5000,
}, },
); );
if (error) {
return (
<>
<div className="flex flex-col items-center justify-center">
<p className="text-sm font-medium opacity-40">{error.message}</p>
<p className="text-sm font-medium opacity-40">
{/* {t("chart_fetch_error_message")} */}
fetch_error_message
</p>
</div>
</>
);
}
if (!data) return null;
return ( return (
<div className="mx-auto grid w-full max-w-5xl gap-1"> <div className="mx-auto grid w-full max-w-5xl gap-1">
<div <div
@ -30,33 +48,196 @@ export default function ServerDetailClient({ server_id }: { server_id: number })
className="flex flex-none cursor-pointer font-semibold leading-none items-center break-all tracking-tight gap-0.5 text-xl" className="flex flex-none cursor-pointer font-semibold leading-none items-center break-all tracking-tight gap-0.5 text-xl"
> >
<BackIcon /> <BackIcon />
HomeDash {data?.name}
</div> </div>
<section className="flex flex-wrap gap-4 mt-2"> <section className="flex flex-wrap gap-2 mt-2">
<Card className="rounded-[10px] flex flex-col justify-center"> <Card className="rounded-[10px]">
<CardContent className="px-1.5 py-1"> <CardContent className="px-1.5 py-1">
<section className="flex items-center gap-2"> <section className="flex items-center gap-2">
<p className="text-xs font-semibold"> <p className="text-xs font-semibold">Status</p>
ID <Badge
</p> className={cn(
<div className="text-xs w-fit"> {server_id} </div> "text-xs rounded-[6px] w-fit px-1 py-0 dark:text-white",
{
" bg-green-800": data?.online_status,
" bg-red-600": !data?.online_status,
},
)}
>
{data?.online_status ? "Online" : "Offline"}
</Badge>
</section> </section>
</CardContent> </CardContent>
</Card> </Card>
<Card className="rounded-[10px]"> <Card className="rounded-[10px]">
<CardContent className="px-1.5 py-1"> <CardContent className="px-1.5 py-1">
<section className="flex items-center gap-2"> <section className="flex items-center gap-2">
<p className="text-xs font-semibold"> <p className="text-xs font-semibold">Uptime</p>
Tag <Badge
className="text-xs rounded-[6px] w-fit px-1 py-0"
variant="secondary"
>
{" "}
{(data?.status.Uptime / 86400).toFixed(0)} Days{" "}
</Badge>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px]">
<CardContent className="px-1.5 py-1">
<section className="flex items-center gap-2">
<p className="text-xs font-semibold">Tag</p>
<Badge
className="text-xs rounded-[6px] w-fit px-1 py-0"
variant="secondary"
>
{" "}
{data?.tag || "Unknown"}{" "}
</Badge>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px]">
<CardContent className="px-1.5 py-1">
<section className="flex items-center gap-2">
<p className="text-xs font-semibold">Arch</p>
<Badge
className="text-xs rounded-[6px] w-fit px-1 py-0"
variant="secondary"
>
{" "}
{data?.host.Arch || "Unknown"}{" "}
</Badge>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px]">
<CardContent className="px-1.5 py-1">
<section className="flex items-center gap-2">
<p className="text-xs font-semibold">Version</p>
<Badge
className="text-xs rounded-[6px] w-fit px-1 py-0"
variant="secondary"
>
{" "}
{data?.host.Version || "Unknown"}{" "}
</Badge>
</section>
</CardContent>
</Card>
</section>
<section className="flex flex-wrap gap-2 mt-1">
<Card className="rounded-[10px] flex flex-col justify-center">
<CardContent className="px-1.5 py-1">
<section className="flex items-center gap-2">
<p className="text-xs font-semibold">System</p>
{data?.host.Platform ? (
<div className="text-xs w-fit">
{" "}
{data?.host.Platform || "Unknown"} -{" "}
{data?.host.PlatformVersion}{" "}
</div>
) : (
<div className="text-xs w-fit"> Unknown </div>
)}
</section>
</CardContent>
</Card>
<Card className="rounded-[10px] flex flex-col justify-center">
<CardContent className="px-1.5 py-1">
<section className="flex items-center gap-2">
<p className="text-xs font-semibold">CPU</p>
{data?.host.CPU ? (
<div className="text-xs w-fit">
{" "}
{data?.host.CPU || "Unknown"}
</div>
) : (
<div className="text-xs w-fit"> Unknown </div>
)}
</section>
</CardContent>
</Card>
</section>
<section className="flex flex-wrap gap-2 mt-1">
<Card className="rounded-[10px]">
<CardContent className="px-1.5 py-1">
<section className="flex items-center gap-2">
<p className="text-xs font-semibold">CPU</p>
<p className="text-xs text-end w-10 font-medium">
{data?.status.CPU.toFixed(0)}%
</p> </p>
<Badge className="text-xs rounded-[6px] w-fit px-1 py-0" variant="secondary"> {data?.tag} </Badge> <AnimatedCircularProgressBar
className="size-3 text-[0px]"
max={100}
min={0}
value={data?.status.CPU}
/>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px]">
<CardContent className="px-1.5 py-1">
<section className="flex items-center gap-2">
<p className="text-xs font-semibold">Mem</p>
<p className="text-xs w-10 text-end font-medium">
{((data?.status.MemUsed / data?.host.MemTotal) * 100).toFixed(
0,
)}
%
</p>
<AnimatedCircularProgressBar
className="size-3 text-[0px]"
max={100}
min={0}
value={(data?.status.MemUsed / data?.host.MemTotal) * 100}
/>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px]">
<CardContent className="px-1.5 py-1">
<section className="flex items-center gap-2">
<p className="text-xs font-semibold">Swap</p>
<p className="text-xs w-10 text-end font-medium">
{data?.status.SwapUsed
? (
(data?.status.SwapUsed / data?.host.SwapTotal) *
100
).toFixed(0)
: 0}
%
</p>
<AnimatedCircularProgressBar
className="size-3 text-[0px]"
max={100}
min={0}
value={(data?.status.SwapUsed / data?.host.SwapTotal) * 100}
/>
</section>
</CardContent>
</Card>
<Card className="rounded-[10px]">
<CardContent className="px-1.5 py-1">
<section className="flex items-center gap-2">
<p className="text-xs font-semibold">Disk</p>
<p className="text-xs w-10 text-end font-medium">
{((data?.status.DiskUsed / data?.host.DiskTotal) * 100).toFixed(
0,
)}
%
</p>
<AnimatedCircularProgressBar
className="size-3 text-[0px]"
max={100}
min={0}
value={(data?.status.DiskUsed / data?.host.DiskTotal) * 100}
/>
</section> </section>
</CardContent> </CardContent>
</Card> </Card>
</section> </section>
</div> </div>
) );
} }

View File

@ -1,7 +1,5 @@
import ServerDetailClient from "@/app/[locale]/(main)/ClientComponents/ServerDetailClient"; import ServerDetailClient from "@/app/[locale]/(main)/ClientComponents/ServerDetailClient";
export default function Page({ params }: { params: { id: string } }) { export default function Page({ params }: { params: { id: string } }) {
return ( return <ServerDetailClient server_id={Number(params.id)} />;
<ServerDetailClient server_id={Number(params.id)} />
);
} }

View File

@ -26,5 +26,4 @@ export async function GET(req: Request) {
return NextResponse.json({ error: response.error }, { status: 400 }); return NextResponse.json({ error: response.error }, { status: 400 });
} }
return NextResponse.json(response, { status: 200 }); return NextResponse.json(response, { status: 200 });
} }

View File

@ -0,0 +1,102 @@
import { cn } from "@/lib/utils";
interface Props {
max: number;
value: number;
min: number;
className?: string;
}
export default function AnimatedCircularProgressBar({
max = 100,
min = 0,
value = 0,
className,
}: Props) {
const circumference = 2 * Math.PI * 45;
const percentPx = circumference / 100;
const currentPercent = ((value - min) / (max - min)) * 100;
return (
<div
className={cn("relative size-40 text-2xl font-semibold", className)}
style={
{
"--circle-size": "100px",
"--circumference": circumference,
"--percent-to-px": `${percentPx}px`,
"--gap-percent": "5",
"--offset-factor": "0",
"--transition-length": "1s",
"--transition-step": "200ms",
"--delay": "0s",
"--percent-to-deg": "3.6deg",
transform: "translateZ(0)",
} as React.CSSProperties
}
>
<svg
fill="none"
className="size-full"
strokeWidth="2"
viewBox="0 0 100 100"
>
{currentPercent <= 90 && currentPercent >= 0 && (
<circle
cx="50"
cy="50"
r="45"
strokeWidth="10"
strokeDashoffset="0"
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-100 stroke-muted"
style={
{
"--stroke-percent": 90 - currentPercent,
"--offset-factor-secondary": "calc(1 - var(--offset-factor))",
strokeDasharray:
"calc(var(--stroke-percent) * var(--percent-to-px)) var(--circumference)",
transform:
"rotate(calc(1turn - 90deg - (var(--gap-percent) * var(--percent-to-deg) * var(--offset-factor-secondary)))) scaleY(-1)",
transition: "all var(--transition-length) ease var(--delay)",
transformOrigin:
"calc(var(--circle-size) / 2) calc(var(--circle-size) / 2)",
} as React.CSSProperties
}
/>
)}
<circle
cx="50"
cy="50"
r="45"
strokeWidth="10"
strokeDashoffset="0"
strokeLinecap="round"
strokeLinejoin="round"
className="opacity-100 stroke-current"
style={
{
"--stroke-percent": currentPercent,
strokeDasharray:
"calc(var(--stroke-percent) * var(--percent-to-px)) var(--circumference)",
transition:
"var(--transition-length) ease var(--delay),stroke var(--transition-length) ease var(--delay)",
transitionProperty: "stroke-dasharray,transform",
transform:
"rotate(calc(-90deg + var(--gap-percent) * var(--offset-factor) * var(--percent-to-deg)))",
transformOrigin:
"calc(var(--circle-size) / 2) calc(var(--circle-size) / 2)",
} as React.CSSProperties
}
/>
</svg>
<span
data-current-value={currentPercent}
className="duration-[var(--transition-length)] delay-[var(--delay)] absolute inset-0 m-auto size-fit ease-linear animate-in fade-in"
>
{currentPercent}
</span>
</div>
);
}

View File

@ -3,7 +3,7 @@ import { type VariantProps, cva } from "class-variance-authority";
import * as React from "react"; import * as React from "react";
const badgeVariants = cva( const badgeVariants = cva(
"inline-flex items-center text-nowarp rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", "inline-flex items-center text-nowarp rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors pointer-events-none focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{ {
variants: { variants: {
variant: { variant: {

View File

@ -145,7 +145,8 @@ export async function GetServerDetail({ server_id }: { server_id: number }) {
} }
const timestamp = Date.now() / 1000; const timestamp = Date.now() / 1000;
const detailData = detailDataList.map((element: MakeOptional<NezhaAPI, "ipv4" | "ipv6" | "valid_ip">) => { const detailData = detailDataList.map(
(element: MakeOptional<NezhaAPI, "ipv4" | "ipv6" | "valid_ip">) => {
if (timestamp - element.last_active > 300) { if (timestamp - element.last_active > 300) {
element.online_status = false; element.online_status = false;
} else { } else {
@ -155,7 +156,8 @@ export async function GetServerDetail({ server_id }: { server_id: number }) {
delete element.ipv6; delete element.ipv6;
delete element.valid_ip; delete element.valid_ip;
return element; return element;
})[0]; },
)[0];
return detailData; return detailData;
} catch (error) { } catch (error) {

View File

@ -3,6 +3,7 @@ module.exports = {
importOrder: ["^@core/(.*)$", "^@server/(.*)$", "^@ui/(.*)$", "^[./]"], importOrder: ["^@core/(.*)$", "^@server/(.*)$", "^@ui/(.*)$", "^[./]"],
importOrderSeparation: true, importOrderSeparation: true,
importOrderSortSpecifiers: true, importOrderSortSpecifiers: true,
endOfLine: "auto",
plugins: [ plugins: [
"prettier-plugin-tailwindcss", "prettier-plugin-tailwindcss",
"@trivago/prettier-plugin-sort-imports", "@trivago/prettier-plugin-sort-imports",