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"
```
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
+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
- 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`:
+164 -40
View File
@@ -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,9 +49,112 @@ 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()
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:
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:
+15
View File
@@ -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)
+1
View File
@@ -3,4 +3,5 @@ uvicorn[standard]>=0.27
httpx>=0.27
pydantic>=2.6
python-multipart>=0.0.9
pyyaml>=6.0.2
+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[];
};