ssửa lại chức năng frontend

This commit is contained in:
2026-04-28 15:58:39 +07:00
parent 81c727b7a6
commit ecd9845e14
8 changed files with 367 additions and 88 deletions
+3
View File
@@ -57,6 +57,7 @@ export function useWhepPlayer({
pc.oniceconnectionstatechange = null;
pc.close();
} catch {
// Ignore teardown errors when peer connection is already closed.
}
}
@@ -66,6 +67,7 @@ export function useWhepPlayer({
v.srcObject = null;
v.removeAttribute("src");
} catch {
// Ignore cleanup errors on detached video element.
}
}
@@ -73,6 +75,7 @@ export function useWhepPlayer({
try {
await fetch(sessionUrl, { method: "DELETE" });
} catch {
// Ignore best-effort WHEP session cleanup failures.
}
}
}, []);
+159 -44
View File
@@ -2,6 +2,8 @@ import { Plus, Trash2 } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import type { FormEvent } from "react";
import { useConfigStore } from "@/stores/configStore";
import type { MediaMtxConfigView } from "@/types/api";
import { apiJson } from "@/utils/api";
export default function Settings() {
const {
@@ -9,21 +11,41 @@ export default function Settings() {
isLoading,
error,
load,
addCamera,
deleteCamera,
setSchedulerEnabled,
updateSchedule,
} = useConfigStore();
const [name, setName] = useState("");
const [rtspUrl, setRtspUrl] = useState("");
const [mtx, setMtx] = useState<MediaMtxConfigView | null>(null);
const [mtxBusy, setMtxBusy] = useState(false);
const [mtxError, setMtxError] = useState<string | null>(null);
const [restartMsg, setRestartMsg] = useState<string | null>(null);
const schedule = config?.schedule;
const [weekdaysFrom, setWeekdaysFrom] = useState("18:00");
const [weekdaysTo, setWeekdaysTo] = useState("08:00");
const [weekendAllDay, setWeekendAllDay] = useState(true);
const loadMtx = async () => {
setMtxBusy(true);
setMtxError(null);
try {
const data = await apiJson<MediaMtxConfigView>("/mediamtx/config", { method: "GET" });
setMtx(data);
} catch (e) {
if (typeof e === "object" && e && "status" in e) {
const ex = e as { status: number; bodyText?: string };
setMtxError(`http_${ex.status}${ex.bodyText ? `: ${ex.bodyText}` : ""}`);
} else {
setMtxError("failed_to_load_mediamtx_config");
}
} finally {
setMtxBusy(false);
}
};
useEffect(() => {
if (!config) void load();
void loadMtx();
}, [config, load]);
useEffect(() => {
@@ -33,17 +55,31 @@ export default function Settings() {
setWeekendAllDay(schedule.weekend_all_day);
}, [schedule]);
const canAdd = useMemo(
() => name.trim().length > 0 && rtspUrl.trim().length > 0,
[name, rtspUrl]
);
const canAdd = useMemo(() => rtspUrl.trim().length > 0, [rtspUrl]);
const onAdd = async (e: FormEvent) => {
e.preventDefault();
if (!canAdd) return;
await addCamera({ name: name.trim(), rtsp_url: rtspUrl.trim() });
setName("");
setRtspUrl("");
setMtxBusy(true);
setMtxError(null);
try {
const data = await apiJson<MediaMtxConfigView>("/mediamtx/cameras", {
method: "POST",
body: JSON.stringify({ rtsp_url: rtspUrl.trim() }),
});
setMtx(data);
setRtspUrl("");
await load();
} catch (e) {
if (typeof e === "object" && e && "status" in e) {
const ex = e as { status: number; bodyText?: string };
setMtxError(`http_${ex.status}${ex.bodyText ? `: ${ex.bodyText}` : ""}`);
} else {
setMtxError("failed_to_add_camera");
}
} finally {
setMtxBusy(false);
}
};
const onSaveSchedule = async () => {
@@ -54,6 +90,69 @@ export default function Settings() {
});
};
const onDelete = async (name: string) => {
setMtxBusy(true);
setMtxError(null);
try {
const data = await apiJson<MediaMtxConfigView>(`/mediamtx/cameras/${encodeURIComponent(name)}`, {
method: "DELETE",
});
setMtx(data);
await load();
} catch (e) {
if (typeof e === "object" && e && "status" in e) {
const ex = e as { status: number; bodyText?: string };
setMtxError(`http_${ex.status}${ex.bodyText ? `: ${ex.bodyText}` : ""}`);
} else {
setMtxError("failed_to_delete_camera");
}
} finally {
setMtxBusy(false);
}
};
const onToggleRecord = async (enabled: boolean) => {
setMtxBusy(true);
setMtxError(null);
try {
const data = await apiJson<MediaMtxConfigView>("/mediamtx/recording", {
method: "POST",
body: JSON.stringify({ enabled }),
});
setMtx(data);
} catch (e) {
if (typeof e === "object" && e && "status" in e) {
const ex = e as { status: number; bodyText?: string };
setMtxError(`http_${ex.status}${ex.bodyText ? `: ${ex.bodyText}` : ""}`);
} else {
setMtxError("failed_to_toggle_recording");
}
} finally {
setMtxBusy(false);
}
};
const onRestartDocker = async () => {
setMtxBusy(true);
setRestartMsg(null);
setMtxError(null);
try {
const res = await apiJson<{ ok: boolean; output?: string }>("/mediamtx/restart", {
method: "POST",
});
setRestartMsg(res.ok ? "Docker restart thành công" : "Docker restart thất bại");
} catch (e) {
if (typeof e === "object" && e && "status" in e) {
const ex = e as { status: number; bodyText?: string };
setMtxError(`http_${ex.status}${ex.bodyText ? `: ${ex.bodyText}` : ""}`);
} else {
setMtxError("failed_to_restart_docker");
}
} finally {
setMtxBusy(false);
}
};
return (
<div className="space-y-4">
<div>
@@ -66,35 +165,44 @@ export default function Settings() {
{error}
</div>
) : null}
{mtxError ? (
<div className="rounded-lg border border-rose-900/60 bg-rose-950/30 px-4 py-3 text-sm text-rose-200">
{mtxError}
</div>
) : null}
{restartMsg ? (
<div className="rounded-lg border border-emerald-900/60 bg-emerald-950/30 px-4 py-3 text-sm text-emerald-200">
{restartMsg}
</div>
) : null}
<div className="grid gap-3 lg:grid-cols-2">
<div className="rounded-lg border border-zinc-800 bg-zinc-900/20 p-4">
<div className="text-xs font-semibold text-zinc-200">Cameras</div>
<div className="mt-1 text-xs text-zinc-400">
Đng bộ paths lên MediaMTX thông qua Control API
</div>
<div className="mt-1 text-xs text-zinc-400">Đc chỉnh trực tiếp từ `mediamtx.yml`</div>
<div className="mt-3 space-y-2">
{(config?.cameras ?? []).map((c) => (
{(mtx?.cameras ?? []).map((camera) => (
<div
key={c.name}
key={camera.name}
className="flex items-center justify-between gap-3 rounded-md border border-zinc-800 bg-zinc-950/10 px-3 py-2"
>
<div className="min-w-0">
<div className="truncate text-sm text-zinc-100">{c.name}</div>
<div className="truncate text-xs text-zinc-500">{c.rtsp_url}</div>
<div className="truncate text-sm text-zinc-100">{camera.name}</div>
<div className="truncate text-xs text-zinc-500">{camera.rtsp_url}</div>
</div>
<button
type="button"
onClick={() => void deleteCamera(c.name)}
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-zinc-700 bg-zinc-950/40 text-zinc-200 transition hover:bg-zinc-900"
onClick={() => void onDelete(camera.name)}
disabled={mtxBusy}
className="inline-flex h-9 w-9 items-center justify-center rounded-md border border-zinc-700 bg-zinc-950/40 text-zinc-200 transition hover:bg-zinc-900 disabled:opacity-60"
title="Remove"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
{(config?.cameras ?? []).length === 0 ? (
{(mtx?.cameras ?? []).length === 0 ? (
<div className="rounded-md border border-zinc-800 bg-zinc-950/10 px-3 py-2 text-xs text-zinc-400">
Chưa camera.
</div>
@@ -102,29 +210,18 @@ export default function Settings() {
</div>
<form onSubmit={(e) => void onAdd(e)} className="mt-4 space-y-2">
<div className="grid gap-2 md:grid-cols-2">
<div>
<label className="text-xs text-zinc-400">Name</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="cam1"
className="mt-1 h-9 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 text-sm text-zinc-100"
/>
</div>
<div>
<label className="text-xs text-zinc-400">RTSP URL</label>
<input
value={rtspUrl}
onChange={(e) => setRtspUrl(e.target.value)}
placeholder="rtsp://user:pass@ip:554/stream"
className="mt-1 h-9 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 text-sm text-zinc-100"
/>
</div>
<div>
<label className="text-xs text-zinc-400">RTSP URL</label>
<input
value={rtspUrl}
onChange={(e) => setRtspUrl(e.target.value)}
placeholder="rtsp://user:pass@ip:554/stream"
className="mt-1 h-9 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 text-sm text-zinc-100"
/>
</div>
<button
type="submit"
disabled={!canAdd || isLoading}
disabled={!canAdd || isLoading || mtxBusy}
className="inline-flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-950/40 px-3 py-2 text-sm text-zinc-200 transition hover:bg-zinc-900 disabled:cursor-not-allowed disabled:opacity-60"
>
<Plus className="h-4 w-4" />
@@ -134,10 +231,20 @@ export default function Settings() {
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-900/20 p-4">
<div className="text-xs font-semibold text-zinc-200">Recording Schedule</div>
<div className="mt-1 text-xs text-zinc-400">Backend sẽ bật/tắt record mỗi 60 giây</div>
<div className="text-xs font-semibold text-zinc-200">Recording & Scheduler</div>
<div className="mt-1 text-xs text-zinc-400"> thể đi record trong `mediamtx.yml` scheduler backend</div>
<div className="mt-4 space-y-3">
<label className="flex items-center gap-2 text-sm text-zinc-200">
<input
type="checkbox"
checked={Boolean(mtx?.record_enabled)}
onChange={(e) => void onToggleRecord(e.target.checked)}
className="h-4 w-4 rounded border-zinc-700 bg-zinc-950"
/>
MediaMTX record (pathDefaults.record)
</label>
<label className="flex items-center gap-2 text-sm text-zinc-200">
<input
type="checkbox"
@@ -187,11 +294,19 @@ export default function Settings() {
>
Save
</button>
<button
type="button"
onClick={() => void onRestartDocker()}
disabled={mtxBusy}
className="inline-flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-950/40 px-3 py-2 text-sm text-zinc-200 transition hover:bg-zinc-900 disabled:cursor-not-allowed disabled:opacity-60"
>
Restart MediaMTX Docker
</button>
<div className="rounded-md border border-zinc-800 bg-zinc-950/10 px-3 py-2 text-xs text-zinc-400">
MediaMTX API: <span className="text-zinc-200">{config?.mediamtx_api_url ?? "-"}</span>
MediaMTX API: <span className="text-zinc-200">{mtx?.api_url ?? config?.mediamtx_api_url ?? "-"}</span>
<br />
WebRTC: <span className="text-zinc-200">{config?.mediamtx_webrtc_url ?? "-"}</span>
WebRTC: <span className="text-zinc-200">{mtx?.webrtc_url ?? config?.mediamtx_webrtc_url ?? "-"}</span>
<br />
Recordings dir: <span className="text-zinc-200">{config?.recordings_dir ?? "-"}</span>
</div>
+9
View File
@@ -13,7 +13,10 @@ export type Schedule = {
export type AppConfig = {
mediamtx_api_url: string;
mediamtx_webrtc_url: string;
mediamtx_api_user?: string | null;
mediamtx_api_pass?: string | null;
recordings_dir: string;
api_port?: number;
cameras: Camera[];
schedule: Schedule;
};
@@ -36,3 +39,9 @@ export type MediaMtxPathsListResponse = {
pageCount?: number;
};
export type MediaMtxConfigView = {
api_url: string;
webrtc_url: string;
record_enabled: boolean;
cameras: Camera[];
};