feat: add Dashboard tab with live GPU overview
Add a new Dashboard tab as the default/first tab (Dashboard - Curve - Performance - Fans) showing a full GPU overview. Backend: - New hal/dashboard.py: one-shot static GPU info via NVML (VBIOS, CUDA cores, compute capability, bus width, BAR1/CPU-accessible VRAM, Resizable BAR, PCIe max, max clocks, power limits, temp thresholds, persistence mode, fan count, serial, board part number, UUID). ROP count and VRAM type are best-effort from a per-model table since NVML does not expose them. - New /api/dashboard endpoint. - MonitoringSample gains live throttle_reasons (+label), PCIe link width/generation (downclocks when idle, so read per poll), and VRAM temp (from the MEMORY thermal sensor, if exposed). Frontend: - New Dashboard component: critical top row (throttling, voltage, GPU/VRAM temps), full live-monitor grid with sparklines, and a static GPU-information grid. Unavailable fields are omitted. - useDashboard hook + DashboardInfo type + api.client dashboard(). Also: restrict CORS to localhost origins (end-anchored), make _int_key_deltas fail closed on bad keys, and clean up lint blockers in server.py/monitoring.py.
This commit is contained in:
1 parent
3c55263250
commit
da507d55bb
11 files changed
+907
-72
No files matched your search
+20
-4
@@ -1,8 +1,10 @@
|
||||
import { useGpu } from "./hooks/useGpu";
|
||||
import { useCurve } from "./hooks/useCurve";
|
||||
import { useMonitor } from "./hooks/useMonitor";
|
||||
import { useDashboard } from "./hooks/useDashboard";
|
||||
import { StatusBar } from "./components/Monitor/StatusBar";
|
||||
import { LiveMonitor } from "./components/Monitor/LiveMonitor";
|
||||
import { Dashboard } from "./components/Dashboard/Dashboard";
|
||||
import { CurveEditor } from "./components/CurveEditor/CurveEditor";
|
||||
import { PointTable } from "./components/PointTable/PointTable";
|
||||
import { PerformancePanel } from "./components/Limits/PerformancePanel";
|
||||
@@ -89,12 +91,13 @@ function MainApp({
|
||||
const gpuInfo = useGpu();
|
||||
const { curve, wsStatus: curveWsStatus } = useCurve();
|
||||
const { monitor, monitorHistory, wsStatus: monitorWsStatus } = useMonitor();
|
||||
const { dashboard, loading: dashboardLoading } = useDashboard();
|
||||
const { setCurve, activeProfile, setActiveProfile, selectedGpuIndex } =
|
||||
useCurveStore();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"curve" | "performance" | "fans">(
|
||||
"curve",
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"dashboard" | "curve" | "performance" | "fans"
|
||||
>("dashboard");
|
||||
const [fanState, setFanState] = useState<FanState | null>(null);
|
||||
const [activeDomain, setActiveDomain] = useState<"gpu" | "memory">("gpu");
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||
@@ -168,6 +171,12 @@ function MainApp({
|
||||
{/* Tab Header and Profile Selector */}
|
||||
<div className="flex justify-between items-end border-b border-zinc-800 pb-2">
|
||||
<div className="flex gap-6">
|
||||
<button
|
||||
onClick={() => setActiveTab("dashboard")}
|
||||
className={`text-lg font-medium pb-2 -mb-[9px] border-b-2 transition-colors ${activeTab === "dashboard" ? "border-pink-500 text-zinc-100" : "border-transparent text-zinc-500 hover:text-zinc-300"}`}
|
||||
>
|
||||
Dashboard
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("curve")}
|
||||
className={`text-lg font-medium pb-2 -mb-[9px] border-b-2 transition-colors ${activeTab === "curve" ? "border-pink-500 text-zinc-100" : "border-transparent text-zinc-500 hover:text-zinc-300"}`}
|
||||
@@ -222,7 +231,14 @@ function MainApp({
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{activeTab === "curve" ? (
|
||||
{activeTab === "dashboard" ? (
|
||||
<Dashboard
|
||||
monitor={monitor}
|
||||
history={monitorHistory}
|
||||
dashboard={dashboard}
|
||||
loading={dashboardLoading}
|
||||
/>
|
||||
) : activeTab === "curve" ? (
|
||||
<>
|
||||
<div className="flex gap-4 items-stretch w-full">
|
||||
<div className="flex-1 min-w-0 flex flex-col">
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ProfileData,
|
||||
FanState,
|
||||
FanPoint,
|
||||
DashboardInfo,
|
||||
} from "../types";
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -108,6 +109,7 @@ export const api = {
|
||||
|
||||
gpus: () => get<GpuInfo[]>("/gpus"),
|
||||
gpu: (gpuIndex: number) => get<GpuInfo>("/gpu", gpuIndex),
|
||||
dashboard: (gpuIndex: number) => get<DashboardInfo>("/dashboard", gpuIndex),
|
||||
curve: (gpuIndex: number) => get<CurveState>("/curve", gpuIndex),
|
||||
ranges: (gpuIndex: number) =>
|
||||
get<Record<string, { min_khz: number; max_khz: number }>>(
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import { Loader } from "lucide-react";
|
||||
import { GaugeCard } from "../Monitor/GaugeCard";
|
||||
import { fmt } from "../../utils/units";
|
||||
import type { MonitoringSample, DashboardInfo } from "../../types";
|
||||
|
||||
interface Props {
|
||||
monitor: MonitoringSample | null;
|
||||
history: MonitoringSample[];
|
||||
dashboard: DashboardInfo | null;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
function pluck<K extends keyof MonitoringSample>(
|
||||
history: MonitoringSample[],
|
||||
key: K,
|
||||
): number[] {
|
||||
return history.map((s) => (s[key] as number | null) ?? 0);
|
||||
}
|
||||
|
||||
function pcieLink(
|
||||
width: number | null | undefined,
|
||||
gen: number | null | undefined,
|
||||
): string | null {
|
||||
if (width == null && gen == null) return null;
|
||||
return `x${width ?? "?"} Gen${gen ?? "?"}`;
|
||||
}
|
||||
|
||||
/** A static label/value pair. Renders nothing when the value is unavailable. */
|
||||
function InfoItem({
|
||||
label,
|
||||
value,
|
||||
accent,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | null | undefined;
|
||||
accent?: string;
|
||||
}) {
|
||||
if (value == null || value === "") return null;
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 min-w-0">
|
||||
<span className="text-xs text-zinc-500">{label}</span>
|
||||
<span
|
||||
className={`text-sm font-mono truncate ${accent ?? "text-zinc-200"}`}
|
||||
title={value}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TempRow({ label, value }: { label: string; value: number | null }) {
|
||||
if (value == null) return null;
|
||||
return (
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-xs text-zinc-500">{label}</span>
|
||||
<span className="text-lg font-mono font-semibold text-orange-400">
|
||||
{fmt.celsius(value)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Dashboard({ monitor, history, dashboard: d, loading }: Props) {
|
||||
if (loading && !d) {
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-lg p-10 text-center text-zinc-500 flex items-center justify-center gap-2">
|
||||
<Loader size={18} className="animate-spin" /> Loading dashboard…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const throttleLabel = monitor?.throttle_reasons_label ?? null;
|
||||
const isThrottled = throttleLabel != null && throttleLabel !== "No";
|
||||
|
||||
const memUsed = monitor?.mem_used_mib ?? null;
|
||||
const memTotal = monitor?.mem_total_mib ?? null;
|
||||
const memLabel =
|
||||
memUsed != null && memTotal != null
|
||||
? `${memUsed.toFixed(0)} / ${memTotal.toFixed(0)} MiB`
|
||||
: "—";
|
||||
|
||||
const powerLimit = d?.power_limit_w ?? null;
|
||||
const powerRange =
|
||||
d?.power_min_limit_w != null && d?.power_max_limit_w != null
|
||||
? `${d.power_min_limit_w}–${d.power_max_limit_w} W`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
{/* ── Critical row ─────────────────────────────────────────────── */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{/* Throttling */}
|
||||
<div
|
||||
className={`rounded-lg p-4 border ${
|
||||
isThrottled
|
||||
? "bg-amber-500/10 border-amber-500/30"
|
||||
: "bg-emerald-500/10 border-emerald-500/30"
|
||||
}`}
|
||||
>
|
||||
<div className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
|
||||
Throttling
|
||||
</div>
|
||||
<div
|
||||
className={`text-2xl font-semibold mt-1 break-words ${
|
||||
isThrottled ? "text-amber-400" : "text-emerald-400"
|
||||
}`}
|
||||
>
|
||||
{throttleLabel ?? "—"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* GPU Voltage */}
|
||||
<div className="bg-zinc-900 rounded-lg p-4 border border-zinc-800">
|
||||
<div className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
|
||||
GPU Voltage
|
||||
</div>
|
||||
<div className="text-2xl font-mono font-semibold mt-1 text-violet-400">
|
||||
{fmt.mv(monitor?.voltage_mv)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Temperature */}
|
||||
<div className="bg-zinc-900 rounded-lg p-4 border border-zinc-800">
|
||||
<div className="text-xs text-zinc-500 uppercase tracking-wider font-semibold">
|
||||
Temperature
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 mt-1">
|
||||
<TempRow label="GPU" value={monitor?.temp_c ?? null} />
|
||||
<TempRow label="VRAM" value={monitor?.mem_temp_c ?? null} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Live Monitor ─────────────────────────────────────────────── */}
|
||||
<div className="bg-zinc-900 rounded-lg p-4">
|
||||
<div className="text-xs text-zinc-500 uppercase tracking-wider font-semibold pb-3 border-b border-zinc-800">
|
||||
Live Monitor
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 mt-3">
|
||||
<GaugeCard
|
||||
label="Core Clock"
|
||||
value={fmt.mhz(monitor?.clock_mhz)}
|
||||
history={pluck(history, "clock_mhz")}
|
||||
color="#34d399"
|
||||
max={d?.max_graphics_clock_mhz ?? 3000}
|
||||
/>
|
||||
<GaugeCard
|
||||
label="Mem Clock"
|
||||
value={fmt.mhz(monitor?.mem_clock_mhz)}
|
||||
history={pluck(history, "mem_clock_mhz")}
|
||||
color="#67e8f9"
|
||||
max={d?.max_memory_clock_mhz ?? 20000}
|
||||
/>
|
||||
<GaugeCard
|
||||
label="Voltage"
|
||||
value={fmt.mv(monitor?.voltage_mv)}
|
||||
history={pluck(history, "voltage_mv")}
|
||||
color="#a78bfa"
|
||||
max={1100}
|
||||
/>
|
||||
<GaugeCard
|
||||
label="Power Draw"
|
||||
value={fmt.watts(monitor?.power_w)}
|
||||
history={pluck(history, "power_w")}
|
||||
color="#f472b6"
|
||||
max={powerLimit != null && powerLimit > 0 ? powerLimit : 600}
|
||||
/>
|
||||
<GaugeCard
|
||||
label="GPU Util"
|
||||
value={fmt.pct(monitor?.gpu_util_pct)}
|
||||
history={pluck(history, "gpu_util_pct")}
|
||||
color="#facc15"
|
||||
max={100}
|
||||
/>
|
||||
<GaugeCard
|
||||
label="Mem Util"
|
||||
value={fmt.pct(monitor?.mem_util_pct)}
|
||||
history={pluck(history, "mem_util_pct")}
|
||||
color="#38bdf8"
|
||||
max={100}
|
||||
/>
|
||||
<GaugeCard
|
||||
label="Fan Speed"
|
||||
value={fmt.pct(monitor?.fan_pct)}
|
||||
history={pluck(history, "fan_pct")}
|
||||
color="#fb923c"
|
||||
max={100}
|
||||
/>
|
||||
<GaugeCard
|
||||
label="P-State"
|
||||
value={monitor?.pstate_label ?? "Unknown"}
|
||||
history={pluck(history, "pstate")}
|
||||
color="#a8a29e"
|
||||
max={15}
|
||||
/>
|
||||
<GaugeCard
|
||||
label="VRAM Used"
|
||||
value={memLabel}
|
||||
history={pluck(history, "mem_used_mib")}
|
||||
color="#a78bfa"
|
||||
max={memTotal ?? 32768}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── GPU Information ──────────────────────────────────────────── */}
|
||||
<div className="bg-zinc-900 rounded-lg p-4">
|
||||
<div className="text-xs text-zinc-500 uppercase tracking-wider font-semibold pb-3 border-b border-zinc-800">
|
||||
GPU Information
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-x-6 gap-y-3 mt-3">
|
||||
<InfoItem label="Driver" value={d?.driver_version} />
|
||||
<InfoItem label="VBIOS" value={d?.vbios_version} />
|
||||
<InfoItem
|
||||
label="CUDA Cores"
|
||||
value={d?.cuda_cores != null ? String(d.cuda_cores) : null}
|
||||
/>
|
||||
<InfoItem label="CUDA Compute" value={d?.cuda_compute_capability} />
|
||||
<InfoItem
|
||||
label="ROP Count"
|
||||
value={d?.rop_count != null ? String(d.rop_count) : null}
|
||||
/>
|
||||
<InfoItem
|
||||
label="VRAM Type"
|
||||
value={d?.vram_type}
|
||||
accent="text-cyan-400"
|
||||
/>
|
||||
<InfoItem label="VRAM Total" value={fmt.bytes(d?.vram_total_bytes)} />
|
||||
<InfoItem
|
||||
label="Bus Width"
|
||||
value={
|
||||
d?.memory_bus_width != null ? `${d.memory_bus_width}-bit` : null
|
||||
}
|
||||
/>
|
||||
<InfoItem
|
||||
label="CPU Accessible VRAM"
|
||||
value={fmt.bytes(d?.bar1_total_bytes)}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Resizable BAR"
|
||||
value={
|
||||
d?.resizable_bar == null
|
||||
? null
|
||||
: d.resizable_bar
|
||||
? "Enabled"
|
||||
: "Disabled"
|
||||
}
|
||||
accent={
|
||||
d?.resizable_bar == null
|
||||
? undefined
|
||||
: d.resizable_bar
|
||||
? "text-emerald-400"
|
||||
: "text-zinc-400"
|
||||
}
|
||||
/>
|
||||
<InfoItem
|
||||
label="PCIe Link"
|
||||
value={pcieLink(
|
||||
monitor?.pcie_link_width,
|
||||
monitor?.pcie_link_generation,
|
||||
)}
|
||||
accent="text-cyan-400"
|
||||
/>
|
||||
<InfoItem
|
||||
label="PCIe Max"
|
||||
value={pcieLink(
|
||||
d?.pcie_max_link_width,
|
||||
d?.pcie_max_link_generation,
|
||||
)}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Max Core Clock"
|
||||
value={fmt.mhz(d?.max_graphics_clock_mhz)}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Max Mem Clock"
|
||||
value={fmt.mhz(d?.max_memory_clock_mhz)}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Power Limit"
|
||||
value={powerLimit != null ? `${powerLimit} W` : null}
|
||||
/>
|
||||
<InfoItem label="Power Range" value={powerRange} />
|
||||
<InfoItem
|
||||
label="Temp Slowdown"
|
||||
value={
|
||||
d?.temp_slowdown_c != null ? `${d.temp_slowdown_c} °C` : null
|
||||
}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Temp Shutdown"
|
||||
value={
|
||||
d?.temp_shutdown_c != null ? `${d.temp_shutdown_c} °C` : null
|
||||
}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Persistence Mode"
|
||||
value={
|
||||
d?.persistence_mode == null
|
||||
? null
|
||||
: d.persistence_mode
|
||||
? "Enabled"
|
||||
: "Disabled"
|
||||
}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Fans"
|
||||
value={d?.num_fans != null ? String(d.num_fans) : null}
|
||||
/>
|
||||
<InfoItem label="Serial" value={d?.serial} />
|
||||
<InfoItem label="Board Part No." value={d?.board_part_number} />
|
||||
<InfoItem label="UUID" value={d?.uuid} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api/client";
|
||||
import { useCurveStore } from "../store/curveStore";
|
||||
import type { DashboardInfo } from "../types";
|
||||
|
||||
interface DashboardState {
|
||||
gpuIndex: number;
|
||||
data: DashboardInfo | null;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch static GPU info for the Dashboard tab.
|
||||
* Re-fetches when the selected GPU changes.
|
||||
*/
|
||||
export function useDashboard() {
|
||||
const { selectedGpuIndex } = useCurveStore();
|
||||
const [state, setState] = useState<DashboardState>({
|
||||
gpuIndex: -1,
|
||||
data: null,
|
||||
done: false,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api
|
||||
.dashboard(selectedGpuIndex)
|
||||
.then((data) => {
|
||||
if (!cancelled)
|
||||
setState({ gpuIndex: selectedGpuIndex, data, done: true });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to load dashboard info:", err);
|
||||
if (!cancelled)
|
||||
setState({ gpuIndex: selectedGpuIndex, data: null, done: true });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedGpuIndex]);
|
||||
|
||||
const isCurrent = state.gpuIndex === selectedGpuIndex;
|
||||
return {
|
||||
dashboard: isCurrent ? state.data : null,
|
||||
loading: !isCurrent || !state.done,
|
||||
};
|
||||
}
|
||||
+42
-2
@@ -8,7 +8,7 @@ export interface VFPoint {
|
||||
delta_mhz: number;
|
||||
effective_freq_khz: number;
|
||||
effective_freq_mhz: number;
|
||||
domain: 'gpu' | 'memory';
|
||||
domain: "gpu" | "memory";
|
||||
}
|
||||
|
||||
export interface CurveState {
|
||||
@@ -34,6 +34,46 @@ export interface MonitoringSample {
|
||||
gpu_util_pct: number | null;
|
||||
mem_util_pct: number | null;
|
||||
mem_clock_mhz: number | null;
|
||||
throttle_reasons: number | null;
|
||||
throttle_reasons_label: string | null;
|
||||
pcie_link_width: number | null;
|
||||
pcie_link_generation: number | null;
|
||||
mem_temp_c: number | null;
|
||||
}
|
||||
|
||||
export interface DashboardInfo {
|
||||
name: string | null;
|
||||
index: number;
|
||||
driver_version: string | null;
|
||||
vbios_version: string | null;
|
||||
serial: string | null;
|
||||
board_part_number: string | null;
|
||||
uuid: string | null;
|
||||
cuda_cores: number | null;
|
||||
cuda_compute_capability: string | null;
|
||||
rop_count: number | null;
|
||||
vram_total_bytes: number | null;
|
||||
vram_type: string | null;
|
||||
memory_bus_width: number | null;
|
||||
bar1_total_bytes: number | null;
|
||||
bar1_used_bytes: number | null;
|
||||
resizable_bar: boolean | null;
|
||||
pcie_link_width: number | null;
|
||||
pcie_link_generation: number | null;
|
||||
pcie_max_link_width: number | null;
|
||||
pcie_max_link_generation: number | null;
|
||||
max_graphics_clock_mhz: number | null;
|
||||
max_memory_clock_mhz: number | null;
|
||||
max_video_clock_mhz: number | null;
|
||||
power_limit_w: number | null;
|
||||
power_min_limit_w: number | null;
|
||||
power_max_limit_w: number | null;
|
||||
persistence_mode: boolean | null;
|
||||
num_fans: number | null;
|
||||
supported_throttle_reasons: number | null;
|
||||
temp_slowdown_c: number | null;
|
||||
temp_shutdown_c: number | null;
|
||||
temp_gpu_max_c: number | null;
|
||||
}
|
||||
|
||||
export interface GpuInfo {
|
||||
@@ -73,7 +113,7 @@ export interface FanPoint {
|
||||
|
||||
export interface FanState {
|
||||
fan_pct: number | null;
|
||||
fan_mode: 'auto' | 'curve' | null;
|
||||
fan_mode: "auto" | "curve" | null;
|
||||
min_fan_pct: number | null;
|
||||
max_fan_pct: number | null;
|
||||
curve: FanPoint[] | null;
|
||||
|
||||
@@ -1,16 +1,24 @@
|
||||
export const fmt = {
|
||||
mhz: (v: number | null | undefined, decimals = 0) =>
|
||||
v == null ? '—' : `${v.toFixed(decimals)} MHz`,
|
||||
v == null ? "—" : `${v.toFixed(decimals)} MHz`,
|
||||
mv: (v: number | null | undefined, decimals = 0) =>
|
||||
v == null ? '—' : `${v.toFixed(decimals)} mV`,
|
||||
v == null ? "—" : `${v.toFixed(decimals)} mV`,
|
||||
celsius: (v: number | null | undefined, decimals = 0) =>
|
||||
v == null ? '—' : `${v.toFixed(decimals)} °C`,
|
||||
v == null ? "—" : `${v.toFixed(decimals)} °C`,
|
||||
watts: (v: number | null | undefined, decimals = 0) =>
|
||||
v == null ? '—' : `${v.toFixed(decimals)} W`,
|
||||
v == null ? "—" : `${v.toFixed(decimals)} W`,
|
||||
pct: (v: number | null | undefined, decimals = 0) =>
|
||||
v == null ? '—' : `${v.toFixed(decimals)}%`,
|
||||
v == null ? "—" : `${v.toFixed(decimals)}%`,
|
||||
mib: (v: number | null | undefined) =>
|
||||
v == null ? '—' : `${v.toFixed(0)} MiB`,
|
||||
v == null ? "—" : `${v.toFixed(0)} MiB`,
|
||||
// Human-readable byte size (auto-scales to MiB / GiB).
|
||||
bytes: (v: number | null | undefined) => {
|
||||
if (v == null) return "—";
|
||||
const gib = v / 1024 ** 3;
|
||||
if (gib >= 1) return `${gib.toFixed(2)} GiB`;
|
||||
const mib = v / 1024 ** 2;
|
||||
return `${mib.toFixed(0)} MiB`;
|
||||
},
|
||||
};
|
||||
|
||||
export const conv = {
|
||||
|
||||
Reference in new issue
Block a user