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
+9 -1
View File
@@ -72,7 +72,7 @@ Sau đó set trực tiếp trong `api/data/config.json` của dashboard backend:
"mediamtx_api_pass": "dashboard_password" "mediamtx_api_pass": "dashboard_password"
``` ```
Trong `paths`, bạn có thể để dashboard tự thêm path bằng API (Settings → Add Camera). Trong `paths`, bạn có thể để dashboard tự thêm path bằng Settings (nhập RTSP URL). Backend sẽ cập nhật trực tiếp file `mediamtx.yml`.
Tạo thư mục recordings: Tạo thư mục recordings:
@@ -130,6 +130,7 @@ pip install -r api/requirements.txt
Backend chỉ đọc cấu hình từ `api/data/config.json` (không đọc `.env`). Backend chỉ đọc cấu hình từ `api/data/config.json` (không đọc `.env`).
Chạy lần đầu sẽ tự tạo file này (lưu camera + schedule + các tham số backend). Chạy lần đầu sẽ tự tạo file này (lưu camera + schedule + các tham số backend).
Danh sách camera trong `config.json` sẽ được backend đồng bộ từ `mediamtx.yml`.
Bạn có thể chỉnh: Bạn có thể chỉnh:
@@ -139,6 +140,13 @@ Bạn có thể chỉnh:
- `recordings_dir` (mặc định `./mediamtx/recordings` trong project) - `recordings_dir` (mặc định `./mediamtx/recordings` trong project)
- `api_port` (mặc định `8008`) - `api_port` (mặc định `8008`)
Các thao tác trong Settings:
- Add Camera: chỉ nhập RTSP URL, backend tự tạo tên `camN` trong `mediamtx.yml`
- Delete Camera: xóa path tương ứng trong `mediamtx.yml`
- MediaMTX record: bật/tắt `pathDefaults.record` trong `mediamtx.yml`
- Restart MediaMTX Docker: gọi `docker compose -f mediamtx/docker-compose.yml restart mediamtx`
Nếu chạy mô hình 2 thiết bị, set theo IP máy A (MediaMTX), ví dụ: Nếu chạy mô hình 2 thiết bị, set theo IP máy A (MediaMTX), ví dụ:
```json ```json
+7 -3
View File
@@ -4,7 +4,7 @@ Dashboard giám sát camera IP gọn nhẹ chạy trên Orange Pi, dựa trên M
- Live View (WebRTC WHEP) dạng grid, auto reconnect, lazy load - Live View (WebRTC WHEP) dạng grid, auto reconnect, lazy load
- Playback fMP4 theo camera + ngày (file list + HTML5 video) - Playback fMP4 theo camera + ngày (file list + HTML5 video)
- Settings: quản lý camera + lịch ghi hình (scheduler ở backend) - Settings: quản lý camera trực tiếp trong `mediamtx.yml` + lịch ghi hình
## Cấu trúc thư mục ## Cấu trúc thư mục
@@ -16,10 +16,14 @@ Dashboard giám sát camera IP gọn nhẹ chạy trên Orange Pi, dựa trên M
- `GET /api/health` - `GET /api/health`
- `GET /api/config` - `GET /api/config`
- `POST /api/cameras` / `DELETE /api/cameras/{name}`
- `GET /api/paths` (proxy trạng thái từ MediaMTX) - `GET /api/paths` (proxy trạng thái từ MediaMTX)
- `POST /api/recording` (bật/tắt ghi hình ngay) - `POST /api/recording` (bật/tắt ghi hình ngay)
- `POST /api/scheduler/enabled` / `POST /api/scheduler/schedule` - `POST /api/scheduler/enabled` / `POST /api/scheduler/schedule`
- `GET /api/mediamtx/config`
- `POST /api/mediamtx/cameras` (body: `{ "rtsp_url": "..." }`)
- `DELETE /api/mediamtx/cameras/{name}`
- `POST /api/mediamtx/recording` (ghi `pathDefaults.record` vào `mediamtx.yml`)
- `POST /api/mediamtx/restart` (restart container `mediamtx`)
- `GET /api/recordings?camera=cam1&date=YYYY-MM-DD` - `GET /api/recordings?camera=cam1&date=YYYY-MM-DD`
- `GET /videos/<camera>/<file>.fmp4` - `GET /videos/<camera>/<file>.fmp4`
@@ -53,7 +57,7 @@ Backend chỉ dùng file `api/data/config.json` (không đọc `.env`).
- `mediamtx_api_pass`: password API (nếu bật auth trong MediaMTX) - `mediamtx_api_pass`: password API (nếu bật auth trong MediaMTX)
- `recordings_dir`: ví dụ `./mediamtx/recordings` (cùng máy) hoặc đường dẫn mount NFS/SMB - `recordings_dir`: ví dụ `./mediamtx/recordings` (cùng máy) hoặc đường dẫn mount NFS/SMB
- `api_port`: cổng chạy backend (mặc định `8008`) - `api_port`: cổng chạy backend (mặc định `8008`)
- `cameras`: danh sách camera (name + rtsp_url) - `cameras`: danh sách camera đồng bộ từ `mediamtx.yml` (`name` + `rtsp_url`)
- `schedule`: lịch ghi hình - `schedule`: lịch ghi hình
Ví dụ `config.json`: Ví dụ `config.json`:
+164 -40
View File
@@ -2,10 +2,12 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import subprocess
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import httpx import httpx
import yaml
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
@@ -15,6 +17,9 @@ from .mediamtx_client import MediaMTXClient
from .models import ( from .models import (
AppConfig, AppConfig,
Camera, Camera,
MediaMTXAddCameraRequest,
MediaMTXCamera,
MediaMTXConfigView,
RecordingToggle, RecordingToggle,
ScheduleUpdate, ScheduleUpdate,
SchedulerEnabled, SchedulerEnabled,
@@ -28,6 +33,8 @@ def _cors_origins() -> list[str]:
logger = logging.getLogger("ipcam_dashboard") logger = logging.getLogger("ipcam_dashboard")
PROJECT_ROOT = Path(__file__).resolve().parents[2] PROJECT_ROOT = Path(__file__).resolve().parents[2]
DEFAULT_LOCAL_RECORDINGS_DIR = PROJECT_ROOT / "mediamtx" / "recordings" DEFAULT_LOCAL_RECORDINGS_DIR = PROJECT_ROOT / "mediamtx" / "recordings"
MEDIAMTX_YML_PATH = PROJECT_ROOT / "mediamtx" / "mediamtx.yml"
MEDIAMTX_COMPOSE_PATH = PROJECT_ROOT / "mediamtx" / "docker-compose.yml"
app = FastAPI(title="IPCam Dashboard API") app = FastAPI(title="IPCam Dashboard API")
@@ -42,9 +49,112 @@ app.add_middleware(
store = default_store() store = default_store()
def _extract_port(address: str, fallback: int) -> int:
if not address:
return fallback
text = str(address).strip()
if text.startswith(":"):
text = text[1:]
if ":" in text:
text = text.rsplit(":", 1)[-1]
try:
value = int(text)
return value if 1 <= value <= 65535 else fallback
except ValueError:
return fallback
def _load_mediamtx_yml() -> dict:
if not MEDIAMTX_YML_PATH.exists():
raise HTTPException(status_code=500, detail="mediamtx_yml_not_found")
raw = MEDIAMTX_YML_PATH.read_text(encoding="utf-8")
return yaml.safe_load(raw) or {}
def _save_mediamtx_yml(data: dict) -> None:
MEDIAMTX_YML_PATH.write_text(
yaml.safe_dump(data, sort_keys=False, allow_unicode=False),
encoding="utf-8",
)
def _build_mediamtx_view(data: dict) -> MediaMTXConfigView:
api_port = _extract_port(str(data.get("apiAddress", ":9997")), 9997)
webrtc_port = _extract_port(str(data.get("webrtcAddress", ":8889")), 8889)
hosts = data.get("webrtcAdditionalHosts") or []
host = hosts[0] if isinstance(hosts, list) and hosts else "127.0.0.1"
path_defaults = data.get("pathDefaults") or {}
record_enabled = bool(path_defaults.get("record", False))
cameras: list[MediaMTXCamera] = []
paths = data.get("paths") or {}
if isinstance(paths, dict):
for name, cfg in paths.items():
if not isinstance(cfg, dict):
continue
source = cfg.get("source")
if isinstance(source, str) and source.strip():
cameras.append(MediaMTXCamera(name=str(name), rtsp_url=source))
cameras.sort(key=lambda x: x.name)
return MediaMTXConfigView(
api_url=f"http://127.0.0.1:{api_port}",
webrtc_url=f"http://{host}:{webrtc_port}",
record_enabled=record_enabled,
cameras=cameras,
)
async def _sync_app_config_from_mediamtx() -> AppConfig:
cfg = await store.load()
data = _load_mediamtx_yml()
view = _build_mediamtx_view(data)
cfg.mediamtx_api_url = view.api_url
cfg.mediamtx_webrtc_url = view.webrtc_url
cfg.cameras = [Camera(name=c.name, rtsp_url=c.rtsp_url) for c in view.cameras]
await store.save(cfg)
return cfg
def _restart_mediamtx() -> dict:
cmds = [
["docker", "compose", "-f", str(MEDIAMTX_COMPOSE_PATH), "restart", "mediamtx"],
["docker-compose", "-f", str(MEDIAMTX_COMPOSE_PATH), "restart", "mediamtx"],
]
last_err = ""
for cmd in cmds:
try:
proc = subprocess.run(
cmd,
cwd=str(MEDIAMTX_COMPOSE_PATH.parent),
capture_output=True,
text=True,
timeout=40,
check=False,
)
if proc.returncode == 0:
return {"ok": True, "output": proc.stdout.strip()}
last_err = (proc.stderr or proc.stdout or "").strip()
except FileNotFoundError:
last_err = "docker_command_not_found"
except subprocess.TimeoutExpired:
last_err = "docker_restart_timeout"
raise HTTPException(status_code=500, detail=f"mediamtx_restart_failed: {last_err}")
async def _apply_recording(enabled: bool) -> None: async def _apply_recording(enabled: bool) -> None:
cfg = await store.load() cfg = await store.load()
names = [c.name for c in cfg.cameras] try:
paths = await MediaMTXClient(
api_url=cfg.mediamtx_api_url,
username=cfg.mediamtx_api_user,
password=cfg.mediamtx_api_pass,
).list_paths_status()
names = [it.get("name") for it in (paths.get("items") or []) if isinstance(it, dict) and it.get("name")]
except Exception:
names = [c.name for c in cfg.cameras]
if not names: if not names:
return return
@@ -82,7 +192,7 @@ def _raise_mediamtx_http_error(err: httpx.HTTPError) -> None:
@app.on_event("startup") @app.on_event("startup")
async def _startup() -> None: async def _startup() -> None:
cfg = await store.load() cfg = await _sync_app_config_from_mediamtx()
recordings_dir = Path(cfg.recordings_dir) recordings_dir = Path(cfg.recordings_dir)
# Backward-compatible migration: old defaults used "/recordings". # Backward-compatible migration: old defaults used "/recordings".
@@ -111,49 +221,63 @@ async def health() -> dict:
@app.get("/api/config") @app.get("/api/config")
async def get_config() -> AppConfig: async def get_config() -> AppConfig:
return await store.load() return await _sync_app_config_from_mediamtx()
@app.post("/api/cameras") @app.get("/api/mediamtx/config")
async def add_camera(camera: Camera) -> AppConfig: async def get_mediamtx_config() -> MediaMTXConfigView:
cfg = await store.load() data = _load_mediamtx_yml()
if any(c.name == camera.name for c in cfg.cameras): view = _build_mediamtx_view(data)
raise HTTPException(status_code=409, detail="camera_already_exists") await _sync_app_config_from_mediamtx()
return view
cfg.cameras.append(camera)
await store.save(cfg)
client = MediaMTXClient(
api_url=cfg.mediamtx_api_url,
username=cfg.mediamtx_api_user,
password=cfg.mediamtx_api_pass,
)
try:
await client.upsert_paths_sources_bulk({camera.name: camera.rtsp_url})
except httpx.HTTPError as e:
_raise_mediamtx_http_error(e)
return cfg
@app.delete("/api/cameras/{name}") @app.post("/api/mediamtx/cameras")
async def delete_camera(name: str) -> AppConfig: async def add_mediamtx_camera(payload: MediaMTXAddCameraRequest) -> MediaMTXConfigView:
cfg = await store.load() data = _load_mediamtx_yml()
before = len(cfg.cameras) paths = data.setdefault("paths", {})
cfg.cameras = [c for c in cfg.cameras if c.name != name] if not isinstance(paths, dict):
if len(cfg.cameras) == before: raise HTTPException(status_code=400, detail="invalid_mediamtx_paths")
used = {str(k) for k in paths.keys()}
idx = 1
while f"cam{idx}" in used:
idx += 1
name = f"cam{idx}"
paths[name] = {"source": payload.rtsp_url.strip()}
_save_mediamtx_yml(data)
await _sync_app_config_from_mediamtx()
return _build_mediamtx_view(data)
@app.delete("/api/mediamtx/cameras/{name}")
async def delete_mediamtx_camera(name: str) -> MediaMTXConfigView:
data = _load_mediamtx_yml()
paths = data.get("paths") or {}
if not isinstance(paths, dict) or name not in paths:
raise HTTPException(status_code=404, detail="camera_not_found") raise HTTPException(status_code=404, detail="camera_not_found")
await store.save(cfg) del paths[name]
data["paths"] = paths
_save_mediamtx_yml(data)
await _sync_app_config_from_mediamtx()
return _build_mediamtx_view(data)
client = MediaMTXClient(
api_url=cfg.mediamtx_api_url, @app.post("/api/mediamtx/recording")
username=cfg.mediamtx_api_user, async def set_mediamtx_recording(data: RecordingToggle) -> MediaMTXConfigView:
password=cfg.mediamtx_api_pass, payload = _load_mediamtx_yml()
) path_defaults = payload.setdefault("pathDefaults", {})
try: if not isinstance(path_defaults, dict):
await client.delete_path(name) raise HTTPException(status_code=400, detail="invalid_mediamtx_path_defaults")
except httpx.HTTPError as e: path_defaults["record"] = bool(data.enabled)
_raise_mediamtx_http_error(e) payload["pathDefaults"] = path_defaults
return cfg _save_mediamtx_yml(payload)
return _build_mediamtx_view(payload)
@app.post("/api/mediamtx/restart")
async def restart_mediamtx() -> dict:
return _restart_mediamtx()
@app.get("/api/paths") @app.get("/api/paths")
@@ -206,7 +330,7 @@ async def recordings(
limit: int = 200, limit: int = 200,
offset: int = 0, offset: int = 0,
) -> list[dict]: ) -> list[dict]:
cfg = await store.load() cfg = await _sync_app_config_from_mediamtx()
if not any(c.name == camera for c in cfg.cameras): if not any(c.name == camera for c in cfg.cameras):
raise HTTPException(status_code=404, detail="camera_not_found") raise HTTPException(status_code=404, detail="camera_not_found")
if limit < 1 or limit > 2000: if limit < 1 or limit > 2000:
+15
View File
@@ -42,3 +42,18 @@ class ScheduleUpdate(BaseModel):
weekdays_to: str = Field(pattern=r"^\d{2}:\d{2}$") weekdays_to: str = Field(pattern=r"^\d{2}:\d{2}$")
weekend_all_day: bool weekend_all_day: bool
class MediaMTXCamera(BaseModel):
name: str
rtsp_url: str
class MediaMTXConfigView(BaseModel):
api_url: str
webrtc_url: str
record_enabled: bool
cameras: list[MediaMTXCamera]
class MediaMTXAddCameraRequest(BaseModel):
rtsp_url: str = Field(min_length=1, max_length=2048)
+1
View File
@@ -3,4 +3,5 @@ uvicorn[standard]>=0.27
httpx>=0.27 httpx>=0.27
pydantic>=2.6 pydantic>=2.6
python-multipart>=0.0.9 python-multipart>=0.0.9
pyyaml>=6.0.2
+3
View File
@@ -57,6 +57,7 @@ export function useWhepPlayer({
pc.oniceconnectionstatechange = null; pc.oniceconnectionstatechange = null;
pc.close(); pc.close();
} catch { } catch {
// Ignore teardown errors when peer connection is already closed.
} }
} }
@@ -66,6 +67,7 @@ export function useWhepPlayer({
v.srcObject = null; v.srcObject = null;
v.removeAttribute("src"); v.removeAttribute("src");
} catch { } catch {
// Ignore cleanup errors on detached video element.
} }
} }
@@ -73,6 +75,7 @@ export function useWhepPlayer({
try { try {
await fetch(sessionUrl, { method: "DELETE" }); await fetch(sessionUrl, { method: "DELETE" });
} catch { } 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 { useEffect, useMemo, useState } from "react";
import type { FormEvent } from "react"; import type { FormEvent } from "react";
import { useConfigStore } from "@/stores/configStore"; import { useConfigStore } from "@/stores/configStore";
import type { MediaMtxConfigView } from "@/types/api";
import { apiJson } from "@/utils/api";
export default function Settings() { export default function Settings() {
const { const {
@@ -9,21 +11,41 @@ export default function Settings() {
isLoading, isLoading,
error, error,
load, load,
addCamera,
deleteCamera,
setSchedulerEnabled, setSchedulerEnabled,
updateSchedule, updateSchedule,
} = useConfigStore(); } = useConfigStore();
const [name, setName] = useState("");
const [rtspUrl, setRtspUrl] = 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 schedule = config?.schedule;
const [weekdaysFrom, setWeekdaysFrom] = useState("18:00"); const [weekdaysFrom, setWeekdaysFrom] = useState("18:00");
const [weekdaysTo, setWeekdaysTo] = useState("08:00"); const [weekdaysTo, setWeekdaysTo] = useState("08:00");
const [weekendAllDay, setWeekendAllDay] = useState(true); 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(() => { useEffect(() => {
if (!config) void load(); if (!config) void load();
void loadMtx();
}, [config, load]); }, [config, load]);
useEffect(() => { useEffect(() => {
@@ -33,17 +55,31 @@ export default function Settings() {
setWeekendAllDay(schedule.weekend_all_day); setWeekendAllDay(schedule.weekend_all_day);
}, [schedule]); }, [schedule]);
const canAdd = useMemo( const canAdd = useMemo(() => rtspUrl.trim().length > 0, [rtspUrl]);
() => name.trim().length > 0 && rtspUrl.trim().length > 0,
[name, rtspUrl]
);
const onAdd = async (e: FormEvent) => { const onAdd = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!canAdd) return; if (!canAdd) return;
await addCamera({ name: name.trim(), rtsp_url: rtspUrl.trim() }); setMtxBusy(true);
setName(""); setMtxError(null);
setRtspUrl(""); 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 () => { 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
@@ -66,35 +165,44 @@ export default function Settings() {
{error} {error}
</div> </div>
) : null} ) : 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="grid gap-3 lg:grid-cols-2">
<div className="rounded-lg border border-zinc-800 bg-zinc-900/20 p-4"> <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="text-xs font-semibold text-zinc-200">Cameras</div>
<div className="mt-1 text-xs text-zinc-400"> <div className="mt-1 text-xs text-zinc-400">Đc chỉnh trực tiếp từ `mediamtx.yml`</div>
Đng bộ paths lên MediaMTX thông qua Control API
</div>
<div className="mt-3 space-y-2"> <div className="mt-3 space-y-2">
{(config?.cameras ?? []).map((c) => ( {(mtx?.cameras ?? []).map((camera) => (
<div <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" 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="min-w-0">
<div className="truncate text-sm text-zinc-100">{c.name}</div> <div className="truncate text-sm text-zinc-100">{camera.name}</div>
<div className="truncate text-xs text-zinc-500">{c.rtsp_url}</div> <div className="truncate text-xs text-zinc-500">{camera.rtsp_url}</div>
</div> </div>
<button <button
type="button" type="button"
onClick={() => void deleteCamera(c.name)} onClick={() => void onDelete(camera.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" 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" title="Remove"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</button> </button>
</div> </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"> <div className="rounded-md border border-zinc-800 bg-zinc-950/10 px-3 py-2 text-xs text-zinc-400">
Chưa camera. Chưa camera.
</div> </div>
@@ -102,29 +210,18 @@ export default function Settings() {
</div> </div>
<form onSubmit={(e) => void onAdd(e)} className="mt-4 space-y-2"> <form onSubmit={(e) => void onAdd(e)} className="mt-4 space-y-2">
<div className="grid gap-2 md:grid-cols-2"> <div>
<div> <label className="text-xs text-zinc-400">RTSP URL</label>
<label className="text-xs text-zinc-400">Name</label> <input
<input value={rtspUrl}
value={name} onChange={(e) => setRtspUrl(e.target.value)}
onChange={(e) => setName(e.target.value)} placeholder="rtsp://user:pass@ip:554/stream"
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"
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> </div>
<button <button
type="submit" 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" 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" /> <Plus className="h-4 w-4" />
@@ -134,10 +231,20 @@ export default function Settings() {
</div> </div>
<div className="rounded-lg border border-zinc-800 bg-zinc-900/20 p-4"> <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="text-xs font-semibold text-zinc-200">Recording & Scheduler</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="mt-1 text-xs text-zinc-400"> thể đi record trong `mediamtx.yml` scheduler backend</div>
<div className="mt-4 space-y-3"> <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"> <label className="flex items-center gap-2 text-sm text-zinc-200">
<input <input
type="checkbox" type="checkbox"
@@ -187,11 +294,19 @@ export default function Settings() {
> >
Save Save
</button> </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"> <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 /> <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 /> <br />
Recordings dir: <span className="text-zinc-200">{config?.recordings_dir ?? "-"}</span> Recordings dir: <span className="text-zinc-200">{config?.recordings_dir ?? "-"}</span>
</div> </div>
+9
View File
@@ -13,7 +13,10 @@ export type Schedule = {
export type AppConfig = { export type AppConfig = {
mediamtx_api_url: string; mediamtx_api_url: string;
mediamtx_webrtc_url: string; mediamtx_webrtc_url: string;
mediamtx_api_user?: string | null;
mediamtx_api_pass?: string | null;
recordings_dir: string; recordings_dir: string;
api_port?: number;
cameras: Camera[]; cameras: Camera[];
schedule: Schedule; schedule: Schedule;
}; };
@@ -36,3 +39,9 @@ export type MediaMtxPathsListResponse = {
pageCount?: number; pageCount?: number;
}; };
export type MediaMtxConfigView = {
api_url: string;
webrtc_url: string;
record_enabled: boolean;
cameras: Camera[];
};