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>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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") : "—"}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user