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 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,22 +181,26 @@ 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>
|
||||||
|
<span className="text-xs text-slate-600">{timeAgo(statusTime)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={toggleStatus}
|
onClick={toggleStatus}
|
||||||
@@ -193,21 +215,51 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Severity + title + description */}
|
{/* Title + description */}
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
<span className={`text-xs font-bold uppercase tracking-widest ${severityText[alert.severity]}`}>
|
<h1 className="text-2xl font-bold text-white leading-snug">{alert.title}</h1>
|
||||||
{severityLabel[alert.severity]}
|
<p className="text-sm text-slate-400 leading-relaxed">{alert.description}</p>
|
||||||
</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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Comments ─────────────────────────────────────────────────── */}
|
{/* ── Comments ────────────────────────────────────────────────────── */}
|
||||||
<div className="lg:col-span-2 flex flex-col gap-4">
|
<section className="space-y-4">
|
||||||
|
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-600">
|
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-600">
|
||||||
Comments
|
Comments
|
||||||
</h2>
|
</h2>
|
||||||
@@ -246,9 +298,8 @@ export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 },
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user