Add Radarr/Sonarr links and richer metadata to alert detail

- Thread titleSlug through RadarrMovie/SonarrSeries → MediaEntry →
  AlertCandidate, building a direct mediaUrl server-side at alert
  generation time (RADARR_URL/movie/slug, SONARR_URL/series/slug)
- Alert detail page: single-column full-width layout, metadata chips
  row showing opened/closed dates, media type, and a "View in Radarr/
  Sonarr" external link button when available
- Comments section sits below the full-width overview card

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 11:50:23 -04:00
parent c86b8ff33a
commit 4b2c82cf90
6 changed files with 143 additions and 75 deletions

View File

@@ -4,8 +4,6 @@ import { useState, useRef, useEffect } from "react";
import Link from "next/link";
import { Alert, AlertSeverity, AlertComment } from "@/lib/types";
// ── Severity theming ─────────────────────────────────────────────────────────
const severityAccent: Record<AlertSeverity, string> = {
danger: "border-l-red-500",
warning: "border-l-yellow-500",
@@ -24,8 +22,6 @@ const severityLabel: Record<AlertSeverity, string> = {
info: "Info",
};
// ── Helpers ───────────────────────────────────────────────────────────────────
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
@@ -36,12 +32,33 @@ function timeAgo(iso: string): string {
return `${Math.floor(hrs / 24)}d ago`;
}
function fullDate(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
month: "short", day: "numeric", year: "numeric",
hour: "numeric", minute: "2-digit",
});
}
function shortDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
month: "short", day: "numeric", hour: "numeric", minute: "2-digit",
});
}
// ── Meta chip ─────────────────────────────────────────────────────────────────
function Chip({ label, dim }: { label: string; dim?: boolean }) {
return (
<span className={`inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-medium ${
dim
? "border-slate-700/40 bg-slate-800/40 text-slate-500"
: "border-slate-700 bg-slate-800 text-slate-300"
}`}>
{label}
</span>
);
}
// ── Comment row ───────────────────────────────────────────────────────────────
function CommentRow({ comment }: { comment: AlertComment }) {
@@ -85,7 +102,7 @@ function CommentRow({ comment }: { comment: AlertComment }) {
);
}
// ── Main component ────────────────────────────────────────────────────────────
// ── Main ──────────────────────────────────────────────────────────────────────
export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
const [alert, setAlert] = useState<Alert>(initialAlert);
@@ -145,8 +162,9 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
const statusTime = isOpen ? alert.firstSeen : (alert.closedAt ?? alert.firstSeen);
return (
<main className="mx-auto max-w-5xl px-4 py-8 space-y-6">
<main className="mx-auto max-w-5xl px-6 py-8 space-y-6">
{/* Back */}
<Link
href="/?tab=alerts"
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors"
@@ -163,92 +181,125 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6 items-start">
{/* ── Alert overview ──────────────────────────────────────────────── */}
<div className={`rounded-xl bg-slate-800/40 border border-slate-700/60 border-l-4 overflow-hidden ${severityAccent[alert.severity]}`}>
<div className="px-6 py-6 space-y-5">
{/* ── Alert detail ─────────────────────────────────────────────── */}
<div className={`lg:col-span-3 rounded-xl bg-slate-800/40 border border-slate-700/60 border-l-4 overflow-hidden ${severityAccent[alert.severity]}`}>
<div className="px-6 py-6 space-y-4">
{/* Status row */}
<div className="flex items-center justify-between gap-3">
{/* Top row: status + action */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex items-center gap-2.5 flex-wrap">
<span className={`text-xs font-bold uppercase tracking-widest ${severityText[alert.severity]}`}>
{severityLabel[alert.severity]}
</span>
<span className="text-slate-700">·</span>
<span className={`inline-flex items-center gap-1.5 text-xs font-semibold ${
isOpen ? "text-green-400" : isResolved ? "text-teal-400" : "text-slate-500"
}`}>
{isOpen && <span className="h-1.5 w-1.5 rounded-full bg-green-400 shrink-0" />}
{isOpen ? "Open" : isResolved ? "Auto-resolved" : "Closed"}
<span className="text-slate-700 font-normal">·</span>
<span className="text-slate-600 font-normal">{timeAgo(statusTime)}</span>
</span>
<button
onClick={toggleStatus}
disabled={actionLoading}
className={`rounded-lg px-4 py-1.5 text-xs font-semibold transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
isOpen
? "bg-slate-700 hover:bg-slate-600 text-white"
: "bg-green-900/50 hover:bg-green-800/50 text-green-300 border border-green-800"
}`}
>
{actionLoading ? "…" : isOpen ? "Close" : "Reopen"}
</button>
</div>
{/* Severity + title + description */}
<div className="space-y-1.5">
<span className={`text-xs font-bold uppercase tracking-widest ${severityText[alert.severity]}`}>
{severityLabel[alert.severity]}
</span>
<h1 className="text-xl font-bold text-white leading-snug">{alert.title}</h1>
<p className="text-sm text-slate-400 leading-relaxed pt-1">{alert.description}</p>
<span className="text-slate-700">·</span>
<span className="text-xs text-slate-600">{timeAgo(statusTime)}</span>
</div>
<button
onClick={toggleStatus}
disabled={actionLoading}
className={`rounded-lg px-4 py-1.5 text-xs font-semibold transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
isOpen
? "bg-slate-700 hover:bg-slate-600 text-white"
: "bg-green-900/50 hover:bg-green-800/50 text-green-300 border border-green-800"
}`}
>
{actionLoading ? "…" : isOpen ? "Close" : "Reopen"}
</button>
</div>
</div>
{/* ── Comments ─────────────────────────────────────────────────── */}
<div className="lg:col-span-2 flex flex-col gap-4">
{/* Title + description */}
<div className="space-y-2">
<h1 className="text-2xl font-bold text-white leading-snug">{alert.title}</h1>
<p className="text-sm text-slate-400 leading-relaxed">{alert.description}</p>
</div>
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-600">
Comments
</h2>
{/* Metadata row */}
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-slate-700/30">
<div className="space-y-4">
{alert.comments.length === 0 && (
<p className="text-sm text-slate-700">No comments yet.</p>
{/* Dates */}
<Chip label={`Opened ${fullDate(alert.firstSeen)}`} dim />
{!isOpen && alert.closedAt && (
<Chip label={`${isResolved ? "Resolved" : "Closed"} ${fullDate(alert.closedAt)}`} dim />
)}
{alert.comments.map((c) => (
<CommentRow key={c.id} comment={c} />
))}
<div ref={bottomRef} />
</div>
<form onSubmit={submitComment} className="flex flex-col gap-2 pt-2 border-t border-slate-800">
<textarea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (commentText.trim()) submitComment(e as unknown as React.FormEvent);
}
}}
placeholder="Add a comment…"
rows={3}
className="w-full rounded-lg bg-slate-800/40 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-700 px-4 py-3 focus:outline-none resize-none transition-colors"
/>
<div className="flex justify-end">
<button
type="submit"
disabled={commentLoading || !commentText.trim()}
className="rounded-lg bg-slate-700 hover:bg-slate-600 disabled:opacity-40 disabled:cursor-not-allowed px-4 py-2 text-sm font-medium text-white transition-colors"
{/* Media type */}
{alert.mediaType && (
<Chip label={alert.mediaType === "movie" ? "Movie" : "TV Show"} />
)}
{/* User (for user-behavior alerts) */}
{alert.userName && !alert.mediaTitle && (
<Chip label={alert.userName} />
)}
{/* View in Radarr/Sonarr */}
{alert.mediaUrl && (
<a
href={alert.mediaUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-md border border-slate-600 bg-slate-700/60 hover:bg-slate-700 px-2.5 py-1 text-xs font-medium text-slate-300 hover:text-white transition-colors"
>
{commentLoading ? "Saving…" : "Comment"}
</button>
</div>
</form>
{alert.mediaType === "movie" ? "View in Radarr" : "View in Sonarr"}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3 w-3">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</a>
)}
</div>
</div>
</div>
{/* ── Comments ────────────────────────────────────────────────────── */}
<section className="space-y-4">
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-600">
Comments
</h2>
<div className="space-y-4">
{alert.comments.length === 0 && (
<p className="text-sm text-slate-700">No comments yet.</p>
)}
{alert.comments.map((c) => (
<CommentRow key={c.id} comment={c} />
))}
<div ref={bottomRef} />
</div>
<form onSubmit={submitComment} className="flex flex-col gap-2 pt-2 border-t border-slate-800">
<textarea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (commentText.trim()) submitComment(e as unknown as React.FormEvent);
}
}}
placeholder="Add a comment…"
rows={3}
className="w-full rounded-lg bg-slate-800/40 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-700 px-4 py-3 focus:outline-none resize-none transition-colors"
/>
<div className="flex justify-end">
<button
type="submit"
disabled={commentLoading || !commentText.trim()}
className="rounded-lg bg-slate-700 hover:bg-slate-600 disabled:opacity-40 disabled:cursor-not-allowed px-4 py-2 text-sm font-medium text-white transition-colors"
>
{commentLoading ? "Saving…" : "Comment"}
</button>
</div>
</form>
</section>
</main>
);
}

