frontend: kanal detay sayfası (canlı durum + poll hatası + yayın geçmişi)

/channels/[id] rotası eklendi: dashboard'daki kanal adı artık oraya
link veriyor. Sayfa api-daemon'un /status'undan canlı durum ve son
poll hatasını (lastPollError, DB'de değil sadece bellekte tutuluyordu)
çekiyor, Prisma'dan da o kanala ait tüm StreamSession/RawSegment/
CandidateSegment geçmişini transkript önizlemesiyle listeliyor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 17:34:15 +03:00
co-authored by Claude Sonnet 5
parent b62d6a7e5f
commit 19fe430549
4 changed files with 181 additions and 10 deletions
+127
View File
@@ -0,0 +1,127 @@
import { notFound } from "next/navigation";
import Link from "next/link";
import { prisma } from "@streamclipper/db";
import { fetchChannelStatuses } from "../../../lib/apiDaemon";
export const dynamic = "force-dynamic";
const STATUS_BADGE: Record<string, string> = {
PENDING: "muted",
PROCESSED: "ok",
DISCARDED: "err",
PENDING_STT: "warn",
TRANSCRIBED: "ok",
FAILED: "err",
};
function transcriptPreview(transcriptJson: unknown): string | null {
if (!transcriptJson || typeof transcriptJson !== "object") return null;
const text = (transcriptJson as { text?: string }).text;
return text ? text.slice(0, 240) : null;
}
export default async function ChannelDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const [channel, statuses] = await Promise.all([
prisma.channel.findUnique({
where: { id },
include: {
sessions: {
orderBy: { startedAt: "desc" },
include: {
segments: {
orderBy: { createdAt: "desc" },
include: { candidates: { orderBy: { createdAt: "desc" } } },
},
},
},
},
}),
fetchChannelStatuses(),
]);
if (!channel) notFound();
const status = statuses.get(channel.id);
return (
<>
<p><Link href="/">&larr; Kanal Durumu</Link></p>
<h1>{channel.name}</h1>
<p className="mono">{channel.youtubeHandle} · {channel.channelId}</p>
<div className="card">
<div className="card-head">
<span>Canlı Durum</span>
<span className={`badge ${status?.recording ? "warn" : "muted"}`}>
{status?.recording ? "🔴 Kayıtta" : "Kayıtta değil"}
</span>
</div>
<div className="mono">
Aktif: {channel.isActive ? "evet" : "hayır"} ·{" "}
Son kontrol: {channel.lastCheckedAt ? new Date(channel.lastCheckedAt).toLocaleString("tr-TR") : "—"}
</div>
{status === undefined && (
<p className="empty" style={{ marginTop: "0.5rem" }}>
api-daemon&apos;dan canlı durum alınamadı (servis erişilemez olabilir).
</p>
)}
{status?.lastPollError && (
<div style={{ marginTop: "0.5rem" }}>
<span className="badge err">son poll hatası</span>
<pre className="mono" style={{ whiteSpace: "pre-wrap", marginTop: "0.4rem" }}>
{status.lastPollError}
</pre>
</div>
)}
</div>
<h1 style={{ marginTop: "2rem" }}>Yayın Geçmişi</h1>
{channel.sessions.length === 0 ? (
<p className="empty">Bu kanal için henüz bir kayıt oturumu yok.</p>
) : (
channel.sessions.map((session) => (
<div className="card" key={session.id}>
<div className="card-head">
<span className="mono">
{new Date(session.startedAt).toLocaleString("tr-TR")}
{session.endedAt ? ` ${new Date(session.endedAt).toLocaleString("tr-TR")}` : " (devam ediyor)"}
</span>
<span className="badge muted">{session.totalSegments} segment</span>
</div>
{session.segments.length === 0 ? (
<p className="empty" style={{ marginTop: "0.5rem" }}>Henüz segment yok.</p>
) : (
session.segments.map((segment) => (
<div key={segment.id} style={{ marginTop: "0.75rem", paddingLeft: "0.75rem", borderLeft: "2px solid var(--border)" }}>
<div className="card-head">
<span className="mono">{segment.filePath}</span>
<span className={`badge ${STATUS_BADGE[segment.status] ?? "muted"}`}>{segment.status}</span>
</div>
{segment.candidates.map((c) => (
<div key={c.id} style={{ marginTop: "0.5rem", paddingLeft: "0.75rem", borderLeft: "2px solid var(--border)" }}>
<div className="card-head">
<span className="mono">
{c.startSec}s {c.endSec}s
{c.audioPeakScore != null && ` · peak ${c.audioPeakScore.toFixed(1)}dB`}
</span>
<span className={`badge ${STATUS_BADGE[c.status] ?? "muted"}`}>{c.status}</span>
</div>
{transcriptPreview(c.transcriptJson) && (
<p className="transcript-preview">{transcriptPreview(c.transcriptJson)}</p>
)}
</div>
))}
</div>
))
)}
</div>
))
)}
</>
);
}
+17 -3
View File
@@ -1,10 +1,13 @@
import Link from "next/link";
import { prisma } from "@streamclipper/db"; import { prisma } from "@streamclipper/db";
import { addChannel } from "./actions"; import { addChannel } from "./actions";
import { fetchChannelStatuses } from "../lib/apiDaemon";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export default async function DashboardPage() { export default async function DashboardPage() {
const channels = await prisma.channel.findMany({ const [channels, statuses] = await Promise.all([
prisma.channel.findMany({
orderBy: { name: "asc" }, orderBy: { name: "asc" },
include: { include: {
sessions: { sessions: {
@@ -13,7 +16,9 @@ export default async function DashboardPage() {
take: 1, take: 1,
}, },
}, },
}); }),
fetchChannelStatuses(),
]);
return ( return (
<> <>
@@ -41,9 +46,13 @@ export default async function DashboardPage() {
<tbody> <tbody>
{channels.map((c) => { {channels.map((c) => {
const recording = c.sessions.length > 0; const recording = c.sessions.length > 0;
const status = statuses.get(c.id);
return ( return (
<tr key={c.id}> <tr key={c.id}>
<td>{c.name} <span className="mono">{c.youtubeHandle}</span></td> <td>
<Link href={`/channels/${c.id}`}>{c.name}</Link>{" "}
<span className="mono">{c.youtubeHandle}</span>
</td>
<td> <td>
<span className={`badge ${c.isActive ? "ok" : "muted"}`}> <span className={`badge ${c.isActive ? "ok" : "muted"}`}>
{c.isActive ? "Aktif" : "Pasif"} {c.isActive ? "Aktif" : "Pasif"}
@@ -53,6 +62,11 @@ export default async function DashboardPage() {
<span className={`badge ${recording ? "warn" : "muted"}`}> <span className={`badge ${recording ? "warn" : "muted"}`}>
{recording ? "🔴 Kayıtta" : "—"} {recording ? "🔴 Kayıtta" : "—"}
</span> </span>
{status?.lastPollError && (
<span className="badge err" style={{ marginLeft: "0.4rem" }}>
hata
</span>
)}
</td> </td>
<td className="mono"> <td className="mono">
{c.lastCheckedAt ? new Date(c.lastCheckedAt).toLocaleString("tr-TR") : "—"} {c.lastCheckedAt ? new Date(c.lastCheckedAt).toLocaleString("tr-TR") : "—"}
+28
View File
@@ -0,0 +1,28 @@
export interface ChannelStatus {
id: string;
name: string;
isActive: boolean;
lastCheckedAt: string | null;
recording: boolean;
activeSessionId: string | null;
lastPollError: string | null;
}
const API_DAEMON_URL = process.env.API_DAEMON_URL ?? "http://localhost:4001";
/**
* lastPollError only exists in api-daemon's in-memory map (not persisted to
* the DB), so the panel fetches it live over HTTP rather than via Prisma.
* Failures here (api-daemon down, network hiccup) shouldn't break the page —
* they just mean live-status fields fall back to unknown.
*/
export async function fetchChannelStatuses(): Promise<Map<string, ChannelStatus>> {
try {
const res = await fetch(`${API_DAEMON_URL}/status`, { cache: "no-store" });
if (!res.ok) return new Map();
const data = (await res.json()) as { channels: ChannelStatus[] };
return new Map(data.channels.map((c) => [c.id, c]));
} catch {
return new Map();
}
}
+2
View File
@@ -75,10 +75,12 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper
API_DAEMON_URL: http://sc_api_daemon:4001
ports: ports:
- "3000:3000" - "3000:3000"
depends_on: depends_on:
- sc_postgres - sc_postgres
- sc_api_daemon
volumes: volumes:
redis-data: redis-data: