mirror of
https://github.com/YuzuZensai/Termix.git
synced 2026-09-13 18:58:52 +00:00
feat: initial ui redesign from demo
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
import React from "react";
|
||||
import { Cpu } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios.ts";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
|
||||
interface CpuWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
function Sparkline({
|
||||
history,
|
||||
current,
|
||||
}: {
|
||||
history: ServerMetrics[];
|
||||
current: number | null;
|
||||
}) {
|
||||
const points = [
|
||||
...history.map((m) => m?.cpu?.percent ?? 0),
|
||||
current ?? 0,
|
||||
].slice(-20);
|
||||
|
||||
if (points.length < 2) return null;
|
||||
|
||||
const w = 300;
|
||||
const h = 48;
|
||||
const max = Math.max(...points, 1);
|
||||
const coords = points.map((v, i) => {
|
||||
const x = (i / (points.length - 1)) * w;
|
||||
const y = h - (v / max) * h;
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
const d = `M ${coords.join(" L ")}`;
|
||||
const fill = `M 0,${h} L ${coords.join(" L ")} L ${w},${h} Z`;
|
||||
|
||||
return (
|
||||
<div className="h-12 md:h-16 w-full mt-2 bg-muted/20 border border-border/50 relative overflow-hidden">
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full"
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d={fill} fill="currentColor" className="text-accent-brand/10" />
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-accent-brand/60"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function CpuWidget({ metrics, metricsHistory }: CpuWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
const percent = metrics?.cpu?.percent ?? null;
|
||||
const cores = metrics?.cpu?.cores ?? null;
|
||||
const load = metrics?.cpu?.load ?? null;
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("serverStats.cpuUsage")}
|
||||
icon={<Cpu className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xl md:text-3xl font-bold text-accent-brand">
|
||||
{percent !== null ? `${percent.toFixed(1)}%` : "N/A"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
|
||||
{cores !== null
|
||||
? t("serverStats.cpuCores", { count: cores })
|
||||
: t("serverStats.naCpus")}
|
||||
</span>
|
||||
</div>
|
||||
{load && (
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
|
||||
{t("serverStats.loadAvg")}
|
||||
</span>
|
||||
<div className="text-xs font-mono">
|
||||
{load[0].toFixed(2)} {load[1].toFixed(2)}
|
||||
{load[2].toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-2 bg-muted w-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent-brand transition-all duration-500"
|
||||
style={{ width: `${percent ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<Sparkline history={metricsHistory} current={percent} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios.ts";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
|
||||
interface DiskWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
function Sparkline({
|
||||
history,
|
||||
current,
|
||||
}: {
|
||||
history: ServerMetrics[];
|
||||
current: number | null;
|
||||
}) {
|
||||
const points = [
|
||||
...history.map((m) => m?.disk?.percent ?? 0),
|
||||
current ?? 0,
|
||||
].slice(-20);
|
||||
|
||||
if (points.length < 2) return null;
|
||||
|
||||
const w = 300;
|
||||
const h = 48;
|
||||
const max = Math.max(...points, 1);
|
||||
const coords = points.map((v, i) => {
|
||||
const x = (i / (points.length - 1)) * w;
|
||||
const y = h - (v / max) * h;
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
const d = `M ${coords.join(" L ")}`;
|
||||
const fill = `M 0,${h} L ${coords.join(" L ")} L ${w},${h} Z`;
|
||||
|
||||
return (
|
||||
<div className="h-12 md:h-16 w-full mt-2 bg-muted/20 border border-border/50 relative overflow-hidden">
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full"
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d={fill} fill="currentColor" className="text-accent-brand/10" />
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-accent-brand/60"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DiskWidget({ metrics, metricsHistory }: DiskWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
const percent = metrics?.disk?.percent ?? null;
|
||||
const usedHuman = metrics?.disk?.usedHuman ?? null;
|
||||
const totalHuman = metrics?.disk?.totalHuman ?? null;
|
||||
const availableHuman = metrics?.disk?.availableHuman ?? null;
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("serverStats.diskUsage")}
|
||||
icon={<HardDrive className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xl md:text-3xl font-bold text-accent-brand">
|
||||
{percent !== null ? `${percent}%` : "N/A"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
|
||||
{usedHuman && totalHuman ? `${usedHuman} / ${totalHuman}` : "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 bg-muted w-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent-brand transition-all duration-500"
|
||||
style={{ width: `${percent ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-muted-foreground font-semibold">
|
||||
{t("serverStats.available")}
|
||||
</span>
|
||||
<span className="font-mono">{availableHuman ?? "N/A"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Sparkline history={metricsHistory} current={percent} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import React from "react";
|
||||
import { Shield, ShieldOff, ShieldCheck, ChevronDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios.ts";
|
||||
import type {
|
||||
FirewallMetrics,
|
||||
FirewallChain,
|
||||
FirewallRule,
|
||||
} from "@/types/stats-widgets";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
|
||||
interface FirewallWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
function RuleRow({ rule }: { rule: FirewallRule }) {
|
||||
const { t } = useTranslation();
|
||||
const targetClass =
|
||||
rule.target.toUpperCase() === "ACCEPT"
|
||||
? "text-accent-brand"
|
||||
: rule.target.toUpperCase() === "DROP"
|
||||
? "text-destructive"
|
||||
: rule.target.toUpperCase() === "REJECT"
|
||||
? "text-yellow-500"
|
||||
: "text-muted-foreground";
|
||||
|
||||
const src =
|
||||
rule.interface ??
|
||||
rule.state ??
|
||||
(rule.source === "0.0.0.0/0"
|
||||
? t("serverStats.firewall.anywhere")
|
||||
: rule.source);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 gap-2 text-xs font-mono py-1 border-b border-border/50 last:border-0">
|
||||
<span className={`font-bold ${targetClass}`}>{rule.target}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{rule.protocol.toUpperCase()}
|
||||
</span>
|
||||
<span>{rule.dport ?? "—"}</span>
|
||||
<span className="truncate text-muted-foreground" title={src}>
|
||||
{src}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChainSection({ chain }: { chain: FirewallChain }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = React.useState(true);
|
||||
const policyClass =
|
||||
chain.policy.toUpperCase() === "ACCEPT"
|
||||
? "text-accent-brand"
|
||||
: chain.policy.toUpperCase() === "DROP"
|
||||
? "text-destructive"
|
||||
: "text-yellow-500";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
className="flex items-center gap-2 w-full py-1.5 hover:bg-muted/30 text-left"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`size-3 text-muted-foreground transition-transform ${open ? "" : "-rotate-90"}`}
|
||||
/>
|
||||
<span className="text-xs font-bold">{chain.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
({t("serverStats.firewall.policy")}:{" "}
|
||||
<span className={policyClass}>{chain.policy}</span>)
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground ml-auto">
|
||||
{chain.rules.length} {t("serverStats.firewall.rules")}
|
||||
</span>
|
||||
</button>
|
||||
{open && chain.rules.length > 0 && (
|
||||
<div className="ml-5">
|
||||
<div className="grid grid-cols-4 gap-2 text-[10px] text-muted-foreground font-bold uppercase pb-1 border-b border-border">
|
||||
<span>{t("serverStats.firewall.action")}</span>
|
||||
<span>{t("serverStats.firewall.protocol")}</span>
|
||||
<span>{t("serverStats.firewall.port")}</span>
|
||||
<span>{t("serverStats.firewall.source")}</span>
|
||||
</div>
|
||||
{chain.rules.map((rule, i) => (
|
||||
<RuleRow key={i} rule={rule} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FirewallWidget({ metrics }: FirewallWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
const firewall = (metrics as ServerMetrics & { firewall?: FirewallMetrics })
|
||||
?.firewall;
|
||||
|
||||
const statusIcon =
|
||||
!firewall || firewall.type === "none" ? (
|
||||
<ShieldOff className="size-3.5 text-muted-foreground" />
|
||||
) : firewall.status === "active" ? (
|
||||
<ShieldCheck className="size-3.5 text-accent-brand" />
|
||||
) : (
|
||||
<Shield className="size-3.5 text-yellow-500" />
|
||||
);
|
||||
|
||||
const statusBadge =
|
||||
firewall?.status === "active" ? (
|
||||
<span className="flex items-center gap-1.5 px-2 py-0.5 border border-accent-brand/40 bg-accent-brand/10 text-accent-brand text-[10px] font-bold">
|
||||
<ShieldCheck className="size-3" /> ACTIVE
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 px-2 py-0.5 border border-border text-muted-foreground text-[10px] font-bold">
|
||||
{t("serverStats.firewall.inactive").toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<SectionCard title={t("serverStats.firewall.title")} icon={statusIcon}>
|
||||
<div className="flex flex-col gap-3 py-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-semibold">
|
||||
{t("serverStats.firewall.title")}
|
||||
</span>
|
||||
{statusBadge}
|
||||
</div>
|
||||
{firewall?.type && firewall.type !== "none" && (
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-bold">
|
||||
{firewall.type}
|
||||
</span>
|
||||
)}
|
||||
{firewall && firewall.chains.length > 0 ? (
|
||||
<div className="flex flex-col gap-1">
|
||||
{firewall.chains.map((chain) => (
|
||||
<ChainSection key={chain.name} chain={chain} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("serverStats.firewall.noData")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { UserCheck, UserX } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
|
||||
interface LoginRecord {
|
||||
user: string;
|
||||
ip: string;
|
||||
time: string;
|
||||
status: "success" | "failed";
|
||||
}
|
||||
|
||||
interface LoginStatsMetrics {
|
||||
recentLogins: LoginRecord[];
|
||||
failedLogins: LoginRecord[];
|
||||
totalLogins: number;
|
||||
uniqueIPs: number;
|
||||
}
|
||||
|
||||
interface LoginStatsWidgetProps {
|
||||
metrics: { login_stats?: LoginStatsMetrics } | null;
|
||||
metricsHistory: unknown[];
|
||||
}
|
||||
|
||||
export function LoginStatsWidget({ metrics }: LoginStatsWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const loginStats = metrics?.login_stats;
|
||||
const recentLogins = loginStats?.recentLogins ?? [];
|
||||
const failedLogins = loginStats?.failedLogins ?? [];
|
||||
|
||||
const allLogins = [
|
||||
...recentLogins.map((l) => ({ ...l, status: "success" as const })),
|
||||
...failedLogins.map((l) => ({ ...l, status: "failed" as const })),
|
||||
]
|
||||
.sort((a, b) => new Date(b.time).getTime() - new Date(a.time).getTime())
|
||||
.slice(0, 6);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("serverStats.loginStats")}
|
||||
icon={<UserCheck className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-2 py-1">
|
||||
{allLogins.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground italic py-2">
|
||||
{t("serverStats.noRecentLoginData")}
|
||||
</span>
|
||||
) : (
|
||||
allLogins.map((login, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center justify-between p-2 border ${login.status === "success" ? "border-border bg-muted/30" : "border-destructive/30 bg-destructive/5"}`}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{login.status === "failed" ? (
|
||||
<UserX className="size-3 text-destructive" />
|
||||
) : (
|
||||
<UserCheck className="size-3 text-accent-brand" />
|
||||
)}
|
||||
<span
|
||||
className={`text-xs font-bold ${login.status === "failed" ? "text-destructive" : ""}`}
|
||||
>
|
||||
{login.user}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">
|
||||
{login.ip}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<span
|
||||
className={`text-[9px] font-bold uppercase px-1.5 py-px border ${login.status === "success" ? "border-accent-brand/40 text-accent-brand bg-accent-brand/10" : "border-destructive/40 text-destructive"}`}
|
||||
>
|
||||
{login.status}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{new Date(login.time).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { MemoryStick } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios.ts";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
|
||||
interface MemoryWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
function Sparkline({
|
||||
history,
|
||||
current,
|
||||
}: {
|
||||
history: ServerMetrics[];
|
||||
current: number | null;
|
||||
}) {
|
||||
const points = [
|
||||
...history.map((m) => m?.memory?.percent ?? 0),
|
||||
current ?? 0,
|
||||
].slice(-20);
|
||||
|
||||
if (points.length < 2) return null;
|
||||
|
||||
const w = 300;
|
||||
const h = 48;
|
||||
const max = Math.max(...points, 1);
|
||||
const coords = points.map((v, i) => {
|
||||
const x = (i / (points.length - 1)) * w;
|
||||
const y = h - (v / max) * h;
|
||||
return `${x},${y}`;
|
||||
});
|
||||
|
||||
const d = `M ${coords.join(" L ")}`;
|
||||
const fill = `M 0,${h} L ${coords.join(" L ")} L ${w},${h} Z`;
|
||||
|
||||
return (
|
||||
<div className="h-12 md:h-16 w-full mt-2 bg-muted/20 border border-border/50 relative overflow-hidden">
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full"
|
||||
viewBox={`0 0 ${w} ${h}`}
|
||||
preserveAspectRatio="none"
|
||||
>
|
||||
<path d={fill} fill="currentColor" className="text-accent-brand/10" />
|
||||
<path
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
className="text-accent-brand/60"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MemoryWidget({ metrics, metricsHistory }: MemoryWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
const percent = metrics?.memory?.percent ?? null;
|
||||
const usedGiB = metrics?.memory?.usedGiB ?? null;
|
||||
const totalGiB = metrics?.memory?.totalGiB ?? null;
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("serverStats.memoryUsage")}
|
||||
icon={<MemoryStick className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xl md:text-3xl font-bold text-accent-brand">
|
||||
{percent !== null ? `${percent.toFixed(1)}%` : "N/A"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
|
||||
{usedGiB !== null && totalGiB !== null
|
||||
? `${usedGiB.toFixed(1)} / ${totalGiB.toFixed(1)} GiB`
|
||||
: "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-2 bg-muted w-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent-brand transition-all duration-500"
|
||||
style={{ width: `${percent ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex flex-col p-2 bg-muted/30 border border-border">
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-bold">
|
||||
{t("serverStats.swap")}
|
||||
</span>
|
||||
<span className="text-xs font-semibold">N/A</span>
|
||||
</div>
|
||||
<div className="flex flex-col p-2 bg-muted/30 border border-border">
|
||||
<span className="text-[10px] text-muted-foreground uppercase font-bold">
|
||||
{t("serverStats.free")}
|
||||
</span>
|
||||
<span className="text-xs font-semibold">
|
||||
{usedGiB !== null && totalGiB !== null
|
||||
? `${(totalGiB - usedGiB).toFixed(1)} GiB`
|
||||
: "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Sparkline history={metricsHistory} current={percent} />
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Network, WifiOff } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios.ts";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
|
||||
interface NetworkWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
export function NetworkWidget({ metrics }: NetworkWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const metricsWithNetwork = metrics as ServerMetrics & {
|
||||
network?: {
|
||||
interfaces?: Array<{
|
||||
name: string;
|
||||
state: string;
|
||||
ip: string;
|
||||
rx?: string;
|
||||
tx?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const interfaces = metricsWithNetwork?.network?.interfaces ?? [];
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("serverStats.networkInterfaces")}
|
||||
icon={<Network className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-2 py-1">
|
||||
{interfaces.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-muted-foreground gap-2">
|
||||
<WifiOff className="size-6 opacity-40" />
|
||||
<span className="text-xs">
|
||||
{t("serverStats.noInterfacesFound")}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
interfaces.map((iface, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-col p-2 border border-border bg-muted/30 gap-1"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`size-1.5 rounded-full ${iface.state === "UP" ? "bg-accent-brand" : "bg-muted-foreground"}`}
|
||||
/>
|
||||
<span className="text-sm font-bold font-mono">
|
||||
{iface.name}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-[10px] font-semibold px-1.5 py-px border border-border text-muted-foreground uppercase">
|
||||
{iface.state}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] font-mono text-muted-foreground">
|
||||
<span>{iface.ip}</span>
|
||||
{(iface.rx || iface.tx) && (
|
||||
<span>
|
||||
↓ {iface.rx ?? "—"} / ↑ {iface.tx ?? "—"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Unplug } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios.ts";
|
||||
import type { PortsMetrics, ListeningPort } from "@/types/stats-widgets";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
|
||||
interface PortsWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
function PortRow({ port }: { port: ListeningPort }) {
|
||||
const formatAddress = (addr: string) =>
|
||||
addr === "0.0.0.0" || addr === "*" || addr === "::" ? "*" : addr;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-4 text-xs font-mono py-1 border-b border-border/50 last:border-0 min-w-0">
|
||||
<span className="text-accent-brand font-bold">{port.localPort}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{port.protocol.toUpperCase()}
|
||||
</span>
|
||||
<span className="font-semibold truncate">
|
||||
{port.process ?? (port.pid ? `PID:${port.pid}` : "—")}
|
||||
</span>
|
||||
<span className="text-right text-muted-foreground">
|
||||
{formatAddress(port.localAddress)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortsWidget({ metrics }: PortsWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const portsData = (metrics as ServerMetrics & { ports?: PortsMetrics })
|
||||
?.ports;
|
||||
const ports = portsData?.ports ?? [];
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("serverStats.ports.title")}
|
||||
icon={<Unplug className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 py-1">
|
||||
<div className="grid grid-cols-4 text-[10px] text-muted-foreground font-bold uppercase pb-1 border-b border-border min-w-0">
|
||||
<span>{t("serverStats.ports.port")}</span>
|
||||
<span>{t("serverStats.ports.protocol")}</span>
|
||||
<span>{t("serverStats.ports.process")}</span>
|
||||
<span className="text-right">{t("serverStats.ports.address")}</span>
|
||||
</div>
|
||||
{ports.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground italic py-2">
|
||||
{t("serverStats.ports.noData")}
|
||||
</span>
|
||||
) : (
|
||||
ports.map((port, i) => (
|
||||
<PortRow
|
||||
key={`${port.protocol}-${port.localPort}-${i}`}
|
||||
port={port}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { List, Activity } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios.ts";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
|
||||
interface ProcessesWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
export function ProcessesWidget({ metrics }: ProcessesWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const metricsWithProcesses = metrics as ServerMetrics & {
|
||||
processes?: {
|
||||
total?: number;
|
||||
running?: number;
|
||||
top?: Array<{
|
||||
pid: number;
|
||||
cpu: number;
|
||||
mem: number;
|
||||
command: string;
|
||||
user: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
const processes = metricsWithProcesses?.processes;
|
||||
const topProcesses = processes?.top ?? [];
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("serverStats.processes")}
|
||||
icon={<List className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-1.5 py-1">
|
||||
<div className="grid grid-cols-4 text-[10px] text-muted-foreground font-bold uppercase tracking-wider pb-1 border-b border-border min-w-0">
|
||||
<span>PID</span>
|
||||
<span>CPU</span>
|
||||
<span>MEM</span>
|
||||
<span>CMD</span>
|
||||
</div>
|
||||
{topProcesses.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-muted-foreground gap-2">
|
||||
<Activity className="size-6 opacity-40" />
|
||||
<span className="text-xs">{t("serverStats.noProcessesFound")}</span>
|
||||
</div>
|
||||
) : (
|
||||
topProcesses.map((proc, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="grid grid-cols-4 text-xs font-mono py-1 border-b border-border/50 last:border-0 min-w-0"
|
||||
>
|
||||
<span className="text-muted-foreground">{proc.pid}</span>
|
||||
<span className="text-accent-brand font-bold">{proc.cpu}%</span>
|
||||
<span>{proc.mem}%</span>
|
||||
<span className="truncate font-semibold" title={proc.command}>
|
||||
{proc.command.split("/").pop()}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Server } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios.ts";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
|
||||
interface SystemWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
export function SystemWidget({ metrics }: SystemWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const metricsWithSystem = metrics as ServerMetrics & {
|
||||
system?: { hostname?: string; os?: string; kernel?: string; arch?: string };
|
||||
uptime?: { formatted?: string };
|
||||
};
|
||||
const system = metricsWithSystem?.system;
|
||||
const uptime = metricsWithSystem?.uptime;
|
||||
|
||||
const rows = [
|
||||
{ label: t("serverStats.hostname"), value: system?.hostname },
|
||||
{ label: t("serverStats.operatingSystem"), value: system?.os },
|
||||
{ label: t("serverStats.kernel"), value: system?.kernel },
|
||||
{ label: t("serverStats.architecture"), value: system?.arch },
|
||||
{ label: t("serverStats.uptime"), value: uptime?.formatted },
|
||||
].filter((r) => r.value);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("serverStats.systemInfo")}
|
||||
icon={<Server className="size-3.5" />}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-y-3 py-2">
|
||||
{rows.map(({ label, value }) => (
|
||||
<div key={label} className="flex flex-col">
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-widest font-bold">
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-sm font-mono font-semibold truncate">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{rows.length === 0 && (
|
||||
<span className="text-xs text-muted-foreground">N/A</span>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Clock } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ServerMetrics } from "@/main-axios.ts";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
|
||||
interface UptimeWidgetProps {
|
||||
metrics: ServerMetrics | null;
|
||||
metricsHistory: ServerMetrics[];
|
||||
}
|
||||
|
||||
export function UptimeWidget({ metrics }: UptimeWidgetProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const metricsWithUptime = metrics as ServerMetrics & {
|
||||
uptime?: { formatted?: string; seconds?: number };
|
||||
};
|
||||
const uptime = metricsWithUptime?.uptime;
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("serverStats.uptime")}
|
||||
icon={<Clock className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
<span className="text-xl md:text-3xl font-bold text-accent-brand">
|
||||
{uptime?.formatted ?? "N/A"}
|
||||
</span>
|
||||
{uptime?.seconds && (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{Math.floor(uptime.seconds).toLocaleString()}{" "}
|
||||
{t("serverStats.seconds")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export { CpuWidget } from "./CpuWidget.tsx";
|
||||
export { MemoryWidget } from "./MemoryWidget.tsx";
|
||||
export { DiskWidget } from "./DiskWidget.tsx";
|
||||
export { NetworkWidget } from "./NetworkWidget.tsx";
|
||||
export { UptimeWidget } from "./UptimeWidget.tsx";
|
||||
export { ProcessesWidget } from "./ProcessesWidget.tsx";
|
||||
export { SystemWidget } from "./SystemWidget.tsx";
|
||||
export { LoginStatsWidget } from "./LoginStatsWidget.tsx";
|
||||
export { PortsWidget } from "./PortsWidget.tsx";
|
||||
export { FirewallWidget } from "./FirewallWidget.tsx";
|
||||
Reference in New Issue
Block a user