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
2 changes: 2 additions & 0 deletions dashboard/src/components/layout/app-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { ReactNode } from "react";
import { TooltipProvider } from "@/components/ui";
import { useApplyAccent } from "@/features/settings";
import { CommandPalette } from "./command-palette";
import { Header } from "./header";
import { RouteErrorBoundary } from "./route-error-boundary";
import { Sidebar } from "./sidebar";

export function AppShell({ children }: { children: ReactNode }) {
useApplyAccent();
return (
<TooltipProvider delayDuration={300}>
<div className="flex min-h-screen bg-[var(--bg)] text-[var(--fg)]">
Expand Down
29 changes: 27 additions & 2 deletions dashboard/src/components/layout/mobile-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
Box,
CircuitBoard,
Cog,
ExternalLink as ExternalLinkIcon,
LayoutDashboard,
ListTree,
type LucideIcon,
Expand All @@ -23,8 +24,8 @@ import {
SheetTitle,
SheetTrigger,
} from "@/components/ui";
import { useBranding, useExternalLinks } from "@/features/settings";
import { cn } from "@/lib/cn";
import { site } from "@/lib/site";

interface NavItem {
to: string;
Expand Down Expand Up @@ -66,6 +67,8 @@ const NAV: Array<{ title: string; items: NavItem[] }> = [

export function MobileMenu() {
const { pathname } = useLocation();
const { title } = useBranding();
const externalLinks = useExternalLinks();
const [open, setOpen] = useState(false);

// Close on navigation
Expand All @@ -82,7 +85,7 @@ export function MobileMenu() {
</SheetTrigger>
<SheetContent side="left" className="flex flex-col p-0">
<SheetHeader className="border-b border-[var(--border)] px-5 py-4">
<SheetTitle>{site.name} Dashboard</SheetTitle>
<SheetTitle>{title} Dashboard</SheetTitle>
</SheetHeader>
<nav className="flex-1 overflow-y-auto px-3 py-3">
{NAV.map((group) => (
Expand Down Expand Up @@ -113,6 +116,28 @@ export function MobileMenu() {
</ul>
</div>
))}
{externalLinks.length > 0 ? (
<div className="mt-4">
<div className="px-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-[var(--fg-subtle)]">
Links
</div>
<ul className="flex flex-col gap-0.5">
{externalLinks.map((link) => (
<li key={`${link.label}|${link.url}`}>
<a
href={link.url}
target="_blank"
rel="noreferrer noopener"
className="flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-[var(--fg-muted)] transition-colors hover:bg-[var(--surface-2)] hover:text-[var(--fg)]"
>
<ExternalLinkIcon className="size-4" aria-hidden />
<span className="truncate">{link.label}</span>
</a>
</li>
))}
</ul>
</div>
) : null}
</nav>
</SheetContent>
</Sheet>
Expand Down
29 changes: 27 additions & 2 deletions dashboard/src/components/layout/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Box,
CircuitBoard,
Cog,
ExternalLink as ExternalLinkIcon,
LayoutDashboard,
ListTree,
type LucideIcon,
Expand All @@ -14,8 +15,8 @@ import {
Settings2,
Skull,
} from "lucide-react";
import { useBranding, useExternalLinks } from "@/features/settings";
import { cn } from "@/lib/cn";
import { site } from "@/lib/site";

interface NavItem {
to: string;
Expand Down Expand Up @@ -62,14 +63,16 @@ const NAV: NavGroup[] = [

export function Sidebar() {
const { pathname } = useLocation();
const { title } = useBranding();
const externalLinks = useExternalLinks();
return (
<aside className="hidden lg:flex w-60 shrink-0 flex-col border-r border-[var(--border)] bg-[var(--bg-subtle)]">
<div className="flex items-center gap-2 px-5 py-4">
<div className="grid place-items-center size-7 rounded-md bg-accent text-accent-fg">
<AlertOctagon className="size-4" aria-hidden />
</div>
<div className="flex flex-col leading-tight">
<span className="text-sm font-semibold tracking-tight">{site.name}</span>
<span className="text-sm font-semibold tracking-tight">{title}</span>
<span className="text-[10px] uppercase tracking-wider text-[var(--fg-subtle)]">
Dashboard
</span>
Expand Down Expand Up @@ -104,6 +107,28 @@ export function Sidebar() {
</ul>
</div>
))}
{externalLinks.length > 0 ? (
<div className="mt-5">
<div className="px-2 pb-1.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--fg-subtle)]">
Links
</div>
<ul className="flex flex-col gap-0.5">
{externalLinks.map((link) => (
<li key={`${link.label}|${link.url}`}>
<a
href={link.url}
target="_blank"
rel="noreferrer noopener"
className="flex items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-[var(--fg-muted)] transition-colors hover:bg-[var(--surface-2)] hover:text-[var(--fg)]"
>
<ExternalLinkIcon className="size-4" aria-hidden />
<span className="truncate">{link.label}</span>
</a>
</li>
))}
</ul>
</div>
) : null}
</nav>
<div className="border-t border-[var(--border)] px-5 py-3 text-[11px] text-[var(--fg-subtle)]">
{import.meta.env.DEV ? "dev build" : "production"}
Expand Down
47 changes: 46 additions & 1 deletion dashboard/src/features/jobs/components/job-overview-tab.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { ExternalLink as ExternalLinkIcon } from "lucide-react";
import type { ReactNode } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui";
import { buttonVariants, Card, CardContent, CardHeader, CardTitle } from "@/components/ui";
import { applyJobContext, useIntegrations } from "@/features/settings";
import type { Job } from "@/lib/api-types";
import { cn } from "@/lib/cn";
import { formatAbsolute, formatDuration, formatRelative } from "@/lib/time";

interface JobOverviewTabProps {
Expand All @@ -13,6 +16,7 @@ export function JobOverviewTab({ job }: JobOverviewTabProps) {

return (
<div className="grid gap-4 lg:grid-cols-2">
<JobIntegrations job={job} />
<Card>
<CardHeader className="pb-2">
<CardTitle>Identity</CardTitle>
Expand Down Expand Up @@ -132,3 +136,44 @@ function tryPrettyJson(raw: string): string {
return raw;
}
}

/**
* Render configured integration shortcuts (Grafana / Sentry / OTel) for
* the given job. Each URL may contain a ``{job_id}`` placeholder; if it
* doesn't, the configured URL opens as-is. Renders nothing when no
* integration is configured.
*/
function JobIntegrations({ job }: { job: Job }) {
const integrations = useIntegrations();
const links = (
[
{ label: "Open in Grafana", href: integrations.grafana },
{ label: "Open in Sentry", href: integrations.sentry },
{ label: "Open in OTel", href: integrations.otel },
] as const
).filter((entry) => entry.href);

if (links.length === 0) return null;

return (
<Card className="lg:col-span-2">
<CardHeader className="pb-2">
<CardTitle>Integrations</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap gap-2">
{links.map(({ label, href }) => (
<a
key={label}
href={applyJobContext(href, job.id)}
target="_blank"
rel="noreferrer noopener"
className={cn(buttonVariants({ variant: "outline", size: "sm" }))}
>
<ExternalLinkIcon className="size-4" aria-hidden />
{label}
</a>
))}
</CardContent>
</Card>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
CardTitle,
Input,
} from "@/components/ui";
import { parseExternalLinks } from "../derived";
import { useDeleteSetting, useUpdateSetting } from "../hooks";
import { type ExternalLink, SETTING_KEYS, type SettingsSnapshot } from "../types";

Expand All @@ -23,30 +24,6 @@ function draftId(): string {
return crypto.randomUUID();
}

/**
* Parse the JSON-encoded ``external_links`` setting into a typed list,
* tolerating malformed values (returns ``[]``) so a bad write never
* breaks the page.
*/
function parseLinks(raw: string | undefined): ExternalLink[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed
.filter(
(item): item is ExternalLink =>
typeof item === "object" &&
item !== null &&
typeof (item as ExternalLink).label === "string" &&
typeof (item as ExternalLink).url === "string",
)
.map((item) => ({ label: item.label, url: item.url }));
} catch {
return [];
}
}

/**
* User-defined links rendered in the sidebar (e.g. wiki, runbook, status
* page). Stored as a single JSON-encoded array under
Expand All @@ -56,7 +33,10 @@ export function ExternalLinksSection({ settings }: { settings: SettingsSnapshot
const update = useUpdateSetting();
const remove = useDeleteSetting();

const initial = useMemo(() => parseLinks(settings[SETTING_KEYS.externalLinks]), [settings]);
const initial = useMemo(
() => parseExternalLinks(settings[SETTING_KEYS.externalLinks]),
[settings],
);
const [links, setLinks] = useState<DraftLink[]>(() =>
initial.map((link) => ({ ...link, id: draftId() })),
);
Expand Down
92 changes: 92 additions & 0 deletions dashboard/src/features/settings/derived.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { useEffect } from "react";
import { site } from "@/lib/site";
import { useSettings } from "./hooks";
import { type ExternalLink, type IntegrationUrls, SETTING_KEYS } from "./types";

/**
* Parse the JSON-encoded ``external_links`` setting into a typed list,
* tolerating malformed values (returns ``[]``) so a bad write never
* breaks the page.
*/
export function parseExternalLinks(raw: string | undefined): ExternalLink[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed
.filter(
(item): item is ExternalLink =>
typeof item === "object" &&
item !== null &&
typeof (item as ExternalLink).label === "string" &&
typeof (item as ExternalLink).url === "string",
)
.map((item) => ({ label: item.label, url: item.url }));
} catch {
return [];
}
}

/** User-defined external links rendered in the sidebar. */
export function useExternalLinks(): ExternalLink[] {
const { data } = useSettings();
return parseExternalLinks(data?.[SETTING_KEYS.externalLinks]);
}

/** Configured integration URLs, with empty/whitespace values normalized. */
export function useIntegrations(): IntegrationUrls {
const { data } = useSettings();
return {
grafana: data?.[SETTING_KEYS.integrationGrafana]?.trim() ?? "",
sentry: data?.[SETTING_KEYS.integrationSentry]?.trim() ?? "",
otel: data?.[SETTING_KEYS.integrationOtel]?.trim() ?? "",
};
}

/**
* Substitute supported placeholders in an integration URL template.
*
* Currently supports ``{job_id}``. URLs without any placeholder are
* returned unchanged so users can configure either a templated deep
* link or a static landing page.
*/
export function applyJobContext(template: string, jobId: string): string {
return template.replaceAll("{job_id}", encodeURIComponent(jobId));
}

/**
* Resolved branding values. Falls back to the bundled defaults when no
* override is configured; never returns an empty string.
*/
export function useBranding(): { title: string } {
const { data } = useSettings();
const override = data?.[SETTING_KEYS.brandTitle]?.trim();
return { title: override || site.name };
}

/**
* Apply the configured accent color as a CSS variable on the root
* element. Invalid colors are silently ignored — the dashboard keeps
* the bundled default rather than producing broken styling.
*
* The dim variant (used for muted badges and hover states) is derived
* via ``color-mix`` so the override stays coherent with the base color.
*/
export function useApplyAccent(): void {
const { data } = useSettings();
const value = data?.[SETTING_KEYS.brandAccent]?.trim();
useEffect(() => {
const root = document.documentElement;
if (!value || !CSS.supports("color", value)) {
root.style.removeProperty("--color-accent");
root.style.removeProperty("--color-accent-dim");
return;
}
root.style.setProperty("--color-accent", value);
root.style.setProperty("--color-accent-dim", `color-mix(in srgb, ${value} 18%, transparent)`);
return () => {
root.style.removeProperty("--color-accent");
root.style.removeProperty("--color-accent-dim");
};
}, [value]);
}
8 changes: 8 additions & 0 deletions dashboard/src/features/settings/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,14 @@
export { BrandingSection } from "./components/branding-section";
export { ExternalLinksSection } from "./components/external-links-section";
export { IntegrationsSection } from "./components/integrations-section";
export {
applyJobContext,
parseExternalLinks,
useApplyAccent,
useBranding,
useExternalLinks,
useIntegrations,
} from "./derived";
export { settingsQuery, useDeleteSetting, useSettings, useUpdateSetting } from "./hooks";
export type { ExternalLink, IntegrationUrls, SettingsSnapshot } from "./types";
export { SETTING_KEYS } from "./types";
Loading