mirror of
https://github.com/BetterCorp/BetterFrame.git
synced 2026-05-26 16:56:33 +00:00
fix(onvif): import profiles as streams
This commit is contained in:
parent
3be1a9a624
commit
02e57a5d54
5 changed files with 308 additions and 43 deletions
|
|
@ -245,6 +245,7 @@ Everything else is a shared module (plain TS, no BSB lifecycle).
|
|||
- **BSB config doesn't apply schema defaults** for keys missing from sec-config.yaml. Always declare config values explicitly
|
||||
- **Cookie signing uses HKDF-derived key** (deterministic). NOT encryptString (random IV = non-deterministic = broken)
|
||||
- **RTSP URLs with special chars** in password: URL-encode user/pass components. Camera form splits into host/port/path/user/pass fields, builds URL server-side
|
||||
- **ONVIF discovery import**: ONVIF profiles are streams, not cameras. Group profiles by VideoSourceConfiguration/SourceToken (fallback to channel-ish URI/name), assign largest stream `main`, next `sub`, rest `other`, and import one camera with multiple `camera_streams`. If RTSP URIs omit userinfo, inject the ONVIF username/password before storing so kiosk playback avoids RTSP 401.
|
||||
- **GStreamer on Pi5**: hw H265 decoder rejects non-standard resolutions (960x1080). Use avdec_h265 (sw) as fallback
|
||||
- **Log message strings MUST be string literals** (BSB SmartLogMeta extracts placeholders from literal type)
|
||||
- **Datetimes are ISO-8601 strings** stored as TEXT
|
||||
|
|
|
|||
|
|
@ -29,6 +29,18 @@ import {
|
|||
} from "../../web-templates/admin-pages.js";
|
||||
import { discover as onvifDiscover } from "../../shared/onvif.js";
|
||||
|
||||
interface DiscoverAddStream {
|
||||
profile_name: string;
|
||||
profile_token: string;
|
||||
source_token: string | null;
|
||||
encoding: string | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
framerate: number | null;
|
||||
stream_uri: string;
|
||||
role: "main" | "sub" | "other";
|
||||
}
|
||||
|
||||
function htmlFragment(markup: unknown): Response {
|
||||
return new Response(String(markup), {
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
|
|
@ -54,6 +66,29 @@ function sanitizeRtspUrl(raw: string): string {
|
|||
return `${scheme}${user}:${pass}@${rest}`;
|
||||
}
|
||||
|
||||
function uniqueCameraName(deps: AdminDeps, rawName: string): string {
|
||||
let name = rawName;
|
||||
if (deps.repo.getCameraByName(name)) {
|
||||
let i = 2;
|
||||
while (deps.repo.getCameraByName(`${rawName} (${String(i)})`)) i += 1;
|
||||
name = `${rawName} (${String(i)})`;
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function rtspWithCredentials(raw: string, username: string, password: string): string {
|
||||
if (!username) return raw;
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (url.protocol !== "rtsp:" || url.username) return raw;
|
||||
url.username = username;
|
||||
url.password = password;
|
||||
return url.toString();
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
||||
// ---- Overview -------------------------------------------------------------
|
||||
|
||||
|
|
@ -180,11 +215,13 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
|||
}
|
||||
|
||||
try {
|
||||
const profiles = await onvifDiscover({ host, port, username, password });
|
||||
const cameras = await onvifDiscover({ host, port, username, password });
|
||||
return htmlPage(CameraDiscoverResultsPage({
|
||||
user: user.username,
|
||||
host,
|
||||
profiles,
|
||||
username,
|
||||
password,
|
||||
cameras,
|
||||
}));
|
||||
} catch (err) {
|
||||
return htmlPage(CameraDiscoverPage({
|
||||
|
|
@ -198,42 +235,46 @@ export function registerAdminRoutes(app: H3, deps: AdminDeps): void {
|
|||
app.post("/admin/cameras/discover/add", async (event) => {
|
||||
const body = await readBody<Record<string, string>>(event);
|
||||
const rawName = (body?.["name"] ?? "").trim() || "ONVIF camera";
|
||||
const rtspUrl = (body?.["rtsp_url"] ?? "").trim();
|
||||
const encoding = (body?.["encoding"] ?? "").trim() || null;
|
||||
const profileToken = (body?.["profile_token"] ?? "").trim() || null;
|
||||
const width = body?.["width"] ? Number(body["width"]) : null;
|
||||
const height = body?.["height"] ? Number(body["height"]) : null;
|
||||
const framerate = body?.["framerate"] ? Number(body["framerate"]) : null;
|
||||
const username = (body?.["username"] ?? "").trim();
|
||||
const password = body?.["password"] ?? "";
|
||||
let streams: DiscoverAddStream[] = [];
|
||||
try {
|
||||
const parsed = JSON.parse(body?.["streams_json"] ?? "[]") as DiscoverAddStream[];
|
||||
streams = Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
streams = [];
|
||||
}
|
||||
|
||||
if (!rtspUrl) {
|
||||
if (streams.length === 0) {
|
||||
return new Response(null, { status: 302, headers: { location: "/admin/cameras/discover" } });
|
||||
}
|
||||
|
||||
// Resolve a unique camera name
|
||||
let name = rawName;
|
||||
if (deps.repo.getCameraByName(name)) {
|
||||
let i = 2;
|
||||
while (deps.repo.getCameraByName(`${rawName} (${String(i)})`)) i += 1;
|
||||
name = `${rawName} (${String(i)})`;
|
||||
}
|
||||
const main = streams.find((s) => s.role === "main") ?? streams[0]!;
|
||||
const mainRtspUrl = rtspWithCredentials(main.stream_uri, username, password);
|
||||
const name = uniqueCameraName(deps, rawName);
|
||||
|
||||
const cam = deps.repo.createCamera({
|
||||
name,
|
||||
type: "rtsp",
|
||||
rtsp_url: rtspUrl,
|
||||
});
|
||||
deps.repo.createCameraStream({
|
||||
camera_id: cam.id,
|
||||
role: "main",
|
||||
name: "Main",
|
||||
rtsp_uri: rtspUrl,
|
||||
profile_token: profileToken,
|
||||
width: Number.isFinite(width) ? width : null,
|
||||
height: Number.isFinite(height) ? height : null,
|
||||
encoding,
|
||||
framerate: Number.isFinite(framerate) ? framerate : null,
|
||||
is_discovered: true,
|
||||
rtsp_url: mainRtspUrl,
|
||||
});
|
||||
for (const stream of streams) {
|
||||
const width = stream.width == null ? null : Number(stream.width);
|
||||
const height = stream.height == null ? null : Number(stream.height);
|
||||
const framerate = stream.framerate == null ? null : Number(stream.framerate);
|
||||
deps.repo.createCameraStream({
|
||||
camera_id: cam.id,
|
||||
role: stream.role === "main" || stream.role === "sub" ? stream.role : "other",
|
||||
name: stream.profile_name || stream.role,
|
||||
rtsp_uri: rtspWithCredentials(stream.stream_uri, username, password),
|
||||
profile_token: stream.profile_token || null,
|
||||
width: Number.isFinite(width) ? width : null,
|
||||
height: Number.isFinite(height) ? height : null,
|
||||
encoding: stream.encoding || null,
|
||||
framerate: Number.isFinite(framerate) ? framerate : null,
|
||||
is_discovered: true,
|
||||
});
|
||||
}
|
||||
notifyKiosks();
|
||||
|
||||
return new Response(null, { status: 302, headers: { location: "/admin/cameras" } });
|
||||
|
|
|
|||
|
|
@ -1191,7 +1191,9 @@ export class Repository {
|
|||
for (const [k, v] of Object.entries(patch)) {
|
||||
if (k === "id" || k === "created_at") continue;
|
||||
sets.push(`${k} = ?`);
|
||||
vals.push(v === undefined ? null : v);
|
||||
if (k === "capabilities") vals.push(J(v));
|
||||
else if (typeof v === "boolean") vals.push(B(v));
|
||||
else vals.push(v === undefined ? null : v);
|
||||
}
|
||||
if (sets.length === 0) return;
|
||||
vals.push(id);
|
||||
|
|
|
|||
|
|
@ -14,11 +14,19 @@ import { createHash, randomBytes } from "node:crypto";
|
|||
export interface DiscoveredProfile {
|
||||
profile_name: string;
|
||||
profile_token: string;
|
||||
source_token: string | null;
|
||||
encoding: string | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
framerate: number | null;
|
||||
stream_uri: string;
|
||||
role: "main" | "sub" | "other";
|
||||
}
|
||||
|
||||
export interface DiscoveredCamera {
|
||||
name: string;
|
||||
source_token: string | null;
|
||||
profiles: DiscoveredProfile[];
|
||||
}
|
||||
|
||||
interface DiscoverInput {
|
||||
|
|
@ -32,6 +40,12 @@ interface DiscoverInput {
|
|||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
interface EndpointParts {
|
||||
origin: string;
|
||||
deviceUrl: string;
|
||||
explicitMediaUrl: string | null;
|
||||
}
|
||||
|
||||
function wsseHeader(username: string, password: string): string {
|
||||
// WS-Security UsernameToken with PasswordDigest (the ONVIF-standard form).
|
||||
// PasswordDigest = Base64( SHA1( nonce + created + password ) )
|
||||
|
|
@ -54,6 +68,10 @@ function wsseHeader(username: string, password: string): string {
|
|||
</s:Header>`;
|
||||
}
|
||||
|
||||
function optionalWsseHeader(username: string, password: string): string {
|
||||
return username ? wsseHeader(username, password) : "";
|
||||
}
|
||||
|
||||
function escapeXml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
|
|
@ -86,6 +104,14 @@ async function soap(url: string, action: string, body: string, timeoutMs: number
|
|||
}
|
||||
}
|
||||
|
||||
async function trySoap(url: string, action: string, body: string, timeoutMs: number): Promise<string | null> {
|
||||
try {
|
||||
return await soap(url, action, body, timeoutMs);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildEnvelope(headerXml: string, bodyXml: string): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
|
||||
|
|
@ -119,6 +145,68 @@ function pickAttr(xml: string, tagLocalName: string, attr: string): string[] {
|
|||
return out;
|
||||
}
|
||||
|
||||
function pickFirstXAddr(parentXml: string, tagLocalName: string): string | null {
|
||||
const block = pickNested(parentXml, tagLocalName);
|
||||
if (!block) return null;
|
||||
return pickNested(block, "XAddr");
|
||||
}
|
||||
|
||||
function normalizeEndpoint(input: DiscoverInput): EndpointParts {
|
||||
const raw = input.host.trim();
|
||||
const port = input.port || 80;
|
||||
|
||||
if (/^https?:\/\//i.test(raw)) {
|
||||
const u = new URL(raw);
|
||||
const resolvedPort = u.port ? Number(u.port) : u.protocol === "https:" ? 443 : port;
|
||||
const origin = `${u.protocol}//${u.hostname}:${String(resolvedPort)}`;
|
||||
const path = u.pathname && u.pathname !== "/" ? u.pathname : "";
|
||||
return {
|
||||
origin,
|
||||
deviceUrl: path ? `${origin}${path}` : `${origin}/onvif/device_service`,
|
||||
explicitMediaUrl: path ? `${origin}${path}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
const origin = `http://${raw}:${String(port)}`;
|
||||
return {
|
||||
origin,
|
||||
deviceUrl: `${origin}/onvif/device_service`,
|
||||
explicitMediaUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function discoverMediaUrl(input: DiscoverInput, endpoint: EndpointParts, timeoutMs: number): Promise<string> {
|
||||
if (input.mediaPath) {
|
||||
return `${endpoint.origin}${input.mediaPath.startsWith("/") ? input.mediaPath : `/${input.mediaPath}`}`;
|
||||
}
|
||||
|
||||
if (endpoint.explicitMediaUrl) {
|
||||
return endpoint.explicitMediaUrl;
|
||||
}
|
||||
|
||||
const capabilitiesEnv = buildEnvelope(
|
||||
optionalWsseHeader(input.username, input.password),
|
||||
`<tds:GetCapabilities><tds:Category>All</tds:Category></tds:GetCapabilities>`,
|
||||
).replace(
|
||||
'xmlns:tt="http://www.onvif.org/ver10/schema">',
|
||||
'xmlns:tt="http://www.onvif.org/ver10/schema" xmlns:tds="http://www.onvif.org/ver10/device/wsdl">',
|
||||
);
|
||||
const capabilitiesXml = await trySoap(
|
||||
endpoint.deviceUrl,
|
||||
"http://www.onvif.org/ver10/device/wsdl/GetCapabilities",
|
||||
capabilitiesEnv,
|
||||
timeoutMs,
|
||||
);
|
||||
if (capabilitiesXml) {
|
||||
const mediaXAddr = pickFirstXAddr(capabilitiesXml, "Media");
|
||||
if (mediaXAddr) return mediaXAddr;
|
||||
}
|
||||
|
||||
// Common vendor endpoints. Prefer lower-case media_service because many NVRs
|
||||
// advertise that path and return 404 for /onvif/Media.
|
||||
return `${endpoint.origin}/onvif/media_service`;
|
||||
}
|
||||
|
||||
// Pull a single nested value from a parent element block.
|
||||
function pickNested(parentXml: string, tagLocalName: string): string | null {
|
||||
const m = parentXml.match(new RegExp(`<(?:[\\w-]+:)?${tagLocalName}\\b[^>]*>([\\s\\S]*?)</(?:[\\w-]+:)?${tagLocalName}>`));
|
||||
|
|
@ -136,6 +224,50 @@ function splitProfiles(xml: string): string[] {
|
|||
return out;
|
||||
}
|
||||
|
||||
function streamArea(p: DiscoveredProfile): number {
|
||||
return (p.width ?? 0) * (p.height ?? 0);
|
||||
}
|
||||
|
||||
function roleProfiles(profiles: DiscoveredProfile[]): DiscoveredProfile[] {
|
||||
const ordered = [...profiles].sort((a, b) => streamArea(b) - streamArea(a));
|
||||
const roles = new Map<DiscoveredProfile, "main" | "sub" | "other">();
|
||||
for (let i = 0; i < ordered.length; i += 1) {
|
||||
roles.set(ordered[i]!, i === 0 ? "main" : i === 1 ? "sub" : "other");
|
||||
}
|
||||
return profiles.map((p) => ({ ...p, role: roles.get(p) ?? "other" }));
|
||||
}
|
||||
|
||||
function profileGroupKey(profileName: string, sourceToken: string | null, streamUri: string): string {
|
||||
if (sourceToken) return `source:${sourceToken}`;
|
||||
const match = streamUri.match(/(?:channel|channels|video|cam|camera)[/_-]?(\d+)/i)
|
||||
?? profileName.match(/(?:channel|channels|video|cam|camera|profile)[/_ -]?(\d+)/i);
|
||||
return match?.[1] ? `channel:${match[1]}` : "source:default";
|
||||
}
|
||||
|
||||
function groupProfiles(host: string, profiles: DiscoveredProfile[]): DiscoveredCamera[] {
|
||||
const groups = new Map<string, DiscoveredProfile[]>();
|
||||
for (const profile of profiles) {
|
||||
const key = profileGroupKey(profile.profile_name, profile.source_token, profile.stream_uri);
|
||||
groups.set(key, [...(groups.get(key) ?? []), profile]);
|
||||
}
|
||||
|
||||
const out: DiscoveredCamera[] = [];
|
||||
let i = 1;
|
||||
for (const [key, group] of groups) {
|
||||
const sourceToken = group.find((p) => p.source_token)?.source_token ?? null;
|
||||
const name = groups.size === 1
|
||||
? host
|
||||
: sourceToken ? `${host} ${sourceToken}` : `${host} camera ${String(i)}`;
|
||||
out.push({
|
||||
name,
|
||||
source_token: sourceToken ?? (key.startsWith("channel:") ? key.slice("channel:".length) : null),
|
||||
profiles: roleProfiles(group),
|
||||
});
|
||||
i += 1;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to an ONVIF camera and list its media profiles with their
|
||||
* resolutions, encodings, and RTSP stream URIs.
|
||||
|
|
@ -143,12 +275,10 @@ function splitProfiles(xml: string): string[] {
|
|||
* Throws on transport error. Profile fields default to null if the camera
|
||||
* omits them.
|
||||
*/
|
||||
export async function discover(input: DiscoverInput): Promise<DiscoveredProfile[]> {
|
||||
const host = input.host;
|
||||
const port = input.port || 80;
|
||||
const mediaPath = input.mediaPath ?? "/onvif/Media";
|
||||
const mediaUrl = `http://${host}:${String(port)}${mediaPath}`;
|
||||
export async function discover(input: DiscoverInput): Promise<DiscoveredCamera[]> {
|
||||
const timeoutMs = input.timeoutMs ?? 8000;
|
||||
const endpoint = normalizeEndpoint(input);
|
||||
const mediaUrl = await discoverMediaUrl(input, endpoint, timeoutMs);
|
||||
|
||||
const header = wsseHeader(input.username, input.password);
|
||||
|
||||
|
|
@ -169,6 +299,10 @@ export async function discover(input: DiscoverInput): Promise<DiscoveredProfile[
|
|||
const block = profileBlocks[i] ?? "";
|
||||
const token = tokenAttrs[i] ?? "";
|
||||
const profileName = pickNested(block, "Name") ?? token ?? `profile_${String(i)}`;
|
||||
const vsrc = pickNested(block, "VideoSourceConfiguration") ?? "";
|
||||
const sourceToken = vsrc
|
||||
? pickNested(vsrc, "SourceToken") ?? pickAttr(vsrc, "VideoSourceConfiguration", "token")[0] ?? null
|
||||
: null;
|
||||
|
||||
// VideoEncoderConfiguration → Encoding, Resolution{Width,Height}, RateControl.FrameRateLimit
|
||||
const venc = pickNested(block, "VideoEncoderConfiguration") ?? "";
|
||||
|
|
@ -205,13 +339,15 @@ export async function discover(input: DiscoverInput): Promise<DiscoveredProfile[
|
|||
out.push({
|
||||
profile_name: profileName,
|
||||
profile_token: token,
|
||||
source_token: sourceToken,
|
||||
encoding,
|
||||
width,
|
||||
height,
|
||||
framerate,
|
||||
stream_uri: uri,
|
||||
role: "other",
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
return groupProfiles(input.host, out);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,8 +239,8 @@ export function CameraDiscoverPage(props: CameraDiscoverProps) {
|
|||
>
|
||||
<div style="max-width:600px">
|
||||
<p style="color:#666; margin-bottom:1rem">
|
||||
Connect to an ONVIF camera or NVR by host and credentials. Each profile
|
||||
returned can be saved as a separate RTSP camera.
|
||||
Connect to an ONVIF camera or NVR by host and credentials. Profiles
|
||||
from the same video source are imported as streams on one camera.
|
||||
</p>
|
||||
<form method="post" action="/admin/cameras/discover" class="card">
|
||||
<div class="form-group">
|
||||
|
|
@ -272,25 +272,41 @@ export function CameraDiscoverPage(props: CameraDiscoverProps) {
|
|||
interface DiscoveredProfileRow {
|
||||
profile_name: string;
|
||||
profile_token: string;
|
||||
source_token: string | null;
|
||||
encoding: string | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
framerate: number | null;
|
||||
stream_uri: string;
|
||||
role: "main" | "sub" | "other";
|
||||
}
|
||||
|
||||
interface DiscoveredCameraRow {
|
||||
name: string;
|
||||
source_token: string | null;
|
||||
profiles: DiscoveredProfileRow[];
|
||||
}
|
||||
|
||||
interface CameraDiscoverResultsProps {
|
||||
user: string;
|
||||
host: string;
|
||||
profiles: DiscoveredProfileRow[];
|
||||
username: string;
|
||||
password: string;
|
||||
cameras: DiscoveredCameraRow[];
|
||||
error?: string;
|
||||
success?: string;
|
||||
}
|
||||
|
||||
export function CameraDiscoverResultsPage(props: CameraDiscoverResultsProps) {
|
||||
function CameraDiscoverResultsPageLegacy(props: {
|
||||
user: string;
|
||||
host: string;
|
||||
profiles: DiscoveredProfileRow[];
|
||||
error?: string;
|
||||
success?: string;
|
||||
}) {
|
||||
return (
|
||||
<Layout
|
||||
title="ONVIF Profiles"
|
||||
title="ONVIF Cameras"
|
||||
user={props.user}
|
||||
activeNav="cameras"
|
||||
flash={
|
||||
|
|
@ -300,8 +316,8 @@ export function CameraDiscoverResultsPage(props: CameraDiscoverResultsProps) {
|
|||
}
|
||||
>
|
||||
<p style="color:#666; margin-bottom:1rem">
|
||||
Profiles reported by <strong>{props.host}</strong>. Click <em>Add</em> on
|
||||
any row to import it as a camera.
|
||||
Video sources reported by <strong>{props.host}</strong>. Each source imports
|
||||
as one camera with its profiles saved as streams.
|
||||
</p>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
|
|
@ -352,6 +368,75 @@ export function CameraDiscoverResultsPage(props: CameraDiscoverResultsProps) {
|
|||
);
|
||||
}
|
||||
|
||||
export function CameraDiscoverResultsPage(props: CameraDiscoverResultsProps) {
|
||||
return (
|
||||
<Layout
|
||||
title="ONVIF Cameras"
|
||||
user={props.user}
|
||||
activeNav="cameras"
|
||||
flash={
|
||||
props.error ? { type: "error", message: props.error }
|
||||
: props.success ? { type: "success", message: props.success }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<p style="color:#666; margin-bottom:1rem">
|
||||
Video sources reported by <strong>{props.host}</strong>. Each source imports
|
||||
as one camera with its profiles saved as streams.
|
||||
</p>
|
||||
{props.cameras.length === 0 ? (
|
||||
<div class="card" style="text-align:center; color:#999; padding:2rem">No profiles returned</div>
|
||||
) : props.cameras.map((cam) => (
|
||||
<div class="card" style="margin-bottom:1rem">
|
||||
<div class="section-header" style="margin-bottom:0.75rem">
|
||||
<div>
|
||||
<h2 class="section-title" style="font-size:1rem">{cam.name}</h2>
|
||||
{cam.source_token ? <div style="color:#666; font-size:0.8rem">Source: {cam.source_token}</div> : ""}
|
||||
</div>
|
||||
<form method="post" action="/admin/cameras/discover/add" style="display:inline">
|
||||
<input type="hidden" name="name" value={cam.name} />
|
||||
<input type="hidden" name="username" value={props.username} />
|
||||
<input type="hidden" name="password" value={props.password} />
|
||||
<input type="hidden" name="streams_json" value={JSON.stringify(cam.profiles)} />
|
||||
<button type="submit" class="btn btn-sm btn-primary">Add Camera</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Role</th>
|
||||
<th>Profile</th>
|
||||
<th>Encoding</th>
|
||||
<th>Resolution</th>
|
||||
<th>FPS</th>
|
||||
<th>Stream URI</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cam.profiles.map((p) => (
|
||||
<tr>
|
||||
<td><span class="badge badge-gray">{p.role}</span></td>
|
||||
<td><strong>{p.profile_name}</strong></td>
|
||||
<td>{p.encoding ? <span class="badge badge-blue">{p.encoding}</span> : "-"}</td>
|
||||
<td>{p.width && p.height ? `${String(p.width)}x${String(p.height)}` : "-"}</td>
|
||||
<td>{p.framerate != null ? String(p.framerate) : "-"}</td>
|
||||
<td style="font-size:0.75rem; word-break:break-all; max-width:300px">{p.stream_uri}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)).join("")}
|
||||
<div style="margin-top:1rem">
|
||||
<a href="/admin/cameras/discover" class="btn btn-ghost">Discover Another</a>
|
||||
<a href="/admin/cameras" class="btn btn-ghost" style="margin-left:0.5rem">Back to Cameras</a>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Entities ---------------------------------------------------------------
|
||||
|
||||
interface EntitiesPageProps {
|
||||
|
|
|
|||
Loading…
Reference in a new issue