1
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:
2025-10-14 17:59:59 -04:00
parent 6771ba51bc
commit 3daf9bfd95
12 changed files with 267 additions and 221 deletions

View File

@@ -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 ? (

View 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>
);
}

View 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}
/>
))}
</>
);
}

View File

@@ -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);
});
});
});

View 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>;
}

View File

@@ -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 && (

View File

@@ -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&apos;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&apos;t fetch any HTTP response headers for this site.
It may be offline or blocking requests.
</EmptyDescription>
</EmptyHeader>
</Empty>
)}
/>
</Section>
);
}

View File

@@ -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 ? (
<>

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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();
});
});

View File

@@ -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>
);
}