feat: improve polling recovery and session cleanup

This commit is contained in:
2026-04-27 20:33:12 +08:00
parent ca2c559555
commit 89ba4163fc
22 changed files with 442 additions and 75 deletions
+3 -1
View File
@@ -572,7 +572,9 @@ export const autoStartDecisionLabelMap: Record<string, string> = {
skipped_active_session: "已有活动会话",
skipped_offline: "房间未开播",
skipped_debounce: "触发防抖中",
failed_startup: "启动失败"
failed_startup: "启动失败",
poll_failed_transient: "轮询临时失败",
poll_failed: "轮询失败"
};
export const platformLabelMap: Record<number, string> = {
+8 -4
View File
@@ -517,11 +517,16 @@ function autoStartDecisionTagType(code?: string) {
return "success";
}
if (code === "failed_startup") {
if (code === "failed_startup" || code === "poll_failed") {
return "danger";
}
if (code === "skipped_storage" || code === "skipped_active_session" || code === "skipped_debounce") {
if (
code === "skipped_storage" ||
code === "skipped_active_session" ||
code === "skipped_debounce" ||
code === "poll_failed_transient"
) {
return "warning";
}
@@ -1242,8 +1247,7 @@ onBeforeUnmount(() => {
}
.rooms-table-shell {
margin-inline: -4px;
padding-inline: 4px;
overflow: visible;
}
.rooms-table {
+50 -5
View File
@@ -1,10 +1,12 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import apiClient, { getApiErrorMessage } from "@/api/client";
import { useViewport } from "@/composables/useViewport";
import type { SystemLog } from "@/types";
import { logLevelLabelMap } from "@/types";
const AUTO_REFRESH_INTERVAL_MS = 15000;
const loading = ref(false);
const logs = ref<SystemLog[]>([]);
const loadError = ref("");
@@ -26,7 +28,13 @@ const totalWarnings = computed(() => logs.value.filter((item) => item.level ===
const totalErrors = computed(() => logs.value.filter((item) => item.level === 3).length);
const newestLogTime = computed(() => (logs.value[0] ? formatDate(logs.value[0].createdAt) : "-"));
let autoRefreshTimer: number | null = null;
async function loadLogs() {
if (loading.value) {
return;
}
loading.value = true;
loadError.value = "";
@@ -48,6 +56,34 @@ async function loadLogs() {
}
}
async function autoRefreshLogs() {
if (document.visibilityState !== "visible") {
return;
}
await loadLogs();
}
function startAutoRefresh() {
stopAutoRefresh();
autoRefreshTimer = window.setInterval(() => {
void autoRefreshLogs();
}, AUTO_REFRESH_INTERVAL_MS);
}
function stopAutoRefresh() {
if (autoRefreshTimer !== null) {
window.clearInterval(autoRefreshTimer);
autoRefreshTimer = null;
}
}
function handleVisibilityChange() {
if (document.visibilityState === "visible") {
void autoRefreshLogs();
}
}
function levelTagType(level: number) {
if (level === 3) {
return "danger";
@@ -64,7 +100,16 @@ function formatDate(value?: string) {
return value ? new Date(value).toLocaleString() : "-";
}
onMounted(loadLogs);
onMounted(async () => {
await loadLogs();
startAutoRefresh();
document.addEventListener("visibilitychange", handleVisibilityChange);
});
onBeforeUnmount(() => {
stopAutoRefresh();
document.removeEventListener("visibilitychange", handleVisibilityChange);
});
</script>
<template>
@@ -74,7 +119,7 @@ onMounted(loadLogs);
<div class="page-kicker">排障中心</div>
<h1 class="page-title">系统日志</h1>
<p class="page-subtitle">
面向排障的原始事件流这里保留最近发生的关键日志可以按直播间任务和级别快速缩范围
面向排障的原始事件流这里保留最近发生的关键日志可以按直播间任务和级别快速缩范围
</p>
</div>
@@ -103,7 +148,7 @@ onMounted(loadLogs);
</div>
<div class="stat-card">
<div class="stat-card__label">最新事件</div>
<div class="stat-card__value" style="font-size: 18px; letter-spacing: -0.03em;">{{ newestLogTime }}</div>
<div class="stat-card__value" style="font-size: 18px;">{{ newestLogTime }}</div>
<div class="stat-card__hint">默认按时间倒序优先展示最近发生的事件</div>
</div>
</div>
@@ -146,7 +191,7 @@ onMounted(loadLogs);
<div class="toolbar-row">
<div>
<h3 class="section-title">日志列表</h3>
<p class="section-subtitle">桌面端保留高密度表格移动端成事件卡片便于单手排查</p>
<p class="section-subtitle">页面会自动刷新移动端成事件卡片桌面端保留高密度表格</p>
</div>
</div>
+135 -30
View File
@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElNotification } from "element-plus";
@@ -30,12 +30,13 @@ const stoppingSessionId = ref<string | null>(null);
const uploadingSessionId = ref<string | null>(null);
const uploadingTaskId = ref<string | null>(null);
const deleteDialogVisible = ref(false);
const deleteDialogMode = ref<"tasks" | "sessions">("tasks");
const deleteDialogMode = ref<"tasks" | "sessions" | "missing-sessions">("tasks");
const deleteDialogTaskIds = ref<string[]>([]);
const deleteDialogSessionIds = ref<string[]>([]);
const sessions = ref<RecordSession[]>([]);
const loadError = ref("");
const activeSessionPanels = ref<string[]>([]);
const selectedSessionIds = ref<string[]>([]);
const selectedTaskMap = ref<Record<string, RecordTask>>({});
const realtimeConnected = ref(false);
const realtimeError = ref("");
@@ -47,22 +48,54 @@ const selectedTasks = computed(() => Object.values(selectedTaskMap.value));
const activeSessionCount = computed(() => sessions.value.filter((item) => isActiveStatus(item.status)).length);
const totalTaskCount = computed(() => sessions.value.reduce((sum, item) => sum + item.tasks.length, 0));
const totalDanmakuCount = computed(() => sessions.value.reduce((sum, item) => sum + item.totalDanmakuMessageCount, 0));
const selectedSessionCount = computed(() => selectedSessionIds.value.length);
const deleteDialogTaskCount = computed(() => deleteDialogTaskIds.value.length);
const deleteDialogSessionCount = computed(() => deleteDialogSessionIds.value.length);
const deleteDialogWidth = computed(() => (isMobile.value ? "min(100vw - 24px, 560px)" : "clamp(480px, 48vw, 560px)"));
const deleteDialogEyebrow = computed(() => (deleteDialogMode.value === "sessions" ? "会话删除" : "删除确认"));
const deleteDialogTitle = computed(() => (deleteDialogMode.value === "sessions" ? "删除录制会话" : "删除分片任务"));
const deleteDialogLead = computed(() =>
deleteDialogMode.value === "sessions"
? `将删除 ${deleteDialogSessionCount.value} 个录制会话。删除会先停止当前录制,再清理该会话下的分片记录;你也可以选择同时删除本地视频和弹幕 XML 文件。`
: `将删除 ${deleteDialogTaskCount.value} 个已选择分片任务。你可以只移除数据库记录,也可以同时清理本地视频和弹幕 XML 文件。`
);
const deleteDialogNote = computed(() =>
deleteDialogMode.value === "sessions"
? "活跃会话会先尝试优雅停止,超时后再强制结束 ffmpeg 进程。直播间仍保持启用时,后台巡检后续可能重新创建新会话。"
: "“记录 + 文件”会尝试删除视频文件和对应弹幕 XML。文件不存在时不会阻断删除,但会返回警告信息。"
);
const deleteDialogEyebrow = computed(() => {
if (deleteDialogMode.value === "sessions") {
return "会话删除";
}
if (deleteDialogMode.value === "missing-sessions") {
return "无文件清理";
}
return "删除确认";
});
const deleteDialogTitle = computed(() => {
if (deleteDialogMode.value === "sessions") {
return "删除录制会话";
}
if (deleteDialogMode.value === "missing-sessions") {
return "清理无实体文件会话";
}
return "删除分片任务";
});
const deleteDialogLead = computed(() => {
if (deleteDialogMode.value === "sessions") {
return `将删除 ${deleteDialogSessionCount.value} 个录制会话。删除会先停止当前录制,再清理该会话下的分片记录;你也可以选择同时删除本地视频和弹幕 XML 文件。`;
}
if (deleteDialogMode.value === "missing-sessions") {
return "将自动筛出所有“分片视频文件都不存在”的录制会话,并批量清理这些会话及其分片记录。你也可以选择同时尝试删除残留的 XML 或其他本地文件。";
}
return `将删除 ${deleteDialogTaskCount.value} 个已选择分片任务。你可以只移除数据库记录,也可以同时清理本地视频和弹幕 XML 文件。`;
});
const deleteDialogNote = computed(() => {
if (deleteDialogMode.value === "sessions") {
return "活跃会话会先尝试优雅停止,超时后再强制结束 ffmpeg 进程。直播间仍保持启用时,后台巡检后续可能重新创建新会话。";
}
if (deleteDialogMode.value === "missing-sessions") {
return "只有当会话下所有分片都找不到实体视频文件时,才会命中这类清理。只要仍有任意一个视频文件存在,该会话就不会被误删。";
}
return "“记录 + 文件”会尝试删除视频文件和对应弹幕 XML。文件不存在时不会阻断删除,但会返回警告信息。";
});
function isActiveStatus(status: number) {
return status === 1 || status === 2 || status === 3 || status === 7;
}
@@ -75,6 +108,22 @@ function selectableTask(row: RecordTask) {
return isDeletableTask(row);
}
function isSessionSelected(sessionId: string) {
return selectedSessionIds.value.includes(sessionId);
}
function setSessionSelected(sessionId: string, selected: boolean) {
if (selected) {
if (!selectedSessionIds.value.includes(sessionId)) {
selectedSessionIds.value = [...selectedSessionIds.value, sessionId];
}
return;
}
selectedSessionIds.value = selectedSessionIds.value.filter((value) => value !== sessionId);
}
function applySessionsSnapshot(
data: RecordSession[],
options?: {
@@ -103,10 +152,13 @@ function applySessionsSnapshot(
}
if (options?.resetSelection) {
selectedSessionIds.value = [];
selectedTaskMap.value = {};
return;
}
selectedSessionIds.value = selectedSessionIds.value.filter((sessionId) => nextSessionIds.has(sessionId));
selectedTaskMap.value = Object.fromEntries(
Object.entries(selectedTaskMap.value)
.map(([taskId]) => {
@@ -281,6 +333,13 @@ function openDeleteSessionDialog(sessionIds: string[]) {
deleteDialogVisible.value = true;
}
function openDeleteMissingSessionsDialog() {
deleteDialogMode.value = "missing-sessions";
deleteDialogSessionIds.value = [];
deleteDialogTaskIds.value = [];
deleteDialogVisible.value = true;
}
function closeDeleteDialog() {
if (deleting.value) {
return;
@@ -308,6 +367,7 @@ function applyDeleteResult(data: DeleteCompletedRecordTasksResult) {
}));
activeSessionPanels.value = activeSessionPanels.value.filter((sessionId) => !deletedSessionIds.has(sessionId));
selectedSessionIds.value = selectedSessionIds.value.filter((sessionId) => !deletedSessionIds.has(sessionId));
selectedTaskMap.value = Object.fromEntries(
Object.entries(selectedTaskMap.value).filter(([taskId]) => !deletedTaskIds.has(taskId))
);
@@ -315,8 +375,9 @@ function applyDeleteResult(data: DeleteCompletedRecordTasksResult) {
async function confirmDelete(deleteFiles: boolean) {
const deletingSessions = deleteDialogMode.value === "sessions";
const deletingMissingSessions = deleteDialogMode.value === "missing-sessions";
const selectedIds = deletingSessions ? deleteDialogSessionIds.value : deleteDialogTaskIds.value;
if (selectedIds.length === 0) {
if (!deletingMissingSessions && selectedIds.length === 0) {
closeDeleteDialog();
return;
}
@@ -324,21 +385,29 @@ async function confirmDelete(deleteFiles: boolean) {
deleting.value = true;
try {
const { data } = deletingSessions
? await apiClient.post<DeleteCompletedRecordTasksResult>("/record-sessions/delete", {
sessionIds: deleteDialogSessionIds.value,
const { data } = deletingMissingSessions
? await apiClient.post<DeleteCompletedRecordTasksResult>("/record-sessions/delete-missing-files", {
deleteFiles
})
: await apiClient.post<DeleteCompletedRecordTasksResult>("/record-tasks/delete", {
taskIds: deleteDialogTaskIds.value,
deleteFiles
});
: deletingSessions
? await apiClient.post<DeleteCompletedRecordTasksResult>("/record-sessions/delete", {
sessionIds: deleteDialogSessionIds.value,
deleteFiles
})
: await apiClient.post<DeleteCompletedRecordTasksResult>("/record-tasks/delete", {
taskIds: deleteDialogTaskIds.value,
deleteFiles
});
ElMessage.success(
deletingSessions
? `已删除 ${data.deletedSessionIds.length} 个录制会话。`
: `已删除 ${data.deletedTaskIds.length} 个分片任务。`
);
if (deletingMissingSessions && data.deletedSessionIds.length === 0) {
ElMessage.info("没有找到符合条件的无实体文件会话。");
} else {
ElMessage.success(
deletingSessions || deletingMissingSessions
? `已删除 ${data.deletedSessionIds.length} 个录制会话。`
: `已删除 ${data.deletedTaskIds.length} 个分片任务。`
);
}
applyDeleteResult(data);
@@ -492,6 +561,21 @@ onBeforeUnmount(() => {
会话层代表整场录制分片层代表单个视频文件停止入口作用于整个会话详情入口作用于单个分片
</p>
</div>
<div class="toolbar-row__actions">
<span class="batch-actions__count">已选 {{ selectedSessionCount }} </span>
<el-button
type="danger"
plain
:disabled="selectedSessionCount === 0"
:loading="deleting"
@click="openDeleteSessionDialog(selectedSessionIds)"
>
删除已选会话{{ selectedSessionCount > 0 ? `${selectedSessionCount}` : "" }}
</el-button>
<el-button plain :loading="deleting" @click="openDeleteMissingSessionsDialog()">
清理无文件会话
</el-button>
</div>
</div>
<el-empty v-if="!loading && sessions.length === 0" description="暂无录制会话" />
@@ -626,6 +710,12 @@ onBeforeUnmount(() => {
>
<template #title>
<div class="session-title">
<div class="session-title__select" @click.stop>
<el-checkbox
:model-value="isSessionSelected(session.id)"
@change="setSessionSelected(session.id, Boolean($event))"
/>
</div>
<div class="session-title__main">
<div class="session-title__name">{{ session.liveRoomTitle }}</div>
<div class="session-title__meta">
@@ -692,7 +782,7 @@ onBeforeUnmount(() => {
</el-button>
</div>
<div class="table-scroll-shell tasks-table-shell">
<div class="tasks-table-shell">
<el-table
:data="session.tasks"
row-key="id"
@@ -850,8 +940,7 @@ onBeforeUnmount(() => {
}
.tasks-table-shell {
margin-inline: -4px;
padding-inline: 4px;
overflow: visible;
}
.sessions-card :deep(.el-card__body) {
@@ -868,6 +957,14 @@ onBeforeUnmount(() => {
border-bottom: 1px solid var(--border-subtle);
}
.toolbar-row__actions {
display: flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 12px;
}
.session-collapse {
display: grid;
gap: 14px;
@@ -904,8 +1001,15 @@ onBeforeUnmount(() => {
width: 100%;
}
.session-title__select {
display: inline-flex;
align-items: center;
padding-right: 4px;
}
.session-title__main {
min-width: 0;
flex: 1;
}
.session-title__name {
@@ -1187,3 +1291,4 @@ onBeforeUnmount(() => {
}
}
</style>
+7 -2
View File
@@ -138,11 +138,16 @@ function autoStartDecisionTagType(code?: string) {
return "success";
}
if (code === "failed_startup") {
if (code === "failed_startup" || code === "poll_failed") {
return "danger";
}
if (code === "skipped_storage" || code === "skipped_active_session") {
if (
code === "skipped_storage" ||
code === "skipped_active_session" ||
code === "skipped_debounce" ||
code === "poll_failed_transient"
) {
return "warning";
}
+15 -3
View File
@@ -128,7 +128,7 @@ const form = reactive<SystemSettings>({
<li><strong>Room ID:</strong> {{roomId}}</li>
<li><strong>Title:</strong> {{title}}</li>
<li><strong>Anchor:</strong> {{anchor}}</li>
<li><strong>Detected At (UTC):</strong> {{detectedAtUtc}}</li>
<li><strong>Detected At (Beijing Time):</strong> {{detectedAtUtc}}</li>
</ul>
<p><strong>Source URL:</strong> <a href="{{sourceUrl}}">{{sourceUrl}}</a></p>
</div>`,
@@ -142,7 +142,7 @@ const form = reactive<SystemSettings>({
<li><strong>Room ID:</strong> {{roomId}}</li>
<li><strong>Record Task ID:</strong> {{recordTaskId}}</li>
<li><strong>Task Status:</strong> {{taskStatus}}</li>
<li><strong>Occurred At (UTC):</strong> {{occurredAtUtc}}</li>
<li><strong>Occurred At (Beijing Time):</strong> {{occurredAtUtc}}</li>
</ul>
<div style="margin-top: 16px; padding: 12px 14px; border-radius: 8px; background: #f5f5f5; white-space: pre-wrap;">{{detail}}</div>
</div>`,
@@ -197,7 +197,7 @@ const eventScriptEnvironmentExamples = [
{ name: "LIVE_RECORDER_TITLE", example: "今晚划水聊天", scope: "全部事件" },
{ name: "LIVE_RECORDER_ANCHOR", example: "布莱克🌊", scope: "全部事件" },
{ name: "LIVE_RECORDER_SOURCE_URL", example: "https://live.douyin.com/676493068539", scope: "全部事件" },
{ name: "LIVE_RECORDER_OCCURRED_AT_UTC", example: "2026-04-25T12:34:56.7890000+00:00", scope: "全部事件" },
{ name: "LIVE_RECORDER_OCCURRED_AT_UTC", example: "2026-04-25T20:34:56.7890000+08:00", scope: "全部事件" },
{
name: "LIVE_RECORDER_SCRIPT_LOG_PATH",
example: "/tmp/live-recorder-script-log-7a13c2c5e5cd4f2d8ec2c3b2d5f3f1aa.txt",
@@ -755,6 +755,10 @@ onMounted(loadSettings);
<code>{fileStem}</code> 只用于目录模板表示按文件模板渲染后的基础名<code>{segmentSuffix}</code> 只用于文件名模板分段模式下会展开成 <code>_00001</code> 这类后缀单文件模式下为空
</div>
<div class="helper-panel">
目录模板和文件名模板中的时间变量统一按北京时间UTC+8渲染示例路径也按北京时间展示
</div>
<div class="token-list">
<span v-for="token in outputTemplateTokens" :key="token" class="token-chip">{{ token }}</span>
</div>
@@ -1156,6 +1160,10 @@ onMounted(loadSettings);
如果脚本想把自定义内容写进系统日志请把文本写入 <code>LIVE_RECORDER_SCRIPT_LOG_PATH</code> 指向的临时文件
</div>
<div class="event-script-help__intro">
虽然变量名仍然保留 <code>LIVE_RECORDER_OCCURRED_AT_UTC</code>但实际传入的时间值已经统一改成北京时间UTC+8
</div>
<div class="event-script-help__intro">
官方 Docker 镜像默认内置 <code>curl</code> <code>jq</code>如果你使用宿主机直跑或自定义镜像实际可用命令以运行环境为准
</div>
@@ -1352,6 +1360,10 @@ onMounted(loadSettings);
</div>
</div>
<div class="helper-panel">
邮件模板里的 <code v-pre>{{detectedAtUtc}}</code> <code v-pre>{{occurredAtUtc}}</code> 字段名保持不变但实际渲染值已经统一改成北京时间UTC+8
</div>
<div class="action-strip">
<div class="helper-text">测试发送不会自动保存配置邮件里会同时渲染开播提醒示例异常提醒示例</div>
<el-button :loading="testingEmail" :disabled="!canSendTestEmail" @click="sendTestEmail">测试邮件</el-button>