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"
|
"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
|
||||||
|
|||||||
@@ -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`:
|
||||||
|
|||||||
+163
-39
@@ -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,8 +49,111 @@ 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()
|
||||||
|
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]
|
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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
+150
-35
@@ -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);
|
||||||
|
try {
|
||||||
|
const data = await apiJson<MediaMtxConfigView>("/mediamtx/cameras", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ rtsp_url: rtspUrl.trim() }),
|
||||||
|
});
|
||||||
|
setMtx(data);
|
||||||
setRtspUrl("");
|
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 và 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 có camera.
|
Chưa có camera.
|
||||||
</div>
|
</div>
|
||||||
@@ -102,16 +210,6 @@ 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>
|
|
||||||
<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>
|
<div>
|
||||||
<label className="text-xs text-zinc-400">RTSP URL</label>
|
<label className="text-xs text-zinc-400">RTSP URL</label>
|
||||||
<input
|
<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"
|
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">Có thể đổi record trong `mediamtx.yml` và 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>
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user