fix one machine only
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
# Frontend dev variables only.
|
||||
VITE_DEV_BACKEND_URL=http://localhost:8008
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_MEDIAMTX_WEBRTC_URL=
|
||||
|
||||
+2
-63
@@ -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
|
||||
|
||||
### A) 1 thiết bị (đơn giản nhất)
|
||||
|
||||
- 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)
|
||||
- MediaMTX + Dashboard backend + Dashboard frontend chạy cùng máy.
|
||||
|
||||
## 1) Yêu cầu
|
||||
|
||||
@@ -42,13 +31,6 @@ recordPath: /recordings/%path/%Y-%m-%d_%H-%M-%S-%f
|
||||
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
|
||||
|
||||
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
|
||||
- `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.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`).
|
||||
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`.
|
||||
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:
|
||||
|
||||
@@ -147,21 +103,6 @@ Các thao tác trong Settings:
|
||||
- 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
|
||||
{
|
||||
"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
|
||||
|
||||
```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.1 Backend service
|
||||
|
||||
@@ -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/config`
|
||||
- `POST /api/config/basic` (cập nhật thông số `config.json`)
|
||||
- `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`
|
||||
@@ -50,6 +51,7 @@ Frontend dev server đã được cấu hình proxy `/api` và `/videos` sang `h
|
||||
## Cấu hình
|
||||
|
||||
Backend chỉ dùng file `api/data/config.json` (không đọc `.env`).
|
||||
Frontend dùng `.env` chỉ với `VITE_DEV_BACKEND_URL` và `VITE_API_BASE_URL`.
|
||||
|
||||
- `mediamtx_api_url`: ví dụ `http://127.0.0.1:9997`
|
||||
- `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)
|
||||
- `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 đồ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
|
||||
|
||||
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
|
||||
|
||||
Xem hướng dẫn chi tiết trong `INSTALL.md`.
|
||||
|
||||
+37
-70
@@ -5,7 +5,6 @@ import logging
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
import yaml
|
||||
@@ -17,6 +16,7 @@ from .config_store import default_store
|
||||
from .mediamtx_client import MediaMTXClient
|
||||
from .models import (
|
||||
AppConfig,
|
||||
AppConfigUpdate,
|
||||
Camera,
|
||||
MediaMTXAddCameraRequest,
|
||||
MediaMTXCamera,
|
||||
@@ -50,51 +50,11 @@ 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 _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 _clean_text(value: Optional[str]) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
cleaned = str(value).strip().strip("`'\"")
|
||||
return cleaned or None
|
||||
|
||||
|
||||
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:
|
||||
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)
|
||||
|
||||
def _build_mediamtx_view(data: dict, cfg: AppConfig) -> MediaMTXConfigView:
|
||||
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):
|
||||
for name, path_cfg in paths.items():
|
||||
if not isinstance(path_cfg, dict):
|
||||
continue
|
||||
source = cfg.get("source")
|
||||
source = path_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://{api_host}:{api_port}",
|
||||
webrtc_url=f"http://{host}:{webrtc_port}",
|
||||
api_url=cfg.mediamtx_api_url,
|
||||
webrtc_url=cfg.mediamtx_webrtc_url,
|
||||
record_enabled=record_enabled,
|
||||
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:
|
||||
cfg = await store.load()
|
||||
data = _load_mediamtx_yml()
|
||||
view = _build_mediamtx_view(data, cfg)
|
||||
|
||||
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]
|
||||
cfg.cameras = [
|
||||
Camera(name=c.name, rtsp_url=c.rtsp_url)
|
||||
for c in _build_mediamtx_view(data, cfg).cameras
|
||||
]
|
||||
await store.save(cfg)
|
||||
return cfg
|
||||
|
||||
@@ -274,14 +224,29 @@ async def get_config() -> AppConfig:
|
||||
|
||||
@app.get("/api/mediamtx/config")
|
||||
async def get_mediamtx_config() -> MediaMTXConfigView:
|
||||
cfg = await store.load()
|
||||
data = _load_mediamtx_yml()
|
||||
view = _build_mediamtx_view(data, await store.load())
|
||||
view = _build_mediamtx_view(data, cfg)
|
||||
await _sync_app_config_from_mediamtx()
|
||||
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")
|
||||
async def add_mediamtx_camera(payload: MediaMTXAddCameraRequest) -> MediaMTXConfigView:
|
||||
cfg = await store.load()
|
||||
data = _load_mediamtx_yml()
|
||||
paths = data.setdefault("paths", {})
|
||||
if not isinstance(paths, dict):
|
||||
@@ -295,11 +260,12 @@ async def add_mediamtx_camera(payload: MediaMTXAddCameraRequest) -> MediaMTXConf
|
||||
paths[name] = {"source": payload.rtsp_url.strip()}
|
||||
_save_mediamtx_yml(data)
|
||||
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}")
|
||||
async def delete_mediamtx_camera(name: str) -> MediaMTXConfigView:
|
||||
cfg = await store.load()
|
||||
data = _load_mediamtx_yml()
|
||||
paths = data.get("paths") or {}
|
||||
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
|
||||
_save_mediamtx_yml(data)
|
||||
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")
|
||||
async def set_mediamtx_recording(data: RecordingToggle) -> MediaMTXConfigView:
|
||||
cfg = await store.load()
|
||||
payload = _load_mediamtx_yml()
|
||||
path_defaults = payload.setdefault("pathDefaults", {})
|
||||
if not isinstance(path_defaults, dict):
|
||||
@@ -320,7 +287,7 @@ async def set_mediamtx_recording(data: RecordingToggle) -> MediaMTXConfigView:
|
||||
path_defaults["record"] = bool(data.enabled)
|
||||
payload["pathDefaults"] = path_defaults
|
||||
_save_mediamtx_yml(payload)
|
||||
return _build_mediamtx_view(payload, await store.load())
|
||||
return _build_mediamtx_view(payload, cfg)
|
||||
|
||||
|
||||
@app.post("/api/mediamtx/restart")
|
||||
|
||||
@@ -29,6 +29,15 @@ class AppConfig(BaseModel):
|
||||
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):
|
||||
enabled: bool
|
||||
|
||||
|
||||
+124
-4
@@ -2,7 +2,7 @@ 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 type { AppConfigUpdate, MediaMtxConfigView } from "@/types/api";
|
||||
import { apiJson } from "@/utils/api";
|
||||
|
||||
export default function Settings() {
|
||||
@@ -24,6 +24,12 @@ export default function Settings() {
|
||||
const [weekdaysFrom, setWeekdaysFrom] = useState("18:00");
|
||||
const [weekdaysTo, setWeekdaysTo] = useState("08:00");
|
||||
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 () => {
|
||||
setMtxBusy(true);
|
||||
@@ -55,6 +61,16 @@ export default function Settings() {
|
||||
setWeekendAllDay(schedule.weekend_all_day);
|
||||
}, [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 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) => {
|
||||
setMtxBusy(true);
|
||||
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="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-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="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">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -304,9 +424,9 @@ export default function Settings() {
|
||||
</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">{mtx?.api_url ?? config?.mediamtx_api_url ?? "-"}</span>
|
||||
MediaMTX API: <span className="text-zinc-200">{config?.mediamtx_api_url ?? "-"}</span>
|
||||
<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 />
|
||||
Recordings dir: <span className="text-zinc-200">{config?.recordings_dir ?? "-"}</span>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,15 @@ export type AppConfig = {
|
||||
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 = {
|
||||
camera: string;
|
||||
filename: string;
|
||||
|
||||
+1
-2
@@ -44,7 +44,6 @@ function normalizeLoopbackHost(rawUrl: string): string {
|
||||
}
|
||||
|
||||
export function getMediamtxWebrtcBaseUrl(configUrl?: string) {
|
||||
const env = import.meta.env.VITE_MEDIAMTX_WEBRTC_URL as string | undefined;
|
||||
return normalizeLoopbackHost(env ?? configUrl ?? "http://localhost:8889");
|
||||
return normalizeLoopbackHost(configUrl ?? "http://localhost:8889");
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user