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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user