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:
@@ -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="/">← 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'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>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
+24
-10
@@ -1,19 +1,24 @@
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@streamclipper/db";
|
||||
import { addChannel } from "./actions";
|
||||
import { fetchChannelStatuses } from "../lib/apiDaemon";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function DashboardPage() {
|
||||
const channels = await prisma.channel.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
sessions: {
|
||||
where: { endedAt: null },
|
||||
orderBy: { startedAt: "desc" },
|
||||
take: 1,
|
||||
const [channels, statuses] = await Promise.all([
|
||||
prisma.channel.findMany({
|
||||
orderBy: { name: "asc" },
|
||||
include: {
|
||||
sessions: {
|
||||
where: { endedAt: null },
|
||||
orderBy: { startedAt: "desc" },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
fetchChannelStatuses(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -41,9 +46,13 @@ export default async function DashboardPage() {
|
||||
<tbody>
|
||||
{channels.map((c) => {
|
||||
const recording = c.sessions.length > 0;
|
||||
const status = statuses.get(c.id);
|
||||
return (
|
||||
<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>
|
||||
<span className={`badge ${c.isActive ? "ok" : "muted"}`}>
|
||||
{c.isActive ? "Aktif" : "Pasif"}
|
||||
@@ -53,6 +62,11 @@ export default async function DashboardPage() {
|
||||
<span className={`badge ${recording ? "warn" : "muted"}`}>
|
||||
{recording ? "🔴 Kayıtta" : "—"}
|
||||
</span>
|
||||
{status?.lastPollError && (
|
||||
<span className="badge err" style={{ marginLeft: "0.4rem" }}>
|
||||
hata
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="mono">
|
||||
{c.lastCheckedAt ? new Date(c.lastCheckedAt).toLocaleString("tr-TR") : "—"}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user