From 19fe430549b5c841ff07ea1bf56867992fba7b81 Mon Sep 17 00:00:00 2001 From: ayrisdev Date: Sun, 30 Aug 2026 17:34:15 +0300 Subject: [PATCH] =?UTF-8?q?frontend:=20kanal=20detay=20sayfas=C4=B1=20(can?= =?UTF-8?q?l=C4=B1=20durum=20+=20poll=20hatas=C4=B1=20+=20yay=C4=B1n=20ge?= =?UTF-8?q?=C3=A7mi=C5=9Fi)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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 --- apps/frontend/app/channels/[id]/page.tsx | 127 +++++++++++++++++++++++ apps/frontend/app/page.tsx | 34 ++++-- apps/frontend/lib/apiDaemon.ts | 28 +++++ docker-compose.yml | 2 + 4 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 apps/frontend/app/channels/[id]/page.tsx create mode 100644 apps/frontend/lib/apiDaemon.ts diff --git a/apps/frontend/app/channels/[id]/page.tsx b/apps/frontend/app/channels/[id]/page.tsx new file mode 100644 index 0000000..a2a00c0 --- /dev/null +++ b/apps/frontend/app/channels/[id]/page.tsx @@ -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 = { + 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 ( + <> +

← Kanal Durumu

+ +

{channel.name}

+

{channel.youtubeHandle} · {channel.channelId}

+ +
+
+ Canlı Durum + + {status?.recording ? "🔴 Kayıtta" : "Kayıtta değil"} + +
+
+ Aktif: {channel.isActive ? "evet" : "hayır"} ·{" "} + Son kontrol: {channel.lastCheckedAt ? new Date(channel.lastCheckedAt).toLocaleString("tr-TR") : "—"} +
+ {status === undefined && ( +

+ api-daemon'dan canlı durum alınamadı (servis erişilemez olabilir). +

+ )} + {status?.lastPollError && ( +
+ son poll hatası +
+              {status.lastPollError}
+            
+
+ )} +
+ +

Yayın Geçmişi

+ {channel.sessions.length === 0 ? ( +

Bu kanal için henüz bir kayıt oturumu yok.

+ ) : ( + channel.sessions.map((session) => ( +
+
+ + {new Date(session.startedAt).toLocaleString("tr-TR")} + {session.endedAt ? ` – ${new Date(session.endedAt).toLocaleString("tr-TR")}` : " (devam ediyor)"} + + {session.totalSegments} segment +
+ + {session.segments.length === 0 ? ( +

Henüz segment yok.

+ ) : ( + session.segments.map((segment) => ( +
+
+ {segment.filePath} + {segment.status} +
+ + {segment.candidates.map((c) => ( +
+
+ + {c.startSec}s – {c.endSec}s + {c.audioPeakScore != null && ` · peak ${c.audioPeakScore.toFixed(1)}dB`} + + {c.status} +
+ {transcriptPreview(c.transcriptJson) && ( +

{transcriptPreview(c.transcriptJson)}

+ )} +
+ ))} +
+ )) + )} +
+ )) + )} + + ); +} diff --git a/apps/frontend/app/page.tsx b/apps/frontend/app/page.tsx index 399f42d..19e32df 100644 --- a/apps/frontend/app/page.tsx +++ b/apps/frontend/app/page.tsx @@ -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() { {channels.map((c) => { const recording = c.sessions.length > 0; + const status = statuses.get(c.id); return ( - {c.name} {c.youtubeHandle} + + {c.name}{" "} + {c.youtubeHandle} + {c.isActive ? "Aktif" : "Pasif"} @@ -53,6 +62,11 @@ export default async function DashboardPage() { {recording ? "🔴 Kayıtta" : "—"} + {status?.lastPollError && ( + + hata + + )} {c.lastCheckedAt ? new Date(c.lastCheckedAt).toLocaleString("tr-TR") : "—"} diff --git a/apps/frontend/lib/apiDaemon.ts b/apps/frontend/lib/apiDaemon.ts new file mode 100644 index 0000000..fb6a22a --- /dev/null +++ b/apps/frontend/lib/apiDaemon.ts @@ -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> { + 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(); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index 66841a4..b08fb73 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -75,10 +75,12 @@ services: restart: unless-stopped environment: DATABASE_URL: postgresql://streamclipper:streamclipper@sc_postgres:5432/streamclipper + API_DAEMON_URL: http://sc_api_daemon:4001 ports: - "3000:3000" depends_on: - sc_postgres + - sc_api_daemon volumes: redis-data: