Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 126b588dc7 | |||
| c91f82bf28 |
+11
-2
@@ -28,6 +28,13 @@ logging.basicConfig(
|
|||||||
log = logging.getLogger("nvr")
|
log = logging.getLogger("nvr")
|
||||||
|
|
||||||
FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "..", "frontend")
|
FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "..", "frontend")
|
||||||
|
# Мобильный dashboard: тот же бэкенд слушает один порт, но в docker проброшен второй
|
||||||
|
# хост-порт (8443→8090). Запрос на него приходит с Host ".. :8443" → отдаём mobile.html.
|
||||||
|
MOBILE_PORT = os.environ.get("NVR_MOBILE_PORT", "8443")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_mobile(request: Request) -> bool:
|
||||||
|
return request.headers.get("host", "").endswith(":" + MOBILE_PORT)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -109,8 +116,10 @@ app.include_router(ws.router)
|
|||||||
|
|
||||||
# ── фронтенд ────────────────────────────────────────────────────
|
# ── фронтенд ────────────────────────────────────────────────────
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def index() -> FileResponse:
|
async def index(request: Request) -> FileResponse:
|
||||||
return FileResponse(os.path.join(FRONTEND_DIR, "index.html"))
|
# на мобильном порту — мобильный dashboard (Live одной камеры + Архив)
|
||||||
|
page = "mobile.html" if _is_mobile(request) else "index.html"
|
||||||
|
return FileResponse(os.path.join(FRONTEND_DIR, page))
|
||||||
|
|
||||||
|
|
||||||
@app.get("/archive")
|
@app.get("/archive")
|
||||||
|
|||||||
+2
-1
@@ -10,7 +10,8 @@ services:
|
|||||||
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8090",
|
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8090",
|
||||||
"--ssl-keyfile", "/app/certs/key.pem", "--ssl-certfile", "/app/certs/cert.pem"]
|
"--ssl-keyfile", "/app/certs/key.pem", "--ssl-certfile", "/app/certs/cert.pem"]
|
||||||
ports:
|
ports:
|
||||||
- "443:8090" # дашборд на https://<сервер>
|
- "443:8090" # десктоп-дашборд на https://<сервер>
|
||||||
|
- "8443:8090" # мобильный dashboard https://<сервер>:8443 (тот же бэкенд, mobile.html по Host)
|
||||||
volumes:
|
volumes:
|
||||||
- ./certs:/app/certs # self-signed TLS-сертификат (rw: entrypoint генерирует при отсутствии)
|
- ./certs:/app/certs # self-signed TLS-сертификат (rw: entrypoint генерирует при отсутствии)
|
||||||
- ./VERSION:/app/VERSION:ro # номер версии (показывается в UI)
|
- ./VERSION:/app/VERSION:ro # номер версии (показывается в UI)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#161a22">
|
||||||
|
<title>CoRE.Vizion+ — Mobile</title>
|
||||||
|
<link rel="stylesheet" href="/static/mobile.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="m-app">
|
||||||
|
<header class="m-head"><span class="m-brand">CoRE<span>.Vizion+</span></span></header>
|
||||||
|
|
||||||
|
<!-- LIVE: одна камера + выбор камеры -->
|
||||||
|
<section class="m-view" id="view-live">
|
||||||
|
<div class="m-bar">
|
||||||
|
<select id="m-live-cam" class="m-sel" title="Камера"></select>
|
||||||
|
</div>
|
||||||
|
<div class="m-player" id="m-live-player">
|
||||||
|
<video id="m-live-video" playsinline muted autoplay></video>
|
||||||
|
<div class="m-hint" id="m-live-hint">Нет камер</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- АРХИВ: камера + дата + список записей -->
|
||||||
|
<section class="m-view" id="view-arch" hidden>
|
||||||
|
<div class="m-bar">
|
||||||
|
<select id="m-arch-cam" class="m-sel" title="Камера"></select>
|
||||||
|
<input type="date" id="m-arch-date" class="m-date" title="Дата">
|
||||||
|
</div>
|
||||||
|
<div class="m-player">
|
||||||
|
<video id="m-arch-video" playsinline controls></video>
|
||||||
|
<div class="m-hint" id="m-arch-hint">Выберите запись ниже</div>
|
||||||
|
</div>
|
||||||
|
<div class="m-seglist" id="m-seglist"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<nav class="m-tabs">
|
||||||
|
<!-- одна кнопка: переключает режим Live ⇄ Архив (или/или) -->
|
||||||
|
<button class="m-mode" id="m-mode"><span class="i">⇆</span><span class="m-mode-name" id="m-mode-name">Live</span></button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
<script src="/static/mobile.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/* CoRE.Vizion+ — мобильный dashboard (отдельный порт). Тёмная тема, нижние вкладки. */
|
||||||
|
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
|
||||||
|
html, body {
|
||||||
|
margin: 0; height: 100%; background: #0f1115; color: #e6e9ef;
|
||||||
|
font: 15px/1.4 -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.m-app { display: flex; flex-direction: column; height: 100%; }
|
||||||
|
|
||||||
|
.m-head {
|
||||||
|
height: 44px; flex: none; display: flex; align-items: center; justify-content: center;
|
||||||
|
background: #161a22; border-bottom: 1px solid #272d3a;
|
||||||
|
}
|
||||||
|
.m-brand { font-weight: 700; font-size: 16px; letter-spacing: .3px; }
|
||||||
|
.m-brand span { color: #c0392b; }
|
||||||
|
|
||||||
|
.m-view { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
|
||||||
|
.m-bar {
|
||||||
|
flex: none; display: flex; gap: 8px; padding: 8px;
|
||||||
|
background: #161a22; border-bottom: 1px solid #272d3a;
|
||||||
|
}
|
||||||
|
.m-sel, .m-date {
|
||||||
|
flex: 1; min-width: 0; background: #1d2230; color: #e6e9ef; border: 1px solid #272d3a;
|
||||||
|
border-radius: 8px; padding: 10px 12px; font-size: 15px; -webkit-appearance: none; appearance: none;
|
||||||
|
}
|
||||||
|
.m-date { flex: 0 0 auto; }
|
||||||
|
|
||||||
|
.m-player { position: relative; background: #000; flex: none; }
|
||||||
|
.m-player video { width: 100%; aspect-ratio: 16 / 9; display: block; background: #000; object-fit: contain; }
|
||||||
|
.m-hint {
|
||||||
|
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||||
|
color: #8b93a7; font-size: 14px; pointer-events: none; text-align: center; padding: 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.m-seglist { flex: 1; min-height: 0; overflow-y: auto; padding: 8px; -webkit-overflow-scrolling: touch; }
|
||||||
|
.m-seg {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; width: 100%; text-align: left;
|
||||||
|
background: #161a22; color: #e6e9ef; border: 1px solid #272d3a; border-radius: 8px;
|
||||||
|
padding: 13px 14px; margin-bottom: 6px; font-size: 15px; font-variant-numeric: tabular-nums; cursor: pointer;
|
||||||
|
}
|
||||||
|
.m-seg:active { background: #1d2230; }
|
||||||
|
.m-seg.active { background: #c0392b; border-color: #c0392b; color: #fff; }
|
||||||
|
.m-dur { color: #8b93a7; font-size: 13px; }
|
||||||
|
.m-seg.active .m-dur { color: #fff; }
|
||||||
|
.m-empty { color: #8b93a7; text-align: center; padding: 28px 16px; font-size: 14px; }
|
||||||
|
|
||||||
|
.m-tabs {
|
||||||
|
flex: none; display: flex; justify-content: center; background: #161a22;
|
||||||
|
border-top: 1px solid #272d3a; padding: 8px 12px;
|
||||||
|
padding-bottom: calc(8px + env(safe-area-inset-bottom, 0));
|
||||||
|
}
|
||||||
|
/* одна кнопка-переключатель режима (или/или): показывает текущий режим, тап — меняет */
|
||||||
|
.m-mode {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 10px;
|
||||||
|
min-width: 190px; background: #1d2230; color: #e6e9ef; border: 1px solid #272d3a;
|
||||||
|
border-radius: 10px; padding: 12px 28px; cursor: pointer;
|
||||||
|
font: 600 15px/1 inherit; letter-spacing: .3px;
|
||||||
|
}
|
||||||
|
.m-mode:active { background: #232a3a; }
|
||||||
|
.m-mode .i { font-size: 18px; color: #c0392b; }
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// CoRE.Vizion+ — мобильный dashboard. 2 режима: Live (1 камера + выбор) и Архив.
|
||||||
|
// Видео отдаётся сервером уже перекодированным в H.264 (обычный <video>, играет на любом телефоне).
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// фиксированный профиль H.264 для телефона (макс. совместимость)
|
||||||
|
const TR = "codec=h264&profile=baseline&bitrate=2000k&preset=ultrafast&keyint=2&rc=vbr&qp=23";
|
||||||
|
|
||||||
|
function pad(n) { return String(n).padStart(2, "0"); }
|
||||||
|
async function api(url) {
|
||||||
|
const r = await fetch(url, { credentials: "same-origin" });
|
||||||
|
if (r.status === 401) { location.href = "/login"; throw new Error("unauthorized"); }
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const M = {
|
||||||
|
cams: [], liveId: null, archId: null, date: null, view: "live",
|
||||||
|
|
||||||
|
async init() {
|
||||||
|
let data;
|
||||||
|
try { data = await api("/api/cameras"); } catch (e) { return; }
|
||||||
|
this.cams = data.cameras || [];
|
||||||
|
const enabled = this.cams.filter((c) => c.enabled);
|
||||||
|
|
||||||
|
const lc = document.getElementById("m-live-cam");
|
||||||
|
const ac = document.getElementById("m-arch-cam");
|
||||||
|
lc.innerHTML = enabled.map((c) => `<option value="${c.id}">${c.name}</option>`).join("");
|
||||||
|
ac.innerHTML = this.cams.map((c) => `<option value="${c.id}">${c.name}</option>`).join("");
|
||||||
|
this.liveId = enabled[0] ? enabled[0].id : null;
|
||||||
|
this.archId = this.cams[0] ? this.cams[0].id : null;
|
||||||
|
lc.onchange = () => { this.liveId = lc.value; this.startLive(); };
|
||||||
|
ac.onchange = () => { this.archId = ac.value; this.loadArch(); };
|
||||||
|
|
||||||
|
const dt = document.getElementById("m-arch-date");
|
||||||
|
const t = new Date();
|
||||||
|
this.date = `${t.getFullYear()}-${pad(t.getMonth() + 1)}-${pad(t.getDate())}`;
|
||||||
|
dt.value = this.date;
|
||||||
|
dt.onchange = () => { this.date = dt.value; this.loadArch(); };
|
||||||
|
|
||||||
|
// тап по живому видео — на весь экран
|
||||||
|
const lv = document.getElementById("m-live-video");
|
||||||
|
lv.addEventListener("click", () => {
|
||||||
|
if (lv.requestFullscreen) lv.requestFullscreen().catch(() => {});
|
||||||
|
else if (lv.webkitEnterFullscreen) lv.webkitEnterFullscreen(); // iOS
|
||||||
|
});
|
||||||
|
|
||||||
|
// одна кнопка снизу — переключатель режима (или/или): Live ⇄ Архив
|
||||||
|
document.getElementById("m-mode").onclick = () =>
|
||||||
|
this.showView(this.view === "live" ? "arch" : "live");
|
||||||
|
|
||||||
|
this.showView("live");
|
||||||
|
},
|
||||||
|
|
||||||
|
showView(v) {
|
||||||
|
this.view = v;
|
||||||
|
document.getElementById("m-mode-name").textContent = v === "live" ? "Live" : "Архив";
|
||||||
|
document.getElementById("view-live").hidden = v !== "live";
|
||||||
|
document.getElementById("view-arch").hidden = v !== "arch";
|
||||||
|
if (v === "live") { this.stopArch(); this.startLive(); }
|
||||||
|
else { this.stopLive(); this.loadArch(); }
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Live ──
|
||||||
|
startLive() {
|
||||||
|
const v = document.getElementById("m-live-video");
|
||||||
|
const hint = document.getElementById("m-live-hint");
|
||||||
|
if (!this.liveId) { this.stopLive(); hint.textContent = "Нет доступных камер"; hint.hidden = false; return; }
|
||||||
|
hint.hidden = true;
|
||||||
|
v.src = `/api/cameras/${this.liveId}/live.mp4?stream=sub&${TR}`;
|
||||||
|
v.play().catch(() => {});
|
||||||
|
},
|
||||||
|
stopLive() {
|
||||||
|
const v = document.getElementById("m-live-video");
|
||||||
|
v.pause(); v.removeAttribute("src"); v.load(); // обрыв запроса → сервер гасит ffmpeg
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── Архив ──
|
||||||
|
async loadArch() {
|
||||||
|
const box = document.getElementById("m-seglist");
|
||||||
|
this.stopArch();
|
||||||
|
document.getElementById("m-arch-hint").hidden = false;
|
||||||
|
if (!this.archId || !this.date) { box.innerHTML = ""; return; }
|
||||||
|
const [y, m, d] = this.date.split("-").map(Number);
|
||||||
|
const from = Math.floor(new Date(y, m - 1, d, 0, 0, 0).getTime() / 1000);
|
||||||
|
const to = from + 86400;
|
||||||
|
box.innerHTML = '<div class="m-empty">Загрузка…</div>';
|
||||||
|
let recs = [];
|
||||||
|
try { recs = (await api(`/api/recordings?camera=${this.archId}&from=${from}&to=${to}`)).recordings || []; }
|
||||||
|
catch (e) { box.innerHTML = '<div class="m-empty">Ошибка загрузки</div>'; return; }
|
||||||
|
if (!recs.length) { box.innerHTML = '<div class="m-empty">За выбранный день записей нет</div>'; return; }
|
||||||
|
box.innerHTML = recs.map((r) => {
|
||||||
|
const t = new Date(r.started_at * 1000);
|
||||||
|
const mins = Math.round((r.duration_s || 0) / 60);
|
||||||
|
return `<button class="m-seg" data-id="${r.id}">` +
|
||||||
|
`${pad(t.getHours())}:${pad(t.getMinutes())}:${pad(t.getSeconds())}` +
|
||||||
|
`<span class="m-dur">${mins} мин</span></button>`;
|
||||||
|
}).join("");
|
||||||
|
box.onclick = (e) => {
|
||||||
|
const b = e.target.closest(".m-seg");
|
||||||
|
if (!b) return;
|
||||||
|
box.querySelectorAll(".m-seg").forEach((x) => x.classList.toggle("active", x === b));
|
||||||
|
this.playSeg(+b.dataset.id);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
playSeg(id) {
|
||||||
|
const v = document.getElementById("m-arch-video");
|
||||||
|
document.getElementById("m-arch-hint").hidden = true;
|
||||||
|
v.src = `/api/recordings/${id}/play.mp4?${TR}`;
|
||||||
|
v.play().catch(() => {});
|
||||||
|
},
|
||||||
|
stopArch() {
|
||||||
|
const v = document.getElementById("m-arch-video");
|
||||||
|
v.pause(); v.removeAttribute("src"); v.load();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => M.init());
|
||||||
Reference in New Issue
Block a user