ssửa lại chức năng frontend
This commit is contained in:
+9
-1
@@ -72,7 +72,7 @@ Sau đó set trực tiếp trong `api/data/config.json` của dashboard backend:
|
||||
"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:
|
||||
|
||||
@@ -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`).
|
||||
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:
|
||||
|
||||
@@ -139,6 +140,13 @@ Bạn có thể chỉnh:
|
||||
- `recordings_dir` (mặc định `./mediamtx/recordings` trong project)
|
||||
- `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ụ:
|
||||
|
||||
```json
|
||||
|
||||
@@ -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
|
||||
- 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
|
||||
|
||||
@@ -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/config`
|
||||
- `POST /api/cameras` / `DELETE /api/cameras/{name}`
|
||||
- `GET /api/paths` (proxy trạng thái từ MediaMTX)
|
||||
- `POST /api/recording` (bật/tắt ghi hình ngay)
|
||||
- `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 /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)
|
||||
- `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`)
|
||||
- `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
|
||||
|
||||
Ví dụ `config.json`:
|
||||
|
||||
+163
-39
@@ -2,10 +2,12 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
@@ -15,6 +17,9 @@ from .mediamtx_client import MediaMTXClient
|
||||
from .models import (
|
||||
AppConfig,
|
||||
Camera,
|
||||
MediaMTXAddCameraRequest,
|
||||
MediaMTXCamera,
|
||||
MediaMTXConfigView,
|
||||
RecordingToggle,
|
||||
ScheduleUpdate,
|
||||
SchedulerEnabled,
|
||||
@@ -28,6 +33,8 @@ def _cors_origins() -> list[str]:
|
||||
logger = logging.getLogger("ipcam_dashboard")
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
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")
|
||||
@@ -42,8 +49,111 @@ app.add_middleware(
|
||||
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:
|
||||
cfg = await store.load()
|
||||
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:
|
||||
return
|
||||
@@ -82,7 +192,7 @@ def _raise_mediamtx_http_error(err: httpx.HTTPError) -> None:
|
||||
|
||||
@app.on_event("startup")
|
||||
async def _startup() -> None:
|
||||
cfg = await store.load()
|
||||
cfg = await _sync_app_config_from_mediamtx()
|
||||
recordings_dir = Path(cfg.recordings_dir)
|
||||
|
||||
# Backward-compatible migration: old defaults used "/recordings".
|
||||
@@ -111,49 +221,63 @@ async def health() -> dict:
|
||||
|
||||
@app.get("/api/config")
|
||||
async def get_config() -> AppConfig:
|
||||
return await store.load()
|
||||
return await _sync_app_config_from_mediamtx()
|
||||
|
||||
|
||||
@app.post("/api/cameras")
|
||||
async def add_camera(camera: Camera) -> AppConfig:
|
||||
cfg = await store.load()
|
||||
if any(c.name == camera.name for c in cfg.cameras):
|
||||
raise HTTPException(status_code=409, detail="camera_already_exists")
|
||||
|
||||
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.get("/api/mediamtx/config")
|
||||
async def get_mediamtx_config() -> MediaMTXConfigView:
|
||||
data = _load_mediamtx_yml()
|
||||
view = _build_mediamtx_view(data)
|
||||
await _sync_app_config_from_mediamtx()
|
||||
return view
|
||||
|
||||
|
||||
@app.delete("/api/cameras/{name}")
|
||||
async def delete_camera(name: str) -> AppConfig:
|
||||
cfg = await store.load()
|
||||
before = len(cfg.cameras)
|
||||
cfg.cameras = [c for c in cfg.cameras if c.name != name]
|
||||
if len(cfg.cameras) == before:
|
||||
@app.post("/api/mediamtx/cameras")
|
||||
async def add_mediamtx_camera(payload: MediaMTXAddCameraRequest) -> MediaMTXConfigView:
|
||||
data = _load_mediamtx_yml()
|
||||
paths = data.setdefault("paths", {})
|
||||
if not isinstance(paths, dict):
|
||||
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")
|
||||
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,
|
||||
username=cfg.mediamtx_api_user,
|
||||
password=cfg.mediamtx_api_pass,
|
||||
)
|
||||
try:
|
||||
await client.delete_path(name)
|
||||
except httpx.HTTPError as e:
|
||||
_raise_mediamtx_http_error(e)
|
||||
return cfg
|
||||
|
||||
@app.post("/api/mediamtx/recording")
|
||||
async def set_mediamtx_recording(data: RecordingToggle) -> MediaMTXConfigView:
|
||||
payload = _load_mediamtx_yml()
|
||||
path_defaults = payload.setdefault("pathDefaults", {})
|
||||
if not isinstance(path_defaults, dict):
|
||||
raise HTTPException(status_code=400, detail="invalid_mediamtx_path_defaults")
|
||||
path_defaults["record"] = bool(data.enabled)
|
||||
payload["pathDefaults"] = path_defaults
|
||||
_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")
|
||||
@@ -206,7 +330,7 @@ async def recordings(
|
||||
limit: int = 200,
|
||||
offset: int = 0,
|
||||
) -> list[dict]:
|
||||
cfg = await store.load()
|
||||
cfg = await _sync_app_config_from_mediamtx()
|
||||
if not any(c.name == camera for c in cfg.cameras):
|
||||
raise HTTPException(status_code=404, detail="camera_not_found")
|
||||
if limit < 1 or limit > 2000:
|
||||
|
||||
@@ -42,3 +42,18 @@ class ScheduleUpdate(BaseModel):
|
||||
weekdays_to: str = Field(pattern=r"^\d{2}:\d{2}$")
|
||||
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)
|
||||
|
||||
@@ -3,4 +3,5 @@ uvicorn[standard]>=0.27
|
||||
httpx>=0.27
|
||||
pydantic>=2.6
|
||||
python-multipart>=0.0.9
|
||||
pyyaml>=6.0.2
|
||||
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
+150
-35
@@ -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("");
|
||||
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 và 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 có camera.
|
||||
</div>
|
||||
@@ -102,16 +210,6 @@ 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
|
||||
@@ -121,10 +219,9 @@ export default function Settings() {
|
||||
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>
|
||||
<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">Có thể đổi record trong `mediamtx.yml` và 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>
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user