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 Link from "next/link";
import { Alert, AlertSeverity, AlertComment } from "@/lib/types"; import { Alert, AlertSeverity, AlertComment } from "@/lib/types";
// ── Severity theming ─────────────────────────────────────────────────────────
const severityAccent: Record<AlertSeverity, string> = { const severityAccent: Record<AlertSeverity, string> = {
danger: "border-l-red-500", danger: "border-l-red-500",
warning: "border-l-yellow-500", warning: "border-l-yellow-500",
@@ -24,8 +22,6 @@ const severityLabel: Record<AlertSeverity, string> = {
info: "Info", info: "Info",
}; };
// ── Helpers ───────────────────────────────────────────────────────────────────
function timeAgo(iso: string): string { function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime(); const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000); const mins = Math.floor(diff / 60_000);
@@ -36,12 +32,33 @@ function timeAgo(iso: string): string {
return `${Math.floor(hrs / 24)}d ago`; 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 { function shortDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, { return new Date(iso).toLocaleDateString(undefined, {
month: "short", day: "numeric", hour: "numeric", minute: "2-digit", 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 ─────────────────────────────────────────────────────────────── // ── Comment row ───────────────────────────────────────────────────────────────
function CommentRow({ comment }: { comment: AlertComment }) { function CommentRow({ comment }: { comment: AlertComment }) {
@@ -85,7 +102,7 @@ function CommentRow({ comment }: { comment: AlertComment }) {
); );
} }
// ── Main component ──────────────────────────────────────────────────────────── // ── Main ──────────────────────────────────────────────────────────────────────
export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) { export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
const [alert, setAlert] = useState<Alert>(initialAlert); 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); const statusTime = isOpen ? alert.firstSeen : (alert.closedAt ?? alert.firstSeen);
return ( 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 <Link
href="/?tab=alerts" href="/?tab=alerts"
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors" 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>
)} )}
<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 ─────────────────────────────────────────────── */} {/* Top row: status + action */}
<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="flex items-center justify-between gap-4 flex-wrap">
<div className="px-6 py-6 space-y-4"> <div className="flex items-center gap-2.5 flex-wrap">
<span className={`text-xs font-bold uppercase tracking-widest ${severityText[alert.severity]}`}>
{/* Status row */} {severityLabel[alert.severity]}
<div className="flex items-center justify-between gap-3"> </span>
<span className="text-slate-700">·</span>
<span className={`inline-flex items-center gap-1.5 text-xs font-semibold ${ <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 ? "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 && <span className="h-1.5 w-1.5 rounded-full bg-green-400 shrink-0" />}
{isOpen ? "Open" : isResolved ? "Auto-resolved" : "Closed"} {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> </span>
<span className="text-slate-700">·</span>
<button <span className="text-xs text-slate-600">{timeAgo(statusTime)}</span>
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>
</div> </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>
</div>
{/* ── Comments ─────────────────────────────────────────────────── */} {/* Title + description */}
<div className="lg:col-span-2 flex flex-col gap-4"> <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"> {/* Metadata row */}
Comments <div className="flex flex-wrap items-center gap-2 pt-1 border-t border-slate-700/30">
</h2>
<div className="space-y-4"> {/* Dates */}
{alert.comments.length === 0 && ( <Chip label={`Opened ${fullDate(alert.firstSeen)}`} dim />
<p className="text-sm text-slate-700">No comments yet.</p> {!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"> {/* Media type */}
<textarea {alert.mediaType && (
value={commentText} <Chip label={alert.mediaType === "movie" ? "Movie" : "TV Show"} />
onChange={(e) => setCommentText(e.target.value)} )}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { {/* User (for user-behavior alerts) */}
e.preventDefault(); {alert.userName && !alert.mediaTitle && (
if (commentText.trim()) submitComment(e as unknown as React.FormEvent); <Chip label={alert.userName} />
} )}
}}
placeholder="Add a comment…" {/* View in Radarr/Sonarr */}
rows={3} {alert.mediaUrl && (
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" <a
/> href={alert.mediaUrl}
<div className="flex justify-end"> target="_blank"
<button rel="noopener noreferrer"
type="submit" 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"
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"} {alert.mediaType === "movie" ? "View in Radarr" : "View in Sonarr"}
</button> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3 w-3">
</div> <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" />
</form> </svg>
</a>
)}
</div>
</div> </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> </main>
); );
} }

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ export async function buildRadarrMap(): Promise<Map<number, MediaEntry>> {
return new Map( return new Map(
movies.map((m) => [ movies.map((m) => [
m.tmdbId, 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, s.tvdbId,
{ {
title: s.title, title: s.title,
titleSlug: s.titleSlug,
sizeOnDisk: s.statistics.sizeOnDisk, sizeOnDisk: s.statistics.sizeOnDisk,
// "upcoming" = series hasn't started airing yet // "upcoming" = series hasn't started airing yet
available: s.status !== "upcoming", available: s.status !== "upcoming",

View File

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