Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 207 additions & 0 deletions components/layout/header/environment-badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
"use client";

import * as React from "react";
import { Badge } from "@/components/ui/badge";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
readBuildMetadata,
type BuildMetadata,
} from "@/lib/build-metadata";

const HOVER_OPEN_DELAY_MS = 200;
const HOVER_CLOSE_DELAY_MS = 150;

export function EnvironmentBadge() {
if (process.env.NODE_ENV !== "development") {
return null;
}

return <DevelopmentBadge metadata={readBuildMetadata()} />;
}

function DevelopmentBadge({ metadata }: { metadata: BuildMetadata }) {
const [open, setOpen] = React.useState(false);
const openSource = React.useRef<"explicit" | "hover" | null>(null);
const openTimer = React.useRef<number | null>(null);
const closeTimer = React.useRef<number | null>(null);
const closingFromHover = React.useRef(false);

React.useEffect(
() => () => {
if (openTimer.current !== null) {
window.clearTimeout(openTimer.current);
}
if (closeTimer.current !== null) {
window.clearTimeout(closeTimer.current);
}
},
[]
);

function clearTimers() {
if (openTimer.current !== null) {
window.clearTimeout(openTimer.current);
openTimer.current = null;
}

if (closeTimer.current !== null) {
window.clearTimeout(closeTimer.current);
closeTimer.current = null;
}
}

function openOnHover(event: React.PointerEvent) {
if (event.pointerType === "touch" || openSource.current === "explicit") {
return;
}

clearTimers();
openTimer.current = window.setTimeout(() => {
openSource.current = "hover";
setOpen(true);
}, HOVER_OPEN_DELAY_MS);
}

function closeAfterHover() {
clearTimers();

if (openSource.current !== "hover") {
return;
}

closeTimer.current = window.setTimeout(() => {
closingFromHover.current = true;
openSource.current = null;
setOpen(false);
}, HOVER_CLOSE_DELAY_MS);
}

function changeOpen(nextOpen: boolean) {
clearTimers();

if (nextOpen) {
openSource.current ??= "explicit";
} else {
closingFromHover.current = openSource.current === "hover";
openSource.current = null;
}

setOpen(nextOpen);
}

function pinHoveredPopover(event: React.MouseEvent) {
if (open && openSource.current === "hover") {
event.preventDefault();
clearTimers();
openSource.current = "explicit";
}
}

return (
<Popover open={open} onOpenChange={changeOpen}>
<Badge asChild variant="outline" className="max-w-28 rounded-sm font-mono">
<PopoverTrigger
type="button"
aria-label="Development environment. Show build details"
onClick={pinHoveredPopover}
onPointerEnter={openOnHover}
onPointerLeave={closeAfterHover}
>
<span className="truncate">DEV</span>
</PopoverTrigger>
</Badge>
<PopoverContent
aria-label="Build details"
align="start"
sideOffset={8}
collisionPadding={16}
className="w-[min(23rem,calc(100vw-2rem))] p-3 font-mono"
onPointerEnter={clearTimers}
onPointerLeave={closeAfterHover}
onOpenAutoFocus={(event) => {
if (openSource.current === "hover") {
event.preventDefault();
}
}}
onCloseAutoFocus={(event) => {
if (closingFromHover.current) {
event.preventDefault();
closingFromHover.current = false;
}
}}
>
<BuildDetails metadata={metadata} />
</PopoverContent>
</Popover>
);
}

export function BuildDetails({ metadata }: { metadata: BuildMetadata }) {
const builtAt = new Date(metadata.builtAt);
const builtAtLabel = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(builtAt);
const relativeBuiltAt = formatRelativeTime(builtAt, new Date());

return (
<dl className="grid grid-cols-[4.5rem_minmax(0,1fr)] gap-x-3 gap-y-1.5 text-xs leading-5">
<BuildDetail label="BRANCH" title={metadata.branch}>
{metadata.branch}
</BuildDetail>
<BuildDetail label="COMMIT" title={metadata.commitSha}>
{metadata.commitSha.slice(0, 8)}
{metadata.dirty ? <span className="text-muted-foreground"> (dirty)</span> : null}
</BuildDetail>
<BuildDetail label="MESSAGE" title={metadata.commitSubject}>
{metadata.commitSubject}
</BuildDetail>
<BuildDetail label="BUILT" title={builtAtLabel}>
<time dateTime={metadata.builtAt}>
{builtAtLabel} · {relativeBuiltAt}
</time>
</BuildDetail>
</dl>
);
}

function BuildDetail({
children,
label,
title,
}: {
children: React.ReactNode;
label: string;
title: string;
}) {
return (
<>
<dt className="font-medium text-muted-foreground">{label}</dt>
<dd className="min-w-0 truncate" title={title}>
{children}
</dd>
</>
);
}

function formatRelativeTime(date: Date, now: Date) {
const seconds = Math.round((date.getTime() - now.getTime()) / 1000);
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
const absoluteSeconds = Math.abs(seconds);

if (absoluteSeconds < 60) {
return formatter.format(seconds, "second");
}
if (absoluteSeconds < 60 * 60) {
return formatter.format(Math.round(seconds / 60), "minute");
}
if (absoluteSeconds < 60 * 60 * 24) {
return formatter.format(Math.round(seconds / (60 * 60)), "hour");
}

return formatter.format(Math.round(seconds / (60 * 60 * 24)), "day");
}
10 changes: 8 additions & 2 deletions components/layout/header/page-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { CategoryIcon } from "@/components/common/icons/category-icon";
import { Skeleton } from "@/components/ui/skeleton";
import { EnvironmentBadge } from "@/components/layout/header/environment-badge";