View File

@@ -128,6 +128,9 @@ export function generateAlertCandidates(
if (flaggedMovies.has(tmdbId)) continue;
flaggedMovies.add(tmdbId);
const byStr = requestedBy.slice(0, 3).join(", ") + (requestedBy.length > 3 ? ` +${requestedBy.length - 3}` : "");
const mediaUrl = entry.titleSlug && process.env.RADARR_URL
? `${process.env.RADARR_URL}/movie/${entry.titleSlug}`
: undefined;
candidates.push({
key: `unfulfilled:movie:${tmdbId}`,
category: "unfulfilled",
@@ -137,6 +140,7 @@ export function generateAlertCandidates(
mediaId: tmdbId,
mediaType: "movie",
mediaTitle: entry.title,
mediaUrl,
});
}
@@ -151,6 +155,9 @@ export function generateAlertCandidates(
const description = pct !== null
? `Only ${pct}% of episodes downloaded (${sonarrEntry!.episodeFileCount}/${sonarrEntry!.totalEpisodeCount}). Approved ${formatAge(oldestAgeHours)} ago. Requested by ${byStr}.`
: `Approved ${formatAge(oldestAgeHours)} ago but no files found in Sonarr. Requested by ${byStr}.`;
const mediaUrl = sonarrEntry?.titleSlug && process.env.SONARR_URL
? `${process.env.SONARR_URL}/series/${sonarrEntry.titleSlug}`
: undefined;
candidates.push({
key: `unfulfilled:tv:${tvdbId}`,
category: "unfulfilled",
@@ -160,6 +167,7 @@ export function generateAlertCandidates(
mediaId: tvdbId,
mediaType: "tv",
mediaTitle: entry.title,
mediaUrl,
});
}

View File

@@ -48,6 +48,7 @@ interface StoredAlert {
mediaId?: number;
mediaType?: string;
mediaTitle?: string;
mediaUrl?: string;
status: AlertStatus;
closeReason: AlertCloseReason | null;
suppressedUntil: string | null;
@@ -84,6 +85,7 @@ function toAlert(s: StoredAlert): Alert {
mediaId: s.mediaId,
mediaType: s.mediaType as Alert["mediaType"],
mediaTitle: s.mediaTitle,
mediaUrl: s.mediaUrl,
status: s.status,
closeReason: s.closeReason ?? null,
suppressedUntil: s.suppressedUntil,
@@ -147,6 +149,7 @@ export function upsertAlerts(candidates: AlertCandidate[]): number {
existing.description = c.description;
if (c.userName) existing.userName = c.userName;
if (c.mediaTitle) existing.mediaTitle = c.mediaTitle;
if (c.mediaUrl) existing.mediaUrl = c.mediaUrl;
} else {
store.alerts[c.key] = {
id: store.nextId++,
@@ -160,6 +163,7 @@ export function upsertAlerts(candidates: AlertCandidate[]): number {
mediaId: c.mediaId,
mediaType: c.mediaType,
mediaTitle: c.mediaTitle,
mediaUrl: c.mediaUrl,
status: "open",
closeReason: null,
suppressedUntil: null,

View File

@@ -13,7 +13,7 @@ export async function buildRadarrMap(): Promise<Map<number, MediaEntry>> {
return new Map(
movies.map((m) => [
m.tmdbId,
{ title: m.title, sizeOnDisk: m.sizeOnDisk, available: m.isAvailable },
{ title: m.title, titleSlug: m.titleSlug, sizeOnDisk: m.sizeOnDisk, available: m.isAvailable },
])
);
}

View File

@@ -15,6 +15,7 @@ export async function buildSonarrMap(): Promise<Map<number, MediaEntry>> {
s.tvdbId,
{
title: s.title,
titleSlug: s.titleSlug,
sizeOnDisk: s.statistics.sizeOnDisk,
// "upcoming" = series hasn't started airing yet
available: s.status !== "upcoming",

View File

@@ -23,6 +23,7 @@ export interface OverseerrRequest {
export interface RadarrMovie {
tmdbId: number;
title: string;
titleSlug: string;
sizeOnDisk: number; // bytes
isAvailable: boolean; // false = unreleased / below minimum availability
}
@@ -30,6 +31,7 @@ export interface RadarrMovie {
export interface SonarrSeries {
tvdbId: number;
title: string;
titleSlug: string;
status: string; // "continuing" | "ended" | "upcoming" | "deleted"
statistics: {
sizeOnDisk: number; // bytes
@@ -51,6 +53,7 @@ export interface TautulliUser {
export interface MediaEntry {
title: string;
titleSlug?: string;
sizeOnDisk: number; // bytes
available: boolean; // false = unreleased, skip unfulfilled alerts
// TV-specific (undefined for movies)
@@ -112,6 +115,7 @@ export interface AlertCandidate {
mediaId?: number;
mediaType?: "movie" | "tv";
mediaTitle?: string;
mediaUrl?: string; // direct link to the item in Radarr/Sonarr
}
export interface AlertComment {