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:
@@ -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,22 +181,26 @@ 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>
|
||||
<span className="text-slate-700">·</span>
|
||||
<span className="text-xs text-slate-600">{timeAgo(statusTime)}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={toggleStatus}
|
||||
@@ -193,21 +215,51 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
|
||||
</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>
|
||||
{/* 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>
|
||||
|
||||
{/* Metadata row */}
|
||||
<div className="flex flex-wrap items-center gap-2 pt-1 border-t border-slate-700/30">
|
||||
|
||||
{/* Dates */}
|
||||
<Chip label={`Opened ${fullDate(alert.firstSeen)}`} dim />
|
||||
{!isOpen && alert.closedAt && (
|
||||
<Chip label={`${isResolved ? "Resolved" : "Closed"} ${fullDate(alert.closedAt)}`} dim />
|
||||
)}
|
||||
|
||||
{/* 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"
|
||||
>
|
||||
{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 ─────────────────────────────────────────────────── */}
|
||||
<div className="lg:col-span-2 flex flex-col gap-4">
|
||||
|
||||
{/* ── Comments ────────────────────────────────────────────────────── */}
|
||||
<section className="space-y-4">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-600">
|
||||
Comments
|
||||
</h2>
|
||||
@@ -246,9 +298,8 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user