interface PageHeaderProps {
title: string;
Expand Down Expand Up @@ -78,14 +79,19 @@ export function PageHeader({ title, description, children, appLogo }: PageHeader
<LogoThumb key={logoKey} appLogo={appLogo} />
</Link>
<div>
<h1 className="text-4xl font-bold font-mono text-gradient-title">{title}</h1>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-4xl font-bold font-mono text-gradient-title">{title}</h1>
<EnvironmentBadge />
</div>
{description && (
<p className="text-muted-foreground mt-2">{description}</p>
)}
</div>
</div>
{children && (
<div className="flex items-center justify-end gap-2 md:gap-3 w-full md:w-auto">{children}</div>
<div className="flex items-center justify-end gap-2 md:gap-3 w-full md:w-auto">
{children}
</div>
)}
</div>
</div>
Expand Down
30 changes: 30 additions & 0 deletions lib/build-metadata.build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
import { parseBuildMetadata } from "./build-metadata";

const repositoryRoot = fileURLToPath(new URL("..", import.meta.url));

interface DiscoverBuildMetadataOptions {
repositoryRoot?: string;
}

export function discoverBuildMetadata(options: DiscoverBuildMetadataOptions = {}) {
const root = options.repositoryRoot ?? repositoryRoot;
const branch = runGit(["branch", "--show-current"], root) || "detached";

return parseBuildMetadata({
branch,
builtAt: new Date().toISOString(),
commitSha: runGit(["rev-parse", "HEAD"], root),
commitSubject: runGit(["log", "-1", "--format=%s", "HEAD"], root),
dirty: String(runGit(["status", "--porcelain=v1"], root).length > 0),
});
}

function runGit(args: string[], cwd: string) {
return execFileSync("git", args, {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "inherit"],
}).trim();
}
65 changes: 65 additions & 0 deletions lib/build-metadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
export interface BuildMetadata {
branch: string;
builtAt: string;
commitSha: string;
commitSubject: string;
dirty: boolean;
}

interface BuildMetadataInput {
branch: string | undefined;
builtAt: string | undefined;
commitSha: string | undefined;
commitSubject: string | undefined;
dirty: string | undefined;
}

const COMMIT_SHA_PATTERN = /^[0-9a-f]{7,64}$/i;
const ISO_8601_TIMESTAMP_PATTERN =
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;

export function readBuildMetadata(): BuildMetadata {
return parseBuildMetadata({
branch: process.env.APP_BUILD_BRANCH,
builtAt: process.env.APP_BUILD_AT,
commitSha: process.env.APP_BUILD_COMMIT_SHA,
commitSubject: process.env.APP_BUILD_COMMIT_SUBJECT,
dirty: process.env.APP_BUILD_DIRTY,
});
}

export function parseBuildMetadata(input: BuildMetadataInput): BuildMetadata {
const branch = requireValue("APP_BUILD_BRANCH", input.branch);
const builtAt = requireValue("APP_BUILD_AT", input.builtAt);
const commitSha = requireValue("APP_BUILD_COMMIT_SHA", input.commitSha);
const commitSubject = requireValue("APP_BUILD_COMMIT_SUBJECT", input.commitSubject);
const dirty = requireValue("APP_BUILD_DIRTY", input.dirty);

if (!COMMIT_SHA_PATTERN.test(commitSha)) {
throw new Error("APP_BUILD_COMMIT_SHA must be a Git commit SHA");
}

if (!ISO_8601_TIMESTAMP_PATTERN.test(builtAt) || Number.isNaN(Date.parse(builtAt))) {
throw new Error("APP_BUILD_AT must be an ISO 8601 timestamp");
}

if (dirty !== "false" && dirty !== "true") {
throw new Error('APP_BUILD_DIRTY must be either "true" or "false"');
}

return {
branch,
builtAt,
commitSha,
commitSubject,
dirty: dirty === "true",
};
}

function requireValue(name: string, value: string | undefined) {
if (!value) {
throw new Error(`${name} is required`);
}

return value;
}
30 changes: 22 additions & 8 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
import type { NextConfig } from "next";
import { PHASE_DEVELOPMENT_SERVER, type PHASE_TYPE } from "next/constants";
import { discoverBuildMetadata } from "./lib/build-metadata.build";
import packageJson from "./package.json";

const nextConfig: NextConfig = {
// Produce standalone output for self-hosted deploys (Docker or zipped bundle)
output: "standalone",
env: {
NEXT_PUBLIC_APP_VERSION: packageJson.version,
},
};
export default function createNextConfig(phase: PHASE_TYPE): NextConfig {
const buildMetadata =
phase === PHASE_DEVELOPMENT_SERVER ? discoverBuildMetadata() : null;

export default nextConfig;
return {
// Produce standalone output for self-hosted deploys (Docker or zipped bundle)
output: "standalone",
env: {
NEXT_PUBLIC_APP_VERSION: packageJson.version,
...(buildMetadata
? {
APP_BUILD_AT: buildMetadata.builtAt,
APP_BUILD_BRANCH: buildMetadata.branch,
APP_BUILD_COMMIT_SHA: buildMetadata.commitSha,
APP_BUILD_COMMIT_SUBJECT: buildMetadata.commitSubject,
APP_BUILD_DIRTY: String(buildMetadata.dirty),
}
: {}),
},
};
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "crapdash",
"version": "1.4.1",
"version": "1.4.2",
"private": true,
"license": "MIT",
"packageManager": "pnpm@11.0.3",
Expand Down
Loading