fix one machine only

This commit is contained in:
2026-04-30 21:29:11 +07:00
parent 5e1f529ed7
commit 176e6bdb8f
8 changed files with 185 additions and 149 deletions
-1
View File
@@ -1,4 +1,3 @@
# Frontend dev variables only. # Frontend dev variables only.
VITE_DEV_BACKEND_URL=http://localhost:8008 VITE_DEV_BACKEND_URL=http://localhost:8008
VITE_API_BASE_URL=/api VITE_API_BASE_URL=/api
VITE_MEDIAMTX_WEBRTC_URL=
+2 -63
View File
@@ -4,18 +4,7 @@ Tài liệu này hướng dẫn triển khai trên Orange Pi (Linux). Mục tiê
## 0) Mô hình triển khai ## 0) Mô hình triển khai
### A) 1 thiết bị (đơn giản nhất) - MediaMTX + Dashboard backend + Dashboard frontend chạy cùng máy.
- MediaMTX + Dashboard backend + Dashboard frontend chạy cùng máy
### B) 2 thiết bị (cùng LAN / cùng lớp mạng)
- Máy A: chạy MediaMTX + lưu recordings
- Máy B: chạy Dashboard backend + frontend
- Máy B sẽ gọi:
- MediaMTX API của máy A (port 9997) để add/remove camera + bật/tắt recording
- WebRTC/WHEP của máy A (port 8889 + UDP 8189) để xem live
- Playback: máy B cần đọc được recordings của máy A (khuyến nghị mount NFS/SMB)
## 1) Yêu cầu ## 1) Yêu cầu
@@ -42,13 +31,6 @@ recordPath: /recordings/%path/%Y-%m-%d_%H-%M-%S-%f
recordPartDuration: 5m recordPartDuration: 5m
``` ```
Nếu chạy mô hình 2 thiết bị, đảm bảo MediaMTX trả về đúng IP LAN để client kết nối ICE:
```yaml
webrtcAdditionalHosts:
- 192.168.88.10
```
### 2.1) Nếu MediaMTX API bị 401 Unauthorized ### 2.1) Nếu MediaMTX API bị 401 Unauthorized
401 nghĩa là request thiếu thông tin xác thực hợp lệ. Khi bật auth trong MediaMTX, bạn cần tạo user có quyền `api` và cấu hình backend dashboard gửi Basic Auth. 401 nghĩa là request thiếu thông tin xác thực hợp lệ. Khi bật auth trong MediaMTX, bạn cần tạo user có quyền `api` và cấu hình backend dashboard gửi Basic Auth.
@@ -88,33 +70,6 @@ Mở firewall/cổng (tuỳ hệ thống):
- `9997/tcp` MediaMTX Control API - `9997/tcp` MediaMTX Control API
- `8189/udp` ICE - `8189/udp` ICE
## 2.1) Playback khi tách 2 thiết bị (mount recordings)
Vì MediaMTX ghi file recordings trên máy A, nên Dashboard (máy B) cần truy cập được folder này để:
- list file `/api/recordings`
- serve file `/videos/...`
Khuyến nghị dùng NFS (Linux-Linux):
Máy A:
```bash
sudo apt-get update
sudo apt-get install -y nfs-kernel-server
echo "/recordings 192.168.88.0/24(rw,sync,no_subtree_check)" | sudo tee -a /etc/exports
sudo exportfs -ra
```
Máy B:
```bash
sudo apt-get update
sudo apt-get install -y nfs-common
sudo mkdir -p /recordings
sudo mount -t nfs 192.168.88.10:/recordings /recordings
```
## 3) Backend FastAPI ## 3) Backend FastAPI
### 3.1 Cài dependencies ### 3.1 Cài dependencies
@@ -131,6 +86,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`. Danh sách camera trong `config.json` sẽ được backend đồng bộ từ `mediamtx.yml`.
Các thông số kết nối MediaMTX (`mediamtx_api_url`, `mediamtx_webrtc_url`, credentials, `recordings_dir`) do user điền trong dashboard Settings hoặc chỉnh trực tiếp `config.json`.
Bạn có thể chỉnh: Bạn có thể chỉnh:
@@ -147,21 +103,6 @@ Các thao tác trong Settings:
- MediaMTX record: bật/tắt `pathDefaults.record` 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` - 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
{
"mediamtx_api_url": "http://192.168.88.10:9997",
"mediamtx_webrtc_url": "http://192.168.88.10:8889",
"mediamtx_api_user": null,
"mediamtx_api_pass": null,
"recordings_dir": "./mediamtx/recordings",
"api_port": 8008,
"cameras": [],
"schedule": { "enabled": true, "weekdays_from": "18:00", "weekdays_to": "08:00", "weekend_all_day": true }
}
```
### 3.3 Chạy backend ### 3.3 Chạy backend
```bash ```bash
@@ -212,8 +153,6 @@ server {
} }
``` ```
Nếu chạy mô hình 2 thiết bị, phần Nginx ở máy B không cần proxy tới MediaMTX. Frontend sẽ gọi trực tiếp `mediamtx_webrtc_url` (máy A).
## 5) Systemd service (khuyến nghị) ## 5) Systemd service (khuyến nghị)
### 5.1 Backend service ### 5.1 Backend service
+3 -9
View File
@@ -16,6 +16,7 @@ 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/config/basic` (cập nhật thông số `config.json`)
- `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`
@@ -50,6 +51,7 @@ Frontend dev server đã được cấu hình proxy `/api` và `/videos` sang `h
## Cấu hình ## Cấu hình
Backend chỉ dùng file `api/data/config.json` (không đọc `.env`). Backend chỉ dùng file `api/data/config.json` (không đọc `.env`).
Frontend dùng `.env` chỉ với `VITE_DEV_BACKEND_URL``VITE_API_BASE_URL`.
- `mediamtx_api_url`: ví dụ `http://127.0.0.1:9997` - `mediamtx_api_url`: ví dụ `http://127.0.0.1:9997`
- `mediamtx_webrtc_url`: ví dụ `http://127.0.0.1:8889` - `mediamtx_webrtc_url`: ví dụ `http://127.0.0.1:8889`
@@ -57,7 +59,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 đồng bộ từ `mediamtx.yml` (`name` + `rtsp_url`) - `cameras`: danh sách camera được đồ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`:
@@ -80,14 +82,6 @@ Ví dụ `config.json`:
} }
``` ```
## Chạy tách 2 thiết bị (cùng LAN)
- Máy A (MediaMTX + ổ lưu recordings): chạy MediaMTX, mở cổng `9997/tcp`, `8889/tcp`, `8189/udp`, `8554/tcp`
- Máy B (Dashboard backend + frontend): chạy FastAPI + serve web UI
- Playback: máy B cần đọc được thư mục recordings của máy A (khuyến nghị mount NFS/SMB và set `recordings_dir` trong `api/data/config.json`)
Chi tiết xem `INSTALL.md`.
## Triển khai ## Triển khai
Xem hướng dẫn chi tiết trong `INSTALL.md`. Xem hướng dẫn chi tiết trong `INSTALL.md`.
+37 -70
View File
@@ -5,7 +5,6 @@ import logging
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
from urllib.parse import urlparse
import httpx import httpx
import yaml import yaml
@@ -17,6 +16,7 @@ from .config_store import default_store
from .mediamtx_client import MediaMTXClient from .mediamtx_client import MediaMTXClient
from .models import ( from .models import (
AppConfig, AppConfig,
AppConfigUpdate,
Camera, Camera,
MediaMTXAddCameraRequest, MediaMTXAddCameraRequest,
MediaMTXCamera, MediaMTXCamera,
@@ -50,51 +50,11 @@ app.add_middleware(
store = default_store() store = default_store()
def _extract_port(address: str, fallback: int) -> int: def _clean_text(value: Optional[str]) -> Optional[str]:
if not address: if value is None:
return fallback return None
text = str(address).strip() cleaned = str(value).strip().strip("`'\"")
if text.startswith(":"): return cleaned or None
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 _host_from_url(url: str, fallback: str) -> str:
try:
parsed = urlparse(url)
host = (parsed.hostname or "").strip()
if host and host not in {"0.0.0.0", "::"}:
return host
except ValueError:
pass
return fallback
def _extract_host(address: str, fallback: str) -> str:
text = str(address or "").strip()
if not text:
return fallback
if text.startswith(":"):
return fallback
if "://" in text:
return _host_from_url(text, fallback)
if text.startswith("[") and "]" in text:
host = text[1 : text.index("]")]
return host or fallback
if ":" in text:
host = text.rsplit(":", 1)[0].strip()
if host and host not in {"0.0.0.0", "::"}:
return host
return fallback
if text in {"0.0.0.0", "::"}:
return fallback
return text
def _load_mediamtx_yml() -> dict: def _load_mediamtx_yml() -> dict:
@@ -111,33 +71,24 @@ def _save_mediamtx_yml(data: dict) -> None:
) )
def _build_mediamtx_view(data: dict, current_cfg: Optional[AppConfig] = None) -> MediaMTXConfigView: def _build_mediamtx_view(data: dict, cfg: AppConfig) -> MediaMTXConfigView:
api_port = _extract_port(str(data.get("apiAddress", ":9997")), 9997)
webrtc_port = _extract_port(str(data.get("webrtcAddress", ":8889")), 8889)
current_api_host = _host_from_url((current_cfg.mediamtx_api_url if current_cfg else ""), "127.0.0.1")
current_webrtc_host = _host_from_url((current_cfg.mediamtx_webrtc_url if current_cfg else ""), "127.0.0.1")
api_host = _extract_host(str(data.get("apiAddress", ":9997")), current_api_host)
hosts = data.get("webrtcAdditionalHosts") or []
host = hosts[0] if isinstance(hosts, list) and hosts else _extract_host(str(data.get("webrtcAddress", ":8889")), current_webrtc_host)
path_defaults = data.get("pathDefaults") or {} path_defaults = data.get("pathDefaults") or {}
record_enabled = bool(path_defaults.get("record", False)) record_enabled = bool(path_defaults.get("record", False))
cameras: list[MediaMTXCamera] = [] cameras: list[MediaMTXCamera] = []
paths = data.get("paths") or {} paths = data.get("paths") or {}
if isinstance(paths, dict): if isinstance(paths, dict):
for name, cfg in paths.items(): for name, path_cfg in paths.items():
if not isinstance(cfg, dict): if not isinstance(path_cfg, dict):
continue continue
source = cfg.get("source") source = path_cfg.get("source")
if isinstance(source, str) and source.strip(): if isinstance(source, str) and source.strip():
cameras.append(MediaMTXCamera(name=str(name), rtsp_url=source)) cameras.append(MediaMTXCamera(name=str(name), rtsp_url=source))
cameras.sort(key=lambda x: x.name) cameras.sort(key=lambda x: x.name)
return MediaMTXConfigView( return MediaMTXConfigView(
api_url=f"http://{api_host}:{api_port}", api_url=cfg.mediamtx_api_url,
webrtc_url=f"http://{host}:{webrtc_port}", webrtc_url=cfg.mediamtx_webrtc_url,
record_enabled=record_enabled, record_enabled=record_enabled,
cameras=cameras, cameras=cameras,
) )
@@ -146,11 +97,10 @@ def _build_mediamtx_view(data: dict, current_cfg: Optional[AppConfig] = None) ->
async def _sync_app_config_from_mediamtx() -> AppConfig: async def _sync_app_config_from_mediamtx() -> AppConfig:
cfg = await store.load() cfg = await store.load()
data = _load_mediamtx_yml() data = _load_mediamtx_yml()
view = _build_mediamtx_view(data, cfg) cfg.cameras = [
Camera(name=c.name, rtsp_url=c.rtsp_url)
cfg.mediamtx_api_url = view.api_url for c in _build_mediamtx_view(data, cfg).cameras
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) await store.save(cfg)
return cfg return cfg
@@ -274,14 +224,29 @@ async def get_config() -> AppConfig:
@app.get("/api/mediamtx/config") @app.get("/api/mediamtx/config")
async def get_mediamtx_config() -> MediaMTXConfigView: async def get_mediamtx_config() -> MediaMTXConfigView:
cfg = await store.load()
data = _load_mediamtx_yml() data = _load_mediamtx_yml()
view = _build_mediamtx_view(data, await store.load()) view = _build_mediamtx_view(data, cfg)
await _sync_app_config_from_mediamtx() await _sync_app_config_from_mediamtx()
return view return view
@app.post("/api/config/basic")
async def update_basic_config(payload: AppConfigUpdate) -> AppConfig:
cfg = await store.load()
cfg.mediamtx_api_url = _clean_text(payload.mediamtx_api_url) or cfg.mediamtx_api_url
cfg.mediamtx_webrtc_url = _clean_text(payload.mediamtx_webrtc_url) or cfg.mediamtx_webrtc_url
cfg.mediamtx_api_user = _clean_text(payload.mediamtx_api_user)
cfg.mediamtx_api_pass = _clean_text(payload.mediamtx_api_pass)
cfg.recordings_dir = _clean_text(payload.recordings_dir) or cfg.recordings_dir
cfg.api_port = payload.api_port
await store.save(cfg)
return await _sync_app_config_from_mediamtx()
@app.post("/api/mediamtx/cameras") @app.post("/api/mediamtx/cameras")
async def add_mediamtx_camera(payload: MediaMTXAddCameraRequest) -> MediaMTXConfigView: async def add_mediamtx_camera(payload: MediaMTXAddCameraRequest) -> MediaMTXConfigView:
cfg = await store.load()
data = _load_mediamtx_yml() data = _load_mediamtx_yml()
paths = data.setdefault("paths", {}) paths = data.setdefault("paths", {})
if not isinstance(paths, dict): if not isinstance(paths, dict):
@@ -295,11 +260,12 @@ async def add_mediamtx_camera(payload: MediaMTXAddCameraRequest) -> MediaMTXConf
paths[name] = {"source": payload.rtsp_url.strip()} paths[name] = {"source": payload.rtsp_url.strip()}
_save_mediamtx_yml(data) _save_mediamtx_yml(data)
await _sync_app_config_from_mediamtx() await _sync_app_config_from_mediamtx()
return _build_mediamtx_view(data, await store.load()) return _build_mediamtx_view(data, cfg)
@app.delete("/api/mediamtx/cameras/{name}") @app.delete("/api/mediamtx/cameras/{name}")
async def delete_mediamtx_camera(name: str) -> MediaMTXConfigView: async def delete_mediamtx_camera(name: str) -> MediaMTXConfigView:
cfg = await store.load()
data = _load_mediamtx_yml() data = _load_mediamtx_yml()
paths = data.get("paths") or {} paths = data.get("paths") or {}
if not isinstance(paths, dict) or name not in paths: if not isinstance(paths, dict) or name not in paths:
@@ -308,11 +274,12 @@ async def delete_mediamtx_camera(name: str) -> MediaMTXConfigView:
data["paths"] = paths data["paths"] = paths
_save_mediamtx_yml(data) _save_mediamtx_yml(data)
await _sync_app_config_from_mediamtx() await _sync_app_config_from_mediamtx()
return _build_mediamtx_view(data, await store.load()) return _build_mediamtx_view(data, cfg)
@app.post("/api/mediamtx/recording") @app.post("/api/mediamtx/recording")
async def set_mediamtx_recording(data: RecordingToggle) -> MediaMTXConfigView: async def set_mediamtx_recording(data: RecordingToggle) -> MediaMTXConfigView:
cfg = await store.load()
payload = _load_mediamtx_yml() payload = _load_mediamtx_yml()
path_defaults = payload.setdefault("pathDefaults", {}) path_defaults = payload.setdefault("pathDefaults", {})
if not isinstance(path_defaults, dict): if not isinstance(path_defaults, dict):
@@ -320,7 +287,7 @@ async def set_mediamtx_recording(data: RecordingToggle) -> MediaMTXConfigView:
path_defaults["record"] = bool(data.enabled) path_defaults["record"] = bool(data.enabled)
payload["pathDefaults"] = path_defaults payload["pathDefaults"] = path_defaults
_save_mediamtx_yml(payload) _save_mediamtx_yml(payload)
return _build_mediamtx_view(payload, await store.load()) return _build_mediamtx_view(payload, cfg)
@app.post("/api/mediamtx/restart") @app.post("/api/mediamtx/restart")
+9
View File
@@ -29,6 +29,15 @@ class AppConfig(BaseModel):
schedule: Schedule = Field(default_factory=Schedule) schedule: Schedule = Field(default_factory=Schedule)
class AppConfigUpdate(BaseModel):
mediamtx_api_url: str = Field(min_length=1, max_length=2048)
mediamtx_webrtc_url: str = Field(min_length=1, max_length=2048)
mediamtx_api_user: Optional[str] = None
mediamtx_api_pass: Optional[str] = None
recordings_dir: str = Field(min_length=1, max_length=4096)
api_port: int = Field(ge=1, le=65535, default=8008)
class RecordingToggle(BaseModel): class RecordingToggle(BaseModel):
enabled: bool enabled: bool
+124 -4
View File
@@ -2,7 +2,7 @@ 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 type { AppConfigUpdate, MediaMtxConfigView } from "@/types/api";
import { apiJson } from "@/utils/api"; import { apiJson } from "@/utils/api";
export default function Settings() { export default function Settings() {
@@ -24,6 +24,12 @@ export default function Settings() {
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 [mediamtxApiUrl, setMediamtxApiUrl] = useState("http://127.0.0.1:9997");
const [mediamtxWebrtcUrl, setMediamtxWebrtcUrl] = useState("http://127.0.0.1:8889");
const [mediamtxApiUser, setMediamtxApiUser] = useState("");
const [mediamtxApiPass, setMediamtxApiPass] = useState("");
const [recordingsDir, setRecordingsDir] = useState("");
const [apiPort, setApiPort] = useState("8008");
const loadMtx = async () => { const loadMtx = async () => {
setMtxBusy(true); setMtxBusy(true);
@@ -55,6 +61,16 @@ export default function Settings() {
setWeekendAllDay(schedule.weekend_all_day); setWeekendAllDay(schedule.weekend_all_day);
}, [schedule]); }, [schedule]);
useEffect(() => {
if (!config) return;
setMediamtxApiUrl(config.mediamtx_api_url);
setMediamtxWebrtcUrl(config.mediamtx_webrtc_url);
setMediamtxApiUser(config.mediamtx_api_user ?? "");
setMediamtxApiPass(config.mediamtx_api_pass ?? "");
setRecordingsDir(config.recordings_dir);
setApiPort(String(config.api_port ?? 8008));
}, [config]);
const canAdd = useMemo(() => rtspUrl.trim().length > 0, [rtspUrl]); const canAdd = useMemo(() => rtspUrl.trim().length > 0, [rtspUrl]);
const onAdd = async (e: FormEvent) => { const onAdd = async (e: FormEvent) => {
@@ -90,6 +106,38 @@ export default function Settings() {
}); });
}; };
const onSaveBasicConfig = async () => {
setMtxBusy(true);
setMtxError(null);
setRestartMsg(null);
const payload: AppConfigUpdate = {
mediamtx_api_url: mediamtxApiUrl.trim(),
mediamtx_webrtc_url: mediamtxWebrtcUrl.trim(),
mediamtx_api_user: mediamtxApiUser.trim() || null,
mediamtx_api_pass: mediamtxApiPass.trim() || null,
recordings_dir: recordingsDir.trim(),
api_port: Number(apiPort) || 8008,
};
try {
await apiJson("/config/basic", {
method: "POST",
body: JSON.stringify(payload),
});
await load();
await loadMtx();
setRestartMsg("Lưu config.json thành công");
} 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_save_config");
}
} finally {
setMtxBusy(false);
}
};
const onDelete = async (name: string) => { const onDelete = async (name: string) => {
setMtxBusy(true); setMtxBusy(true);
setMtxError(null); setMtxError(null);
@@ -232,9 +280,81 @@ export default function Settings() {
<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 & Scheduler</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-1 text-xs text-zinc-400">Camera lấy từ `mediamtx.yml`, các URL/credentials lấy từ `config.json`</div>
<div className="mt-4 space-y-3"> <div className="mt-4 space-y-3">
<div className="grid gap-2">
<div>
<label className="text-xs text-zinc-400">MediaMTX API URL</label>
<input
value={mediamtxApiUrl}
onChange={(e) => setMediamtxApiUrl(e.target.value)}
placeholder="http://127.0.0.1:9997"
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">MediaMTX WebRTC URL</label>
<input
value={mediamtxWebrtcUrl}
onChange={(e) => setMediamtxWebrtcUrl(e.target.value)}
placeholder="http://127.0.0.1:8889"
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 className="grid gap-2 md:grid-cols-2">
<div>
<label className="text-xs text-zinc-400">MediaMTX API User</label>
<input
value={mediamtxApiUser}
onChange={(e) => setMediamtxApiUser(e.target.value)}
placeholder="dashboard"
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">MediaMTX API Pass</label>
<input
type="password"
value={mediamtxApiPass}
onChange={(e) => setMediamtxApiPass(e.target.value)}
placeholder="password"
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 className="grid gap-2 md:grid-cols-2">
<div>
<label className="text-xs text-zinc-400">Recordings Dir</label>
<input
value={recordingsDir}
onChange={(e) => setRecordingsDir(e.target.value)}
placeholder="/mnt/ssd/IPCam_OrangePi_Dashboard/mediamtx/recordings"
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">API Port</label>
<input
type="number"
min={1}
max={65535}
value={apiPort}
onChange={(e) => setApiPort(e.target.value)}
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="button"
onClick={() => void onSaveBasicConfig()}
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"
>
Save Config
</button>
</div>
<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"
@@ -304,9 +424,9 @@ export default function Settings() {
</button> </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">{mtx?.api_url ?? config?.mediamtx_api_url ?? "-"}</span> MediaMTX API: <span className="text-zinc-200">{config?.mediamtx_api_url ?? "-"}</span>
<br /> <br />
WebRTC: <span className="text-zinc-200">{mtx?.webrtc_url ?? config?.mediamtx_webrtc_url ?? "-"}</span> WebRTC: <span className="text-zinc-200">{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
@@ -21,6 +21,15 @@ export type AppConfig = {
schedule: Schedule; schedule: Schedule;
}; };
export type AppConfigUpdate = {
mediamtx_api_url: string;
mediamtx_webrtc_url: string;
mediamtx_api_user?: string | null;
mediamtx_api_pass?: string | null;
recordings_dir: string;
api_port: number;
};
export type RecordingItem = { export type RecordingItem = {
camera: string; camera: string;
filename: string; filename: string;
+1 -2
View File
@@ -44,7 +44,6 @@ function normalizeLoopbackHost(rawUrl: string): string {
} }
export function getMediamtxWebrtcBaseUrl(configUrl?: string) { export function getMediamtxWebrtcBaseUrl(configUrl?: string) {
const env = import.meta.env.VITE_MEDIAMTX_WEBRTC_URL as string | undefined; return normalizeLoopbackHost(configUrl ?? "http://localhost:8889");
return normalizeLoopbackHost(env ?? configUrl ?? "http://localhost:8889");
} }