Import source: web/src/components/TargetDetail

Import the existing local project snapshot. This commit organizes existing files; it does not represent newly developed functionality.
This commit is contained in:
arsam
2026-09-18 23:20:12 +03:30
parent 80b4f420ac
commit 6cedb053f8
+207
View File
@@ -0,0 +1,207 @@
import { motion } from "motion/react";
import type { Bucket, Incident, LiveTarget } from "../lib/types";
import { StatusRing } from "./StatusRing";
import { Sparkline } from "./Sparkline";
import { RadialDay } from "./RadialDay";
import { IncidentList } from "./IncidentList";
import {
fmtDate,
fmtInterval,
fmtLatency,
fmtNumber,
fmtUptime,
pctSign,
} from "../lib/format";
import { springs, STATE_COLOR, useUI } from "../lib/ui";
interface TargetDetailProps {
target: LiveTarget;
buckets: Bucket[];
incidents: Incident[];
onClose: () => void;
}
function Stat({ label, value, accent }: { label: string; value: string; accent?: string }) {
return (
<div className="flex flex-col gap-1 min-w-0">
<span className="microlabel">{label}</span>
<span
className="text-[13px] tabular truncate"
style={{ color: accent ?? "var(--color-paper)" }}
dir="ltr"
>
{value}
</span>
</div>
);
}
/**
* The expanded view: shares its layout with the grid card, so the ring
* physically travels from the grid into the panel.
*/
export function TargetDetail({ target, buckets, incidents, onClose }: TargetDetailProps) {
const { t, lang, window, reduced } = useUI();
const uptime = window === "7d" ? target.uptime_7d : target.uptime_24h;
return (
<motion.div
className="fixed inset-0 z-30 flex items-center justify-center p-4 sm:p-8"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.18 } }}
>
{/* veil */}
<div
className="absolute inset-0 bg-ink-950/75 backdrop-blur-[6px]"
onClick={onClose}
/>
<motion.div
layoutId={`card-${target.id}`}
transition={reduced ? { duration: 0 } : springs.layout}
className="relative w-full max-w-3xl max-h-[88vh] overflow-y-auto rounded-md border border-ink-700 bg-ink-900"
style={{ boxShadow: "var(--panel-shadow)" }}
role="dialog"
aria-modal="true"
aria-label={target.name}
>
{/* state edge */}
<span
aria-hidden
className="absolute inset-x-0 top-0 h-px"
style={{
background: `linear-gradient(to right, transparent, ${STATE_COLOR[target.state]}, transparent)`,
}}
/>
<button
onClick={onClose}
aria-label={t.close}
className="absolute top-4 end-4 z-10 size-8 rounded-full border border-ink-700 text-paper-faint hover:text-paper hover:border-ink-700 hover:bg-ink-850 transition-colors cursor-pointer text-[13px]"
>
✕
</button>
<div className="p-6 sm:p-8">
{/* top: ring + identity */}
<div className="flex flex-col sm:flex-row items-center sm:items-start gap-6">
<motion.div
layoutId={`ring-${target.id}`}
transition={reduced ? { duration: 0 } : springs.layout}
className="shrink-0"
>
<StatusRing target={target} uptime={uptime} size={185} />
</motion.div>
<div className="flex-1 min-w-0 text-center sm:text-start pt-2">
<motion.h2
className="text-[34px] leading-tight text-paper"
style={{ fontFamily: "var(--font-display)" }}
initial={reduced ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.12 }}
>
{target.name}
</motion.h2>
<p className="mt-1 text-[11px] text-paper-faint tabular font-mono" dir="ltr">
{target.type === "http" ? (
<a
href={target.endpoint}
target="_blank"
rel="noreferrer"
className="hover:text-brass transition-colors"
title={t.openEndpoint}
>
{target.endpoint} ↗
</a>
) : (
target.endpoint
)}
</p>
<p className="mt-2 text-[12px]" style={{ color: STATE_COLOR[target.state] }}>
● {t.state[target.state]}
{target.fails > 0 && target.state !== "down" && (
<span className="text-paper-faint"> · {fmtNumber(lang, target.fails)} {t.failed}</span>
)}
</p>
<motion.div
className="mt-5 grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-4"
initial={reduced ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
>
<Stat label={t.latency + " p50"} value={fmtLatency(lang, target.latency.p50_ms)} />
<Stat label={t.latency + " p95"} value={fmtLatency(lang, target.latency.p95_ms)} />
<Stat
label={t.lastCheck}
value={target.last ? fmtDate(lang, target.last.ts) : "—"}
/>
<Stat label={t.interval} value={fmtInterval(lang, target.interval_sec)} />
{target.uptime_30d !== undefined ? (
<Stat
label={t.uptime30}
value={`${fmtUptime(lang, target.uptime_30d)}${pctSign(lang)}`}
/>
) : (
<Stat label="type" value={target.type.toUpperCase()} />
)}
{target.cert_days !== undefined && (
<Stat
label={t.certExpiry}
value={`${fmtNumber(lang, target.cert_days)}${t.daysShort}`}
accent={
target.cert_days < 14
? "var(--color-warn)"
: "var(--color-paper)"
}
/>
)}
</motion.div>
</div>
</div>
{/* middle: sparkline + personal day dial */}
<motion.div
className="mt-8 grid gap-8 sm:grid-cols-[1fr_auto] items-start"
initial={reduced ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.26, ...springs.gentle }}
>
<div>
<div className="microlabel mb-3 flex items-baseline gap-4">
<span>{t.latency}</span>
<span className="rule-fade flex-1" />
</div>
<Sparkline series={target.series} pulse={target.pulse} />
</div>
<div className="hidden sm:block">
<RadialDay
targets={[target]}
dayBuckets={{ [target.id]: buckets }}
only={target.id}
size={185}
caption={false}
/>
</div>
</motion.div>
{/* bottom: incidents */}
<motion.div
className="mt-8"
initial={reduced ? false : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.32, ...springs.gentle }}
>
<div className="microlabel mb-3 flex items-baseline gap-4">
<span>{t.recentIncidents}</span>
<span className="rule-fade flex-1" />
</div>
<IncidentList incidents={incidents.slice(0, 6)} targets={[target]} hideTarget />
</motion.div>
</div>
</motion.div>
</motion.div>
);
}