ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: мониторинг очереди Voice Tasker

This commit is contained in:
DCCONSTRUCTIONS
2026-04-28 16:31:47 +03:00
parent 3b295e33f3
commit d60c28ec04
8 changed files with 772 additions and 10 deletions
@@ -8,7 +8,7 @@ import { useEffect, useMemo, useState } from "react";
import type { ElementType, ReactNode } from "react";
import { observer } from "mobx-react";
import useSWR, { mutate } from "swr";
import { BrainCircuit, Check, FolderKanban, KeyRound, Mic, ShieldCheck, UsersRound } from "lucide-react";
import { Activity, AlertTriangle, BrainCircuit, Check, Clock3, FolderKanban, KeyRound, Mic, RotateCcw, ShieldCheck, UsersRound } from "lucide-react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { Button } from "@plane/propel/button";
@@ -16,6 +16,8 @@ import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type {
IProject,
IWorkspaceMember,
TVoiceTaskMonitor,
TVoiceTaskMonitorSession,
TWorkspaceAIAccessMode,
TWorkspaceAISettings,
TWorkspaceAISettingsPayload,
@@ -113,6 +115,7 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
const [formState, setFormState] = useState<TFormState>(getInitialFormState());
const [isSaving, setIsSaving] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [isCleaningStaleSessions, setIsCleaningStaleSessions] = useState(false);
// store hooks
const { currentWorkspace } = useWorkspace();
const { fetchProjects, projectMap } = useProject();
@@ -127,6 +130,15 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
canPerformWorkspaceAdminActions ? `WORKSPACE_AI_SETTINGS_${workspaceSlug}` : null,
canPerformWorkspaceAdminActions ? () => workspaceAIService.retrieveSettings(workspaceSlug) : null
);
const {
data: monitor,
isLoading: isMonitorLoading,
mutate: mutateMonitor,
} = useSWR(
canPerformWorkspaceAdminActions ? `WORKSPACE_AI_MONITOR_${workspaceSlug}` : null,
canPerformWorkspaceAdminActions ? () => workspaceAIService.retrieveVoiceTaskMonitor(workspaceSlug) : null,
{ refreshInterval: 10000 }
);
useSWR(
canPerformWorkspaceAdminActions ? `WORKSPACE_AI_SETTINGS_PROJECTS_${workspaceSlug}` : null,
@@ -241,6 +253,25 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
}
};
const handleCleanupStaleSessions = async () => {
setIsCleaningStaleSessions(true);
try {
const response = await workspaceAIService.cleanupStaleVoiceTaskSessions(workspaceSlug);
await mutateMonitor(response, false);
setToast({
type: TOAST_TYPE.SUCCESS,
title: `Зависшие обработки сброшены: ${response.cleaned_count ?? 0}`,
});
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "Не удалось сбросить зависшие обработки",
});
} finally {
setIsCleaningStaleSessions(false);
}
};
if (workspaceUserInfo && !canPerformWorkspaceAdminActions) {
return <NotAuthorizedView section="settings" className="h-auto" />;
}
@@ -425,6 +456,13 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
</div>
</section>
<VoiceTaskMonitorSection
isCleaning={isCleaningStaleSessions}
isLoading={isMonitorLoading}
monitor={monitor}
onCleanupStale={handleCleanupStaleSessions}
/>
<div className="flex items-center justify-end gap-3">
<Button
variant="primary"
@@ -609,6 +647,245 @@ function getWorkspaceRoleLabel(role: IWorkspaceMember["role"]) {
return "Участник";
}
type TVoiceTaskMonitorSectionProps = {
isCleaning: boolean;
isLoading: boolean;
monitor?: TVoiceTaskMonitor;
onCleanupStale: () => void;
};
function VoiceTaskMonitorSection({
isCleaning,
isLoading,
monitor,
onCleanupStale,
}: TVoiceTaskMonitorSectionProps) {
const staleCount = monitor?.summary.stale ?? 0;
const recentSessions = monitor?.recent_sessions ?? [];
const activeSessions = monitor?.active_sessions ?? [];
return (
<section className="nodedc-settings-card overflow-hidden">
<SectionHeader
icon={Activity}
title="Очередь и диагностика"
description="Мониторинг обработок Voice Tasker за последние 24 часа: активные job, ошибки, latency и расход parser tokens."
right={
<Button
variant="secondary"
size="md"
className="nodedc-settings-chip min-w-[11rem]"
disabled={!monitor || staleCount === 0}
loading={isCleaning}
onClick={onCleanupStale}
>
<RotateCcw className="size-3.5" />
Сбросить зависшие
</Button>
}
/>
{isLoading || !monitor ? (
<div className="px-5 pb-5 text-12 text-tertiary">Загрузка мониторинга...</div>
) : (
<div className="flex flex-col gap-5 border-t border-white/5 px-5 py-5">
<div className="grid gap-3 md:grid-cols-4">
<MonitorMetricCard
label="Активные"
value={`${monitor.summary.active}/${monitor.concurrency.limit}`}
meta={staleCount ? `зависшие: ${staleCount}` : "очередь в норме"}
tone={staleCount ? "danger" : "accent"}
/>
<MonitorMetricCard
label="Успешно"
value={formatMonitorNumber(monitor.summary.parsed)}
meta={`всего: ${formatMonitorNumber(monitor.summary.total)}`}
/>
<MonitorMetricCard
label="Ошибки"
value={formatMonitorNumber(monitor.summary.failed)}
meta={monitor.summary.error_counts[0]?.error_code || "без ошибок"}
tone={monitor.summary.failed ? "danger" : "default"}
/>
<MonitorMetricCard
label="Средняя обработка"
value={formatDurationMs(monitor.summary.avg_processing_duration_ms)}
meta={`${formatAudioMinutes(monitor.summary.total_audio_seconds)} аудио`}
/>
<MonitorMetricCard
label="Transcribe"
value={formatDurationMs(monitor.summary.avg_transcription_duration_ms)}
meta="средняя стадия"
/>
<MonitorMetricCard
label="Parse"
value={formatDurationMs(monitor.summary.avg_parsing_duration_ms)}
meta="средняя стадия"
/>
<MonitorMetricCard
label="Parser tokens"
value={formatMonitorNumber(monitor.summary.parser_total_tokens)}
meta="OpenAI usage proxy"
/>
<MonitorMetricCard
label="Audio size"
value={formatBytes(monitor.summary.total_audio_size)}
meta="загружено за сутки"
/>
</div>
<div className="grid gap-4 lg:grid-cols-2">
<MonitorSessionList title="Активные обработки" sessions={activeSessions} emptyText="Активных обработок нет." />
<MonitorSessionList title="Последние сессии" sessions={recentSessions} emptyText="Сессий пока нет." />
</div>
</div>
)}
</section>
);
}
type TMonitorMetricCardProps = {
label: string;
meta: string;
tone?: "accent" | "danger" | "default";
value: string;
};
function MonitorMetricCard({ label, meta, tone = "default", value }: TMonitorMetricCardProps) {
return (
<div className="rounded-[1.35rem] bg-white/[0.045] px-4 py-3">
<div className="text-11 font-semibold tracking-[0.18em] text-tertiary uppercase">{label}</div>
<div
className={cn(
"mt-2 text-2xl font-semibold",
tone === "accent" && "text-[rgb(var(--nodedc-accent-rgb))]",
tone === "danger" && "text-red-300",
tone === "default" && "text-primary"
)}
>
{value}
</div>
<div className="mt-1 truncate text-11 text-tertiary">{meta}</div>
</div>
);
}
type TMonitorSessionListProps = {
emptyText: string;
sessions: TVoiceTaskMonitorSession[];
title: string;
};
function MonitorSessionList({ emptyText, sessions, title }: TMonitorSessionListProps) {
return (
<div className="rounded-[1.35rem] bg-white/[0.035] p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<span className="text-12 font-semibold tracking-[0.18em] text-tertiary uppercase">{title}</span>
<span className="nodedc-settings-chip px-3 py-1 text-11 text-secondary">{sessions.length}</span>
</div>
{sessions.length === 0 ? (
<div className="text-12 text-tertiary">{emptyText}</div>
) : (
<div className="flex max-h-80 flex-col gap-2 overflow-auto pr-1">
{sessions.map((session) => (
<MonitorSessionRow key={session.id} session={session} />
))}
</div>
)}
</div>
);
}
function MonitorSessionRow({ session }: { session: TVoiceTaskMonitorSession }) {
const title = session.issue.key || session.project.identifier || session.intent || "Voice Tasker";
const subtitle = [session.user.name, session.project.name].filter(Boolean).join(" / ");
return (
<div className="rounded-2xl bg-white/[0.045] px-3 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-13 font-semibold text-primary">{title}</div>
<div className="mt-1 truncate text-11 text-tertiary">{subtitle || "Без проекта"}</div>
</div>
<span
className={cn(
"inline-flex shrink-0 items-center gap-1.5 rounded-full px-2.5 py-1 text-11",
session.status === "failed"
? "bg-red-500/12 text-red-300"
: session.is_active
? "bg-[rgba(var(--nodedc-accent-rgb),0.14)] text-[rgb(var(--nodedc-accent-rgb))]"
: "bg-white/7 text-secondary"
)}
>
{session.is_stale && <AlertTriangle className="size-3" />}
{getVoiceTaskStatusLabel(session.status)}
</span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-11 text-tertiary">
<span className="nodedc-settings-chip px-2.5 py-1">
<Clock3 className="size-3" />
{formatDurationMs(session.timings.processing_duration_ms)}
</span>
<span className="nodedc-settings-chip px-2.5 py-1">{formatBytes(session.audio.size)}</span>
<span className="nodedc-settings-chip px-2.5 py-1">
{formatDateTime(session.timings.completed_at || session.timings.failed_at || session.timings.created_at)}
</span>
</div>
{session.error.code && <div className="mt-2 truncate text-11 text-red-300">{session.error.code}</div>}
</div>
);
}
function getVoiceTaskStatusLabel(status: TVoiceTaskMonitorSession["status"]) {
const labels: Record<TVoiceTaskMonitorSession["status"], string> = {
queued: "В очереди",
processing: "Обработка",
uploaded: "Загружено",
transcribing: "Транскрибация",
transcribed: "Текст готов",
parsing: "Формирование",
parsed: "Готово",
failed: "Ошибка",
};
return labels[status] || status;
}
function formatMonitorNumber(value: number | null | undefined) {
return new Intl.NumberFormat("ru-RU").format(value ?? 0);
}
function formatDurationMs(value: number | null | undefined) {
if (!value) return "0 сек";
if (value < 1000) return `${Math.round(value)} мс`;
return `${(value / 1000).toFixed(value < 10000 ? 1 : 0)} сек`;
}
function formatAudioMinutes(value: number | null | undefined) {
const seconds = value ?? 0;
if (seconds < 60) return `${Math.round(seconds)} сек`;
return `${(seconds / 60).toFixed(1)} мин`;
}
function formatBytes(value: number | null | undefined) {
const bytes = value ?? 0;
if (bytes < 1024) return `${bytes} Б`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`;
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`;
}
function formatDateTime(value: string | null | undefined) {
if (!value) return "нет даты";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "нет даты";
return new Intl.DateTimeFormat("ru-RU", {
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
month: "short",
}).format(date);
}
function Field({ children, label }: TFieldProps) {
return (
<label className="flex flex-col gap-2.5">
@@ -9,6 +9,7 @@ import type {
TVoiceTaskCommitResult,
TVoiceTaskPreflight,
TVoiceTaskDraft,
TVoiceTaskMonitor,
TVoiceTaskUploadResult,
TWorkspaceAIConnectionTestResult,
TWorkspaceAISettings,
@@ -45,6 +46,22 @@ export class WorkspaceAIService extends APIService {
});
}
async retrieveVoiceTaskMonitor(workspaceSlug: string): Promise<TVoiceTaskMonitor> {
return this.get(`/api/workspaces/${workspaceSlug}/voice-tasker/monitor/`)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async cleanupStaleVoiceTaskSessions(workspaceSlug: string): Promise<TVoiceTaskMonitor> {
return this.post(`/api/workspaces/${workspaceSlug}/voice-tasker/monitor/`, { action: "fail_stale" })
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data;
});
}
async retrieveVoiceTaskPreflight(workspaceSlug: string, projectId?: string | null): Promise<TVoiceTaskPreflight> {
const params = projectId ? `?project_id=${projectId}` : "";
return this.get(