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