mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-08-06 15:56:23 +00:00
Move Monitor dashboard UI copy into translation keys and expand the English source catalog across the main pages, modals, and shared AppImage components.
179 lines
7.1 KiB
TypeScript
179 lines
7.1 KiB
TypeScript
"use client"
|
||
|
||
import { useEffect, useRef, useState } from "react"
|
||
import { Thermometer } from "lucide-react"
|
||
import { Badge } from "./ui/badge"
|
||
import { AreaChart, Area, ResponsiveContainer, Tooltip, YAxis } from "recharts"
|
||
import { fetchApi } from "@/lib/api-config"
|
||
import { useDiskTempThresholds } from "@/lib/health-thresholds"
|
||
import { useT } from "@/lib/i18n/provider"
|
||
|
||
interface TempPoint {
|
||
timestamp: number
|
||
value: number
|
||
}
|
||
|
||
interface DiskTemperatureCardProps {
|
||
diskName: string
|
||
liveTemperature: number
|
||
/** Disk class — "HDD" | "SSD" | "NVMe" | "SAS". Drives the threshold colors. */
|
||
diskType: string
|
||
/** Click handler — opens the full timeframe-selector modal as drill-down. */
|
||
onOpenDetail?: () => void
|
||
}
|
||
|
||
// Disk-temperature thresholds come from the user-configurable backend
|
||
// (lib/health-thresholds.ts). The classifier here takes the resolved
|
||
// pair so the consumer can read it from the hook once per render.
|
||
function statusFor(temp: number, thresholds: { warn: number; hot: number }) {
|
||
if (temp <= 0) return { labelKey: "common.notAvailable", className: "bg-gray-500/10 text-gray-500 border-gray-500/20", color: "#6b7280" }
|
||
if (temp >= thresholds.hot) return { labelKey: "details.temperature.status.hot", className: "bg-red-500/10 text-red-500 border-red-500/20", color: "#ef4444" }
|
||
if (temp >= thresholds.warn) return { labelKey: "details.temperature.status.warm", className: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", color: "#f59e0b" }
|
||
return { labelKey: "details.temperature.status.normal", className: "bg-green-500/10 text-green-500 border-green-500/20", color: "#22c55e" }
|
||
}
|
||
|
||
const MiniTooltip = ({ active, payload }: any) => {
|
||
if (active && payload && payload.length) {
|
||
const ts = payload[0].payload?.timestamp
|
||
const date = ts ? new Date(ts * 1000) : null
|
||
return (
|
||
<div className="bg-gray-900/95 backdrop-blur-sm border border-gray-700 rounded-md px-2 py-1 shadow-xl">
|
||
{date && (
|
||
<p className="text-[10px] text-gray-300">
|
||
{date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
|
||
</p>
|
||
)}
|
||
<p className="text-xs font-semibold text-white">{payload[0].value}°C</p>
|
||
</div>
|
||
)
|
||
}
|
||
return null
|
||
}
|
||
|
||
export function DiskTemperatureCard({
|
||
diskName,
|
||
liveTemperature,
|
||
diskType,
|
||
onOpenDetail,
|
||
}: DiskTemperatureCardProps) {
|
||
const t = useT()
|
||
const [data, setData] = useState<TempPoint[]>([])
|
||
const [loading, setLoading] = useState(true)
|
||
const cancelled = useRef(false)
|
||
|
||
useEffect(() => {
|
||
cancelled.current = false
|
||
const fetchHistory = async () => {
|
||
setLoading(true)
|
||
try {
|
||
// 24-h timeframe gives a more useful "is this drive trending
|
||
// up over a day" view; the 1-h window was too short to spot
|
||
// anything that mattered.
|
||
const result = await fetchApi<{ data: TempPoint[] }>(
|
||
`/api/disk/${encodeURIComponent(diskName)}/temperature/history?timeframe=day`,
|
||
)
|
||
if (cancelled.current) return
|
||
setData(result?.data || [])
|
||
} catch {
|
||
if (!cancelled.current) setData([])
|
||
} finally {
|
||
if (!cancelled.current) setLoading(false)
|
||
}
|
||
}
|
||
fetchHistory()
|
||
// Refresh once a minute so the inline chart tracks the collector
|
||
// without needing the user to reopen the modal.
|
||
const id = setInterval(fetchHistory, 60_000)
|
||
return () => {
|
||
cancelled.current = true
|
||
clearInterval(id)
|
||
}
|
||
}, [diskName])
|
||
|
||
const allThresholds = useDiskTempThresholds()
|
||
const dt = (() => {
|
||
const t = (diskType || "").toUpperCase()
|
||
if (t === "HDD") return allThresholds.HDD
|
||
if (t === "NVME") return allThresholds.NVMe
|
||
if (t === "SAS") return allThresholds.SAS
|
||
return allThresholds.SSD
|
||
})()
|
||
const status = statusFor(liveTemperature, dt)
|
||
const lineColor = status.color
|
||
const tempDisplay = liveTemperature > 0 ? `${liveTemperature}°C` : t("common.notAvailable")
|
||
const samples = data.length
|
||
|
||
const interactive = !!onOpenDetail
|
||
const Wrapper: any = interactive ? "button" : "div"
|
||
|
||
return (
|
||
<Wrapper
|
||
type={interactive ? "button" : undefined}
|
||
onClick={interactive ? onOpenDetail : undefined}
|
||
className={[
|
||
"w-full text-left border border-white/10 rounded-lg p-3 bg-white/[0.02]",
|
||
interactive ? "cursor-pointer hover:bg-white/[0.04] transition-colors focus:outline-none focus:ring-1 focus:ring-white/20" : "",
|
||
].join(" ")}
|
||
title={interactive ? t("details.temperature.openHistory") : undefined}
|
||
>
|
||
<div className="flex items-start justify-between gap-3 mb-1.5">
|
||
<div className="min-w-0">
|
||
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">{t("details.temperature.diskTitle")}</p>
|
||
<p className="text-xl font-bold leading-tight mt-0.5" style={{ color: lineColor }}>
|
||
{tempDisplay}
|
||
</p>
|
||
</div>
|
||
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
||
<Thermometer className="h-3.5 w-3.5" style={{ color: lineColor }} />
|
||
<Badge variant="outline" className={`${status.className} text-[10px] px-2 py-0`}>
|
||
{t(status.labelKey)}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="h-[40px] -mx-1">
|
||
{loading ? (
|
||
<div className="h-full w-full animate-pulse bg-white/[0.03] rounded" />
|
||
) : samples < 2 ? (
|
||
<div className="h-full flex items-center justify-center text-[10px] text-muted-foreground">
|
||
{t("details.temperature.collectingSamples")}
|
||
</div>
|
||
) : (
|
||
<ResponsiveContainer width="100%" height="100%">
|
||
<AreaChart data={data} margin={{ top: 2, right: 4, left: 4, bottom: 0 }}>
|
||
<defs>
|
||
<linearGradient id={`diskTempCardGrad-${diskName}`} x1="0" y1="0" x2="0" y2="1">
|
||
<stop offset="0%" stopColor={lineColor} stopOpacity={0.35} />
|
||
<stop offset="100%" stopColor={lineColor} stopOpacity={0.02} />
|
||
</linearGradient>
|
||
</defs>
|
||
{/* Y domain is computed the same way the detail modal
|
||
does — floor(min−3) / ceil(max+3), floor at 0 — so
|
||
the line shape here matches the bigger chart instead
|
||
of recharts' auto-domain collapsing 24 → 29 °C into
|
||
a near-flat line that doesn't look like the modal. */}
|
||
<YAxis
|
||
hide
|
||
domain={[
|
||
(dataMin: number) => Math.max(0, Math.floor(dataMin - 3)),
|
||
(dataMax: number) => Math.ceil(dataMax + 3),
|
||
]}
|
||
/>
|
||
<Tooltip content={<MiniTooltip />} cursor={{ stroke: lineColor, strokeOpacity: 0.3, strokeWidth: 1 }} />
|
||
<Area
|
||
type="monotone"
|
||
dataKey="value"
|
||
stroke={lineColor}
|
||
strokeWidth={1.6}
|
||
fill={`url(#diskTempCardGrad-${diskName})`}
|
||
dot={false}
|
||
isAnimationActive={false}
|
||
/>
|
||
</AreaChart>
|
||
</ResponsiveContainer>
|
||
)}
|
||
</div>
|
||
</Wrapper>
|
||
)
|
||
}
|