mirror of
https://github.com/jakejarvis/hoot.git
synced 2025-10-18 20:14:25 -04:00
Refactor DNS components to use new KeyValueGrid and SectionContent components; replace TtlBadge with TtlTimeBadge and RelativeExpiry with RelativeExpiryBadge
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
import { useMemo } from "react";
|
||||
import { Favicon } from "@/components/domain/favicon";
|
||||
import { KeyValue } from "@/components/domain/key-value";
|
||||
import { TtlBadge } from "@/components/domain/ttl-badge";
|
||||
import { TtlTimeBadge } from "@/components/domain/time-badges";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -35,7 +35,7 @@ export function DnsRecordList({
|
||||
}
|
||||
value={r.value}
|
||||
trailing={
|
||||
typeof r.ttl === "number" ? <TtlBadge ttl={r.ttl} /> : undefined
|
||||
typeof r.ttl === "number" ? <TtlTimeBadge ttl={r.ttl} /> : undefined
|
||||
}
|
||||
suffix={
|
||||
r.isCloudflare ? (
|
||||
|
32
components/domain/key-value-grid.tsx
Normal file
32
components/domain/key-value-grid.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function KeyValueGrid({
|
||||
children,
|
||||
className,
|
||||
colsSm = 2,
|
||||
colsMd,
|
||||
colsLg,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
colsSm?: 1 | 2 | 3 | 4;
|
||||
colsMd?: 1 | 2 | 3 | 4;
|
||||
colsLg?: 1 | 2 | 3 | 4;
|
||||
}) {
|
||||
const smClass = `sm:grid-cols-${colsSm}`;
|
||||
const mdClass = colsMd ? `md:grid-cols-${colsMd}` : undefined;
|
||||
const lgClass = colsLg ? `lg:grid-cols-${colsLg}` : undefined;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-1 gap-2",
|
||||
smClass,
|
||||
mdClass,
|
||||
lgClass,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
31
components/domain/key-value-skeletons.tsx
Normal file
31
components/domain/key-value-skeletons.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { KeyValueSkeleton } from "@/components/domain/key-value-skeleton";
|
||||
|
||||
export function KeyValueSkeletonList({
|
||||
count,
|
||||
widthClass = "w-[100px]",
|
||||
withLeading = false,
|
||||
withTrailing = false,
|
||||
withSuffix = false,
|
||||
keyPrefix = "kv-skel",
|
||||
}: {
|
||||
count: number;
|
||||
widthClass?: string;
|
||||
withLeading?: boolean;
|
||||
withTrailing?: boolean;
|
||||
withSuffix?: boolean;
|
||||
keyPrefix?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: count }, (_, i) => (
|
||||
<KeyValueSkeleton
|
||||
key={`${keyPrefix}-${i + 1}`}
|
||||
widthClass={widthClass}
|
||||
withLeading={withLeading}
|
||||
withTrailing={withTrailing}
|
||||
withSuffix={withSuffix}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
@@ -1,53 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { RelativeExpiry } from "./relative-expiry";
|
||||
|
||||
describe("RelativeExpiry", () => {
|
||||
it("renders warn color for dates within warn threshold", async () => {
|
||||
const tenDaysMs = 10 * 24 * 60 * 60 * 1000;
|
||||
const date = new Date(Date.now() + tenDaysMs).toISOString();
|
||||
render(<RelativeExpiry to={date} dangerDays={7} warnDays={30} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const els = screen.getAllByText((_, node) => {
|
||||
const t = node?.textContent || "";
|
||||
return t.startsWith("(") && t.endsWith(")") && /in\s+/i.test(t);
|
||||
});
|
||||
expect(els.length).toBeGreaterThan(0);
|
||||
expect(els.some((e) => e.className.includes("text-amber-600"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders danger color for dates within danger threshold", async () => {
|
||||
const threeDaysMs = 3 * 24 * 60 * 60 * 1000;
|
||||
const date = new Date(Date.now() + threeDaysMs).toISOString();
|
||||
render(<RelativeExpiry to={date} dangerDays={7} warnDays={30} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const els = screen.getAllByText((_, node) => {
|
||||
const t = node?.textContent || "";
|
||||
return t.startsWith("(") && t.endsWith(")") && /in\s+/i.test(t);
|
||||
});
|
||||
expect(els.length).toBeGreaterThan(0);
|
||||
expect(els.some((e) => e.className.includes("text-red-600"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows past dates as ago and uses danger color", async () => {
|
||||
const pastMs = -2 * 24 * 60 * 60 * 1000;
|
||||
const date = new Date(Date.now() + pastMs).toISOString();
|
||||
render(<RelativeExpiry to={date} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const els = screen.getAllByText((_, node) => {
|
||||
const t = node?.textContent || "";
|
||||
return t.startsWith("(") && t.endsWith(")") && /ago\)/i.test(t);
|
||||
});
|
||||
expect(els.length).toBeGreaterThan(0);
|
||||
expect(els.some((e) => e.className.includes("text-red-600"))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
49
components/domain/section-content.tsx
Normal file
49
components/domain/section-content.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SectionContentProps<T> = {
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
data?: T | null;
|
||||
className?: string;
|
||||
renderLoading: () => React.ReactNode;
|
||||
renderError: () => React.ReactNode;
|
||||
renderData: (data: T) => React.ReactNode;
|
||||
renderEmpty?: () => React.ReactNode;
|
||||
isEmpty?: (data: T) => boolean;
|
||||
};
|
||||
|
||||
export function SectionContent<T>({
|
||||
isLoading,
|
||||
isError,
|
||||
data,
|
||||
className,
|
||||
renderLoading,
|
||||
renderError,
|
||||
renderData,
|
||||
renderEmpty,
|
||||
isEmpty,
|
||||
}: SectionContentProps<T>) {
|
||||
if (isLoading) {
|
||||
return <div className={cn(className)}>{renderLoading()}</div>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <div className={cn(className)}>{renderError()}</div>;
|
||||
}
|
||||
|
||||
if (data == null) {
|
||||
return renderEmpty ? (
|
||||
<div className={cn(className)}>{renderEmpty()}</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
if (isEmpty?.(data)) {
|
||||
return renderEmpty ? (
|
||||
<div className={cn(className)}>{renderEmpty()}</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
return <div className={cn(className)}>{renderData(data)}</div>;
|
||||
}
|
@@ -11,9 +11,10 @@ import { Fragment, useState } from "react";
|
||||
import { ErrorWithRetry } from "@/components/domain/error-with-retry";
|
||||
import { Favicon } from "@/components/domain/favicon";
|
||||
import { KeyValue } from "@/components/domain/key-value";
|
||||
import { KeyValueGrid } from "@/components/domain/key-value-grid";
|
||||
import { KeyValueSkeleton } from "@/components/domain/key-value-skeleton";
|
||||
import { RelativeExpiry } from "@/components/domain/relative-expiry";
|
||||
import { Section } from "@/components/domain/section";
|
||||
import { RelativeExpiryBadge } from "@/components/domain/time-badges";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -57,7 +58,7 @@ export function CertificatesSection({
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="relative overflow-hidden rounded-2xl border border-black/5 bg-background/40 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.08)] backdrop-blur supports-[backdrop-filter]:bg-background/40 dark:border-white/5">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<KeyValueGrid colsSm={2}>
|
||||
<KeyValueSkeleton
|
||||
label="Issuer"
|
||||
widthClass="w-[100px]"
|
||||
@@ -70,7 +71,7 @@ export function CertificatesSection({
|
||||
widthClass="w-[100px]"
|
||||
withSuffix
|
||||
/>
|
||||
</div>
|
||||
</KeyValueGrid>
|
||||
</div>
|
||||
<div className="my-2 flex justify-center">
|
||||
<Skeleton className="h-8 w-28 rounded-md" />
|
||||
@@ -82,7 +83,7 @@ export function CertificatesSection({
|
||||
key={`cert-${firstCert.subject}-${firstCert.validFrom}-${firstCert.validTo}`}
|
||||
>
|
||||
<div className="relative mb-0 overflow-hidden rounded-2xl border border-black/5 bg-background/40 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.08)] backdrop-blur supports-[backdrop-filter]:bg-background/40 dark:border-white/5">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<KeyValueGrid colsSm={2}>
|
||||
<KeyValue
|
||||
label="Issuer"
|
||||
value={firstCert.issuer}
|
||||
@@ -145,15 +146,14 @@ export function CertificatesSection({
|
||||
value={formatDate(firstCert.validTo)}
|
||||
valueTooltip={formatDateTimeUtc(firstCert.validTo)}
|
||||
suffix={
|
||||
<RelativeExpiry
|
||||
<RelativeExpiryBadge
|
||||
to={firstCert.validTo}
|
||||
dangerDays={7}
|
||||
warnDays={30}
|
||||
className="text-[11px]"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</KeyValueGrid>
|
||||
</div>
|
||||
</Fragment>
|
||||
|
||||
@@ -191,7 +191,7 @@ export function CertificatesSection({
|
||||
key={`cert-${c.subject}-${c.validFrom}-${c.validTo}`}
|
||||
>
|
||||
<div className="relative overflow-hidden rounded-2xl border border-black/5 bg-background/40 p-3 shadow-[inset_0_1px_0_rgba(255,255,255,0.08)] backdrop-blur supports-[backdrop-filter]:bg-background/40 dark:border-white/5">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<KeyValueGrid colsSm={2}>
|
||||
<KeyValue
|
||||
label="Issuer"
|
||||
value={c.issuer}
|
||||
@@ -250,15 +250,14 @@ export function CertificatesSection({
|
||||
value={formatDate(c.validTo)}
|
||||
valueTooltip={formatDateTimeUtc(c.validTo)}
|
||||
suffix={
|
||||
<RelativeExpiry
|
||||
<RelativeExpiryBadge
|
||||
to={c.validTo}
|
||||
dangerDays={7}
|
||||
warnDays={30}
|
||||
className="text-[11px]"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</KeyValueGrid>
|
||||
</div>
|
||||
|
||||
{idx < remainingCerts.length - 1 && (
|
||||
|
@@ -3,8 +3,10 @@
|
||||
import { Logs } from "lucide-react";
|
||||
import { ErrorWithRetry } from "@/components/domain/error-with-retry";
|
||||
import { KeyValue } from "@/components/domain/key-value";
|
||||
import { KeyValueSkeleton } from "@/components/domain/key-value-skeleton";
|
||||
import { KeyValueGrid } from "@/components/domain/key-value-grid";
|
||||
import { KeyValueSkeletonList } from "@/components/domain/key-value-skeletons";
|
||||
import { Section } from "@/components/domain/section";
|
||||
import { SectionContent } from "@/components/domain/section-content";
|
||||
import {
|
||||
Empty,
|
||||
EmptyDescription,
|
||||
@@ -28,56 +30,67 @@ export function HeadersSection({
|
||||
}) {
|
||||
return (
|
||||
<Section {...SECTION_DEFS.headers} isError={isError} isLoading={isLoading}>
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{Array.from({ length: 12 }, (_, n) => `hdr-skel-${n}`).map((id) => (
|
||||
<KeyValueSkeleton key={id} widthClass="w-[100px]" withTrailing />
|
||||
))}
|
||||
</div>
|
||||
) : data && data.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{(() => {
|
||||
const important = new Set([
|
||||
"strict-transport-security",
|
||||
"content-security-policy",
|
||||
"content-security-policy-report-only",
|
||||
"x-frame-options",
|
||||
"referrer-policy",
|
||||
"server",
|
||||
"x-powered-by",
|
||||
"cache-control",
|
||||
"permissions-policy",
|
||||
]);
|
||||
return data.map((h) => (
|
||||
<KeyValue
|
||||
key={`${h.name}:${String((h as { value: unknown }).value)}`}
|
||||
label={h.name}
|
||||
value={String((h as { value: unknown }).value)}
|
||||
copyable
|
||||
highlight={important.has(h.name)}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
) : isError ? (
|
||||
<ErrorWithRetry
|
||||
message="Failed to load headers."
|
||||
onRetryAction={onRetryAction}
|
||||
/>
|
||||
) : (
|
||||
<Empty className="border border-dashed">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Logs />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No HTTP headers detected</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
We couldn't fetch any HTTP response headers for this site. It
|
||||
may be offline or blocking requests.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
<SectionContent
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
data={data ?? null}
|
||||
isEmpty={(d) => !Array.isArray(d) || d.length === 0}
|
||||
renderLoading={() => (
|
||||
<KeyValueGrid colsSm={2}>
|
||||
<KeyValueSkeletonList
|
||||
count={12}
|
||||
widthClass="w-[100px]"
|
||||
withTrailing
|
||||
/>
|
||||
</KeyValueGrid>
|
||||
)}
|
||||
renderData={(d) => (
|
||||
<KeyValueGrid colsSm={2}>
|
||||
{(() => {
|
||||
const important = new Set([
|
||||
"strict-transport-security",
|
||||
"content-security-policy",
|
||||
"content-security-policy-report-only",
|
||||
"x-frame-options",
|
||||
"referrer-policy",
|
||||
"server",
|
||||
"x-powered-by",
|
||||
"cache-control",
|
||||
"permissions-policy",
|
||||
]);
|
||||
return d.map((h) => (
|
||||
<KeyValue
|
||||
key={`${h.name}:${String((h as { value: unknown }).value)}`}
|
||||
label={h.name}
|
||||
value={String((h as { value: unknown }).value)}
|
||||
copyable
|
||||
highlight={important.has(h.name)}
|
||||
/>
|
||||
));
|
||||
})()}
|
||||
</KeyValueGrid>
|
||||
)}
|
||||
renderError={() => (
|
||||
<ErrorWithRetry
|
||||
message="Failed to load headers."
|
||||
onRetryAction={onRetryAction}
|
||||
/>
|
||||
)}
|
||||
renderEmpty={() => (
|
||||
<Empty className="border border-dashed">
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<Logs />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>No HTTP headers detected</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
We couldn't fetch any HTTP response headers for this site.
|
||||
It may be offline or blocking requests.
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
)}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
@@ -6,6 +6,7 @@ import dynamic from "next/dynamic";
|
||||
import { ErrorWithRetry } from "@/components/domain/error-with-retry";
|
||||
import { Favicon } from "@/components/domain/favicon";
|
||||
import { KeyValue } from "@/components/domain/key-value";
|
||||
import { KeyValueGrid } from "@/components/domain/key-value-grid";
|
||||
import { KeyValueSkeleton } from "@/components/domain/key-value-skeleton";
|
||||
import { Section } from "@/components/domain/section";
|
||||
import {
|
||||
@@ -44,7 +45,7 @@ export function HostingEmailSection({
|
||||
<Section {...SECTION_DEFS.hosting} isError={isError} isLoading={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
<KeyValueGrid colsSm={3}>
|
||||
<KeyValueSkeleton label="DNS" withLeading widthClass="w-[100px]" />
|
||||
<KeyValueSkeleton
|
||||
label="Hosting"
|
||||
@@ -56,7 +57,7 @@ export function HostingEmailSection({
|
||||
withLeading
|
||||
widthClass="w-[100px]"
|
||||
/>
|
||||
</div>
|
||||
</KeyValueGrid>
|
||||
|
||||
<KeyValueSkeleton
|
||||
label="Location"
|
||||
@@ -69,7 +70,7 @@ export function HostingEmailSection({
|
||||
</>
|
||||
) : data ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-2 md:grid-cols-3">
|
||||
<KeyValueGrid colsSm={3} colsMd={3}>
|
||||
<KeyValue
|
||||
label="DNS"
|
||||
value={data.dnsProvider.name}
|
||||
@@ -109,7 +110,7 @@ export function HostingEmailSection({
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</KeyValueGrid>
|
||||
|
||||
{data.geo.lat != null && data.geo.lon != null ? (
|
||||
<>
|
||||
|
@@ -4,9 +4,11 @@ import { BadgeCheck, GraduationCap, HatGlasses } from "lucide-react";
|
||||
import { ErrorWithRetry } from "@/components/domain/error-with-retry";
|
||||
import { Favicon } from "@/components/domain/favicon";
|
||||
import { KeyValue } from "@/components/domain/key-value";
|
||||
import { KeyValueGrid } from "@/components/domain/key-value-grid";
|
||||
import { KeyValueSkeleton } from "@/components/domain/key-value-skeleton";
|
||||
import { RelativeExpiry } from "@/components/domain/relative-expiry";
|
||||
import { Section } from "@/components/domain/section";
|
||||
import { SectionContent } from "@/components/domain/section-content";
|
||||
import { RelativeExpiryBadge } from "@/components/domain/time-badges";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -43,23 +45,27 @@ export function RegistrationSection({
|
||||
isError={isError}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{isLoading ? (
|
||||
<>
|
||||
<SectionContent
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
data={data ?? null}
|
||||
renderLoading={() => (
|
||||
<KeyValueGrid colsSm={2}>
|
||||
<KeyValueSkeleton label="Registrar" withLeading withSuffix />
|
||||
<KeyValueSkeleton label="Registrant" />
|
||||
<KeyValueSkeleton label="Created" />
|
||||
<KeyValueSkeleton label="Expires" withSuffix />
|
||||
</>
|
||||
) : data ? (
|
||||
<>
|
||||
</KeyValueGrid>
|
||||
)}
|
||||
renderData={(d) => (
|
||||
<KeyValueGrid colsSm={2}>
|
||||
<KeyValue
|
||||
label="Registrar"
|
||||
value={data.registrarProvider?.name || ""}
|
||||
value={d.registrarProvider?.name || ""}
|
||||
leading={
|
||||
data.registrarProvider?.domain ? (
|
||||
d.registrarProvider?.domain ? (
|
||||
<Favicon
|
||||
domain={data.registrarProvider.domain}
|
||||
domain={d.registrarProvider.domain}
|
||||
size={16}
|
||||
className="rounded"
|
||||
/>
|
||||
@@ -77,37 +83,36 @@ export function RegistrationSection({
|
||||
<span>
|
||||
Verified by{" "}
|
||||
<span className="font-medium">
|
||||
{data.source === "rdap" &&
|
||||
Array.isArray(data.rdapServers) &&
|
||||
data.rdapServers.length > 0 ? (
|
||||
{d.source === "rdap" &&
|
||||
Array.isArray(d.rdapServers) &&
|
||||
d.rdapServers.length > 0 ? (
|
||||
<a
|
||||
href={
|
||||
data.rdapServers[data.rdapServers.length - 1] ??
|
||||
"#"
|
||||
d.rdapServers[d.rdapServers.length - 1] ?? "#"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
className="underline underline-offset-2"
|
||||
>
|
||||
{extractHostnameFromUrlish(
|
||||
data.rdapServers[data.rdapServers.length - 1],
|
||||
d.rdapServers[d.rdapServers.length - 1],
|
||||
) ?? "RDAP"}
|
||||
</a>
|
||||
) : (
|
||||
(data.whoisServer ?? "WHOIS")
|
||||
(d.whoisServer ?? "WHOIS")
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<a
|
||||
href={
|
||||
data.source === "rdap"
|
||||
d.source === "rdap"
|
||||
? "https://rdap.rcode3.com/"
|
||||
: "https://en.wikipedia.org/wiki/WHOIS"
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
title={`Learn about ${
|
||||
data.source === "rdap" ? "RDAP" : "WHOIS"
|
||||
d.source === "rdap" ? "RDAP" : "WHOIS"
|
||||
}`}
|
||||
className="text-muted/80"
|
||||
>
|
||||
@@ -122,12 +127,12 @@ export function RegistrationSection({
|
||||
<KeyValue
|
||||
label="Registrant"
|
||||
value={
|
||||
data.privacyEnabled || !registrant
|
||||
d.privacyEnabled || !registrant
|
||||
? "Hidden"
|
||||
: formatRegistrant(registrant)
|
||||
}
|
||||
leading={
|
||||
data.privacyEnabled || !registrant ? (
|
||||
d.privacyEnabled || !registrant ? (
|
||||
<HatGlasses className="stroke-muted-foreground" />
|
||||
) : undefined
|
||||
}
|
||||
@@ -135,41 +140,39 @@ export function RegistrationSection({
|
||||
|
||||
<KeyValue
|
||||
label="Created"
|
||||
value={formatDate(data.creationDate || "Unknown")}
|
||||
value={formatDate(d.creationDate || "Unknown")}
|
||||
valueTooltip={
|
||||
data.creationDate
|
||||
? formatDateTimeUtc(data.creationDate)
|
||||
: undefined
|
||||
d.creationDate ? formatDateTimeUtc(d.creationDate) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<KeyValue
|
||||
label="Expires"
|
||||
value={formatDate(data.expirationDate || "Unknown")}
|
||||
value={formatDate(d.expirationDate || "Unknown")}
|
||||
valueTooltip={
|
||||
data.expirationDate
|
||||
? formatDateTimeUtc(data.expirationDate)
|
||||
d.expirationDate
|
||||
? formatDateTimeUtc(d.expirationDate)
|
||||
: undefined
|
||||
}
|
||||
suffix={
|
||||
data.expirationDate ? (
|
||||
<RelativeExpiry
|
||||
to={data.expirationDate}
|
||||
d.expirationDate ? (
|
||||
<RelativeExpiryBadge
|
||||
to={d.expirationDate}
|
||||
dangerDays={30}
|
||||
warnDays={60}
|
||||
className="text-[11px]"
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</>
|
||||
) : isError ? (
|
||||
</KeyValueGrid>
|
||||
)}
|
||||
renderError={() => (
|
||||
<ErrorWithRetry
|
||||
message="Failed to load WHOIS."
|
||||
onRetryAction={onRetryAction}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
@@ -1,26 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNowStrict } from "date-fns";
|
||||
import { ClockFading } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { formatTtl } from "@/lib/format";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type RelativeExpiryProps = {
|
||||
/** ISO date string */
|
||||
to: string;
|
||||
export function RelativeExpiryBadge({
|
||||
to,
|
||||
dangerDays = 7,
|
||||
warnDays = 30,
|
||||
className,
|
||||
}: {
|
||||
/** ISO date string */ to: string;
|
||||
/** days threshold for red (imminent) */
|
||||
dangerDays?: number;
|
||||
/** days threshold for yellow (soon) */
|
||||
warnDays?: number;
|
||||
/** className applied to the wrapper span */
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function RelativeExpiry({
|
||||
to,
|
||||
dangerDays = 7,
|
||||
warnDays = 30,
|
||||
className,
|
||||
}: RelativeExpiryProps) {
|
||||
}) {
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<"danger" | "warn" | "ok" | null>(null);
|
||||
|
||||
@@ -52,3 +57,22 @@ export function RelativeExpiry({
|
||||
|
||||
return <span className={cn(colorClass, className)}>({text})</span>;
|
||||
}
|
||||
|
||||
export function TtlTimeBadge({ ttl }: { ttl: number }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="cursor-default text-[11px] text-muted-foreground"
|
||||
>
|
||||
<ClockFading />
|
||||
{formatTtl(ttl)}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span className="font-mono">{ttl}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
@@ -1,24 +0,0 @@
|
||||
/* @vitest-environment jsdom */
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { TtlBadge } from "./ttl-badge";
|
||||
|
||||
vi.mock("@/components/ui/tooltip", () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-slot="tooltip">{children}</div>
|
||||
),
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-slot="tooltip-trigger">{children}</div>
|
||||
),
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) => (
|
||||
<div data-slot="tooltip-content">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("TtlBadge", () => {
|
||||
it("renders formatted ttl and raw ttl in tooltip", () => {
|
||||
render(<TtlBadge ttl={3660} />);
|
||||
expect(screen.getByText("1h 1m")).toBeInTheDocument();
|
||||
expect(screen.getByText("3660")).toBeInTheDocument();
|
||||
});
|
||||
});
|
@@ -1,29 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { ClockFading } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { formatTtl } from "@/lib/format";
|
||||
|
||||
export function TtlBadge({ ttl }: { ttl: number }) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="cursor-default text-[11px] text-muted-foreground"
|
||||
>
|
||||
<ClockFading />
|
||||
{formatTtl(ttl)}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span className="font-mono">{ttl}</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user