Simplify UI and add batch build endpoint

This commit is contained in:
klas
2026-02-10 09:58:11 +01:00
commit dee991a51a
83 changed files with 7043 additions and 0 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
**/__pycache__/
**/*.pyc
**/node_modules/
**/.venv/
**/dev.out.log
**/dev.err.log
**/server.out.log
**/server.err.log
frontend/dist/

13
.gitignore vendored Normal file
View File

@@ -0,0 +1,13 @@
**/.venv/
**/__pycache__/
**/*.pyc
frontend/node_modules/
frontend/dist/
backend/.pytest_cache/
backend/.mypy_cache/
backend/.ruff_cache/
*.log
.env
.env.*
_out.json
_log_import.json

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
FROM node:20-alpine AS frontend-build
WORKDIR /app
ARG VITE_API_BASE=
ENV VITE_API_BASE=$VITE_API_BASE
COPY frontend/package.json frontend/package-lock.json ./
RUN npm ci
COPY frontend/ ./
RUN npm run build
FROM python:3.11-slim AS runtime
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV STATIC_DIR=/app/static
COPY backend/requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY backend/ ./backend/
COPY --from=frontend-build /app/dist/ ./static/
EXPOSE 8002
CMD ["python", "-m", "uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8002"]

68
README.md Normal file
View File

@@ -0,0 +1,68 @@
# GeViSetWEB
Lightweight single-user web tool for editing GeViSet `.set` files with fast tables.
## Features
- Parse `.set` files into editable tables (servers + action mappings)
- Edit, add, and delete G-Core and GeViScope servers
- Edit and extend action mappings
- Import new entries from Excel templates
- Export back to `.set`
## Layout
- `backend/` FastAPI service
- `frontend/` React + Vite UI
- `samples/` Excel templates copied from the original workspace
## Backend setup
```bash
cd C:/DEV/COPILOT_codex/GeViSetWEB/backend
python -m venv .venv
.\.venv\Scripts\activate
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8002
```
## Frontend setup
```bash
cd C:/DEV/COPILOT_codex/GeViSetWEB/frontend
npm install
npm run dev
```
The UI expects the API at `http://localhost:8002` by default. To override, set `VITE_API_BASE` in a `.env` file.
## Docker
Build the image:
```bash
docker build -t gevisetweb .
```
Run:
```bash
docker run --rm -p 8002:8002 gevisetweb
```
Open:
```
http://<host>:8002
```
## Docker Compose
```bash
docker compose up --build
```
## Notes
- The action mapping editor edits output actions (name/action/server) for speed.
- New action mappings imported from Excel use a template from the first mapping rule.

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@


467
backend/app/excel.py Normal file
View File

@@ -0,0 +1,467 @@
from io import BytesIO
from typing import Any, Dict, List, Tuple
import openpyxl
SERVER_HEADER_ALIASES = {
"hostname": "alias",
"alias": "alias",
"typ": "type",
"type": "type",
"ip server": "host",
"ip": "host",
"server": "host",
"username": "user",
"user": "user",
"password": "password",
}
KEYBOARD_HEADER_ALIASES = {
"description": "description",
"name": "name",
"ip address": "host",
"ip": "host",
"host": "host",
"ip host": "host",
"port": "port",
"ip port": "port",
}
def _clean(value):
if value is None:
return ""
return str(value).strip()
def _normalize_type(value: str) -> str:
lowered = value.lower()
if "geviscope" in lowered or "gsc" in lowered:
return "geviscope"
if "g-core" in lowered or "gcore" in lowered:
return "gcore"
return "gcore"
def _load_sheets(contents: bytes):
values_wb = openpyxl.load_workbook(BytesIO(contents), data_only=True)
formulas_wb = openpyxl.load_workbook(BytesIO(contents), data_only=False)
return values_wb.active, formulas_wb.active
def _cell_value(values_sheet, formulas_sheet, row: int, col: int) -> Tuple[Any, bool]:
if not col or col < 1:
return None, False
value = values_sheet.cell(row=row, column=col).value
if value is None:
fcell = formulas_sheet.cell(row=row, column=col)
if fcell.data_type == "f":
return None, True
return value, False
def parse_servers_xlsx(contents: bytes) -> Dict[str, Any]:
sheet, formula_sheet = _load_sheets(contents)
formula_missing = 0
header_row = None
for row in range(1, min(15, sheet.max_row + 1)):
for col in range(1, min(10, sheet.max_column + 1)):
value, is_formula = _cell_value(sheet, formula_sheet, row, col)
formula_missing += 1 if is_formula else 0
cell = _clean(value).lower()
if cell == "hostname":
header_row = row
break
if header_row:
break
if not header_row:
raise ValueError("Could not find header row with 'Hostname'")
header_map = {}
for col in range(1, sheet.max_column + 1):
value, is_formula = _cell_value(sheet, formula_sheet, header_row, col)
formula_missing += 1 if is_formula else 0
name = _clean(value).lower()
if name in SERVER_HEADER_ALIASES:
header_map[SERVER_HEADER_ALIASES[name]] = col
servers = []
skipped = 0
for row in range(header_row + 1, sheet.max_row + 1):
alias_value, alias_formula = _cell_value(sheet, formula_sheet, row, header_map.get("alias", 0))
host_value, host_formula = _cell_value(sheet, formula_sheet, row, header_map.get("host", 0))
formula_missing += int(alias_formula) + int(host_formula)
alias = _clean(alias_value)
host = _clean(host_value)
if not alias or not host:
skipped += 1
continue
type_value, type_formula = _cell_value(sheet, formula_sheet, row, header_map.get("type", 0))
user_value, user_formula = _cell_value(sheet, formula_sheet, row, header_map.get("user", 0))
pass_value, pass_formula = _cell_value(sheet, formula_sheet, row, header_map.get("password", 0))
formula_missing += int(type_formula) + int(user_formula) + int(pass_formula)
server_type = _normalize_type(_clean(type_value))
user = _clean(user_value) or "sysadmin"
password = _clean(pass_value)
servers.append({
"alias": alias,
"host": host,
"user": user,
"password": password,
"type": server_type,
"enabled": True,
"deactivateEcho": False,
"deactivateLiveCheck": False,
})
return {"servers": servers, "skipped": skipped, "formula_cells_missing": formula_missing}
def parse_actions_xlsx(contents: bytes) -> Dict[str, Any]:
sheet, formula_sheet = _load_sheets(contents)
formula_missing = 0
header_row = None
for row in range(1, min(15, sheet.max_row + 1)):
for col in range(1, min(20, sheet.max_column + 1)):
value, is_formula = _cell_value(sheet, formula_sheet, row, col)
formula_missing += 1 if is_formula else 0
if _clean(value).lower() == "camera id":
header_row = row
break
if header_row:
break
if not header_row:
raise ValueError("Could not find header row with 'Camera ID'")
headers = {}
for col in range(1, sheet.max_column + 1):
value, is_formula = _cell_value(sheet, formula_sheet, header_row, col)
formula_missing += 1 if is_formula else 0
name = _clean(value)
if name:
headers.setdefault(name, []).append(col)
def col(name, idx=0):
values = headers.get(name, [])
if idx < len(values):
return values[idx]
return None
compact_mode = all(
name in headers
for name in [
"Input Action",
"Input Category",
"Input Caption",
"Output Action",
"Output Caption",
"Output Server Alias",
]
)
rows: List[Dict[str, Any]] = []
for row in range(header_row + 1, sheet.max_row + 1):
camera_value, camera_formula = _cell_value(sheet, formula_sheet, row, col("Camera ID"))
formula_missing += 1 if camera_formula else 0
camera_id = _clean(camera_value)
if not camera_id:
continue
if compact_mode:
input_caption = _clean(_cell_value(sheet, formula_sheet, row, col("Input Caption"))[0])
output_server = _clean(_cell_value(sheet, formula_sheet, row, col("Output Server Alias"))[0])
entry = {
"cameraId": camera_id,
"server": _clean(_cell_value(sheet, formula_sheet, row, col("Server"))[0]) or output_server,
"serverType": _clean(_cell_value(sheet, formula_sheet, row, col("Server Type"))[0]),
"ptz": _clean(_cell_value(sheet, formula_sheet, row, col("PTZ"))[0]),
"caption": input_caption,
"outputs": [
{
"kind": "output1",
"category": _clean(_cell_value(sheet, formula_sheet, row, col("Input Category"))[0]),
"action": _clean(_cell_value(sheet, formula_sheet, row, col("Input Action"))[0]),
"videoInput": camera_id,
},
],
}
server_type = _clean(_cell_value(sheet, formula_sheet, row, col("Server Type"))[0]).lower()
output_action = _clean(_cell_value(sheet, formula_sheet, row, col("Output Action"))[0])
output_caption = _clean(_cell_value(sheet, formula_sheet, row, col("Output Caption"))[0])
output_entry = {
"category": "",
"action": output_action,
"caption": output_caption,
"server": output_server,
"ptzHead": camera_id,
"speed": "",
}
if "g-core" in server_type or "gcore" in server_type or "gng" in server_type:
output_entry["kind"] = "gcore"
else:
output_entry["kind"] = "gsc"
entry["outputs"].append(output_entry)
else:
input_value, input_formula = _cell_value(sheet, formula_sheet, row, col("Caption", 0))
formula_missing += 1 if input_formula else 0
input_caption = _clean(input_value)
entry = {
"cameraId": camera_id,
"server": _clean(_cell_value(sheet, formula_sheet, row, col("Server"))[0]),
"serverType": _clean(_cell_value(sheet, formula_sheet, row, col("Server Type"))[0]),
"ptz": _clean(_cell_value(sheet, formula_sheet, row, col("PTZ"))[0]),
"caption": input_caption,
"outputs": [
{
"kind": "output1",
"category": _clean(_cell_value(sheet, formula_sheet, row, col("Category", 0))[0]),
"action": _clean(_cell_value(sheet, formula_sheet, row, col("Action", 0))[0]),
"videoInput": _clean(_cell_value(sheet, formula_sheet, row, col("VideoInput", 0))[0]),
},
{
"kind": "gsc",
"category": _clean(_cell_value(sheet, formula_sheet, row, col("Category", 1))[0]),
"action": _clean(_cell_value(sheet, formula_sheet, row, col("Action", 1))[0]),
"caption": _clean(_cell_value(sheet, formula_sheet, row, col("Caption", 1))[0]),
"server": _clean(_cell_value(sheet, formula_sheet, row, col("GeviScope alias", 0))[0]),
"ptzHead": _clean(_cell_value(sheet, formula_sheet, row, col("PTZ head", 0))[0]),
"speed": _clean(_cell_value(sheet, formula_sheet, row, col("speed", 0))[0]),
},
{
"kind": "gcore",
"category": _clean(_cell_value(sheet, formula_sheet, row, col("Category", 2))[0]),
"action": _clean(_cell_value(sheet, formula_sheet, row, col("Action", 2))[0]),
"caption": _clean(_cell_value(sheet, formula_sheet, row, col("Caption", 2))[0]),
"server": _clean(_cell_value(sheet, formula_sheet, row, col("G-Core alias", 0))[0]),
"ptzHead": _clean(_cell_value(sheet, formula_sheet, row, col("PTZ head", 1))[0]),
"speed": _clean(_cell_value(sheet, formula_sheet, row, col("speed", 1))[0]),
},
],
}
rows.append(entry)
return {"rows": rows, "formula_cells_missing": formula_missing}
def parse_keyboards_xlsx(contents: bytes) -> Dict[str, Any]:
sheet, formula_sheet = _load_sheets(contents)
formula_missing = 0
header_row = None
for row in range(1, min(15, sheet.max_row + 1)):
for col in range(1, min(10, sheet.max_column + 1)):
value, is_formula = _cell_value(sheet, formula_sheet, row, col)
formula_missing += 1 if is_formula else 0
cell = _clean(value).lower()
if cell in ("description", "name"):
header_row = row
break
if header_row:
break
if not header_row:
raise ValueError("Could not find header row with 'Name' or 'Description'")
header_map = {}
for col in range(1, sheet.max_column + 1):
value, is_formula = _cell_value(sheet, formula_sheet, header_row, col)
formula_missing += 1 if is_formula else 0
name = _clean(value).lower()
if name in KEYBOARD_HEADER_ALIASES:
header_map[KEYBOARD_HEADER_ALIASES[name]] = col
keyboards = []
skipped = 0
for row in range(header_row + 1, sheet.max_row + 1):
name_value, name_formula = _cell_value(sheet, formula_sheet, row, header_map.get("name", 0))
desc_value, desc_formula = _cell_value(sheet, formula_sheet, row, header_map.get("description", 0))
host_value, host_formula = _cell_value(sheet, formula_sheet, row, header_map.get("host", 0))
port_value, port_formula = _cell_value(sheet, formula_sheet, row, header_map.get("port", 0))
formula_missing += int(name_formula) + int(desc_formula) + int(host_formula) + int(port_formula)
name = _clean(name_value)
description = _clean(desc_value)
host = _clean(host_value)
port_raw = _clean(port_value)
if not name and not description and not host and not port_raw:
skipped += 1
continue
if not host or not port_raw:
skipped += 1
continue
try:
port = int(float(port_raw))
except ValueError:
skipped += 1
continue
keyboards.append({"name": name, "description": description, "host": host, "port": port})
return {"keyboards": keyboards, "skipped": skipped, "formula_cells_missing": formula_missing}
def parse_video_outputs_xlsx(contents: bytes) -> Dict[str, Any]:
sheet, formula_sheet = _load_sheets(contents)
formula_missing = 0
header_row = None
for row in range(1, min(15, sheet.max_row + 1)):
for col in range(1, min(10, sheet.max_column + 1)):
value, is_formula = _cell_value(sheet, formula_sheet, row, col)
formula_missing += 1 if is_formula else 0
cell = _clean(value).lower().replace(" ", "")
if cell in ("localid", "local_id", "local"):
header_row = row
break
if header_row:
break
if not header_row:
raise ValueError("Could not find header row with 'Local ID'")
header_map = {}
for col in range(1, sheet.max_column + 1):
value, is_formula = _cell_value(sheet, formula_sheet, header_row, col)
formula_missing += 1 if is_formula else 0
name = _clean(value).lower().replace(" ", "")
if name in ("localid", "local_id", "local"):
header_map["localId"] = col
elif name in ("globalid", "global_id", "global"):
header_map["globalId"] = col
elif name == "name":
header_map["name"] = col
elif name in ("description", "desc"):
header_map["description"] = col
outputs = []
skipped = 0
for row in range(header_row + 1, sheet.max_row + 1):
local_value, local_formula = _cell_value(sheet, formula_sheet, row, header_map.get("localId", 0))
global_value, global_formula = _cell_value(sheet, formula_sheet, row, header_map.get("globalId", 0))
name_value, name_formula = _cell_value(sheet, formula_sheet, row, header_map.get("name", 0))
desc_value, desc_formula = _cell_value(sheet, formula_sheet, row, header_map.get("description", 0))
formula_missing += int(local_formula) + int(global_formula) + int(name_formula) + int(desc_formula)
local_raw = _clean(local_value)
global_raw = _clean(global_value)
if not local_raw and not global_raw and not name_value and not desc_value:
skipped += 1
continue
try:
local_id = int(float(local_raw)) if local_raw else None
except ValueError:
local_id = None
try:
global_id = int(float(global_raw)) if global_raw else None
except ValueError:
global_id = None
outputs.append({
"localId": local_id,
"globalId": global_id,
"name": _clean(name_value),
"description": _clean(desc_value),
})
return {"outputs": outputs, "skipped": skipped, "formula_cells_missing": formula_missing}
def build_video_outputs_xlsx(outputs: List[Dict[str, Any]]) -> bytes:
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "VideoOutputs"
ws.append(["Local ID", "Global ID", "Name", "Description"])
for row in outputs:
ws.append([
row.get("localId"),
row.get("globalId"),
row.get("name", ""),
row.get("description", ""),
])
out = BytesIO()
wb.save(out)
return out.getvalue()
def build_logging_xlsx(rows: List[Dict[str, Any]]) -> bytes:
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Logging"
ws.append(["Action name", "Log into database", "Notify user", "Notify GeViCom"])
for row in rows:
ws.append([
row.get("actionName", ""),
"X" if row.get("db") else "",
"X" if row.get("user") else "",
"X" if row.get("com") else "",
])
out = BytesIO()
wb.save(out)
return out.getvalue()
def parse_logging_xlsx(contents: bytes) -> Dict[str, Any]:
sheet, formula_sheet = _load_sheets(contents)
formula_missing = 0
header_row = None
for row in range(1, min(15, sheet.max_row + 1)):
for col in range(1, min(10, sheet.max_column + 1)):
value, is_formula = _cell_value(sheet, formula_sheet, row, col)
formula_missing += 1 if is_formula else 0
cell = _clean(value).lower().replace(" ", "")
if cell in ("actionname", "action", "actionid", "action_id"):
header_row = row
break
if header_row:
break
if not header_row:
raise ValueError("Could not find header row with 'Action name'")
header_map = {}
for col in range(1, sheet.max_column + 1):
value, is_formula = _cell_value(sheet, formula_sheet, header_row, col)
formula_missing += 1 if is_formula else 0
name = _clean(value).lower().replace(" ", "")
if name in ("actionname", "action", "actionid", "action_id"):
header_map["actionName"] = col
elif name in ("logintodatabase", "logtodatabase", "database", "db"):
header_map["db"] = col
elif name in ("notifyuser", "user"):
header_map["user"] = col
elif name in ("notifygevicom", "gevicom", "com"):
header_map["com"] = col
def _to_bool(value: Any) -> bool:
if value is None:
return False
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return value != 0
text = _clean(value).lower()
return text in ("x", "yes", "true", "1", "y")
rows = []
skipped = 0
for row in range(header_row + 1, sheet.max_row + 1):
action_value, action_formula = _cell_value(sheet, formula_sheet, row, header_map.get("actionName", 0))
db_value, db_formula = _cell_value(sheet, formula_sheet, row, header_map.get("db", 0))
user_value, user_formula = _cell_value(sheet, formula_sheet, row, header_map.get("user", 0))
com_value, com_formula = _cell_value(sheet, formula_sheet, row, header_map.get("com", 0))
formula_missing += int(action_formula) + int(db_formula) + int(user_formula) + int(com_formula)
action_name = _clean(action_value)
if not action_name and not db_value and not user_value and not com_value:
skipped += 1
continue
if not action_name:
skipped += 1
continue
rows.append({
"actionName": action_name,
"db": _to_bool(db_value),
"user": _to_bool(user_value),
"com": _to_bool(com_value),
})
return {"rows": rows, "skipped": skipped, "formula_cells_missing": formula_missing}

1398
backend/app/geviset.py Normal file

File diff suppressed because it is too large Load Diff

353
backend/app/main.py Normal file
View File

@@ -0,0 +1,353 @@
import os
import re
import copy
import tempfile
import subprocess
from fastapi import FastAPI, UploadFile, File, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
import json
from fastapi.staticfiles import StaticFiles
from . import geviset
from . import excel
from pathlib import Path
app = FastAPI(title="GeViSet Web")
_LAST_TREE = None
_ROOT_DIR = Path(__file__).resolve().parents[2]
_SCRIPT_PATH = _ROOT_DIR / "scripts" / "build_mapping.js"
_TEMPLATE_RULES = _ROOT_DIR / "frontend" / "src" / "templates" / "templateRules.json"
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/api/health")
async def health():
return {"ok": True}
@app.post("/api/set/parse")
async def parse_set(file: UploadFile = File(...)):
if not file.filename.lower().endswith(".set"):
raise HTTPException(status_code=400, detail="Only .set files are supported")
contents = await file.read()
try:
tree = geviset.load_set_bytes(contents)
mapping = geviset.extract_mapping(tree)
gcore = geviset.extract_gcore(tree)
gsc = geviset.extract_gsc(tree)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
global _LAST_TREE
_LAST_TREE = tree
return {
"filename": file.filename,
"token": "last",
"tree": tree,
"mapping": mapping,
"gcore": gcore,
"gsc": gsc,
}
@app.post("/api/set/export")
async def export_set(payload: dict):
try:
tree = payload.get("tree")
token = payload.get("token")
if tree is None:
if token == "last" and _LAST_TREE is not None:
tree = copy.deepcopy(_LAST_TREE)
else:
raise ValueError("Missing tree in payload")
if "mapping" in payload:
geviset.apply_mapping(tree, payload["mapping"])
mapping_for_ids = geviset.extract_mapping(tree)
camera_ids = set()
ptz_by_id = {}
for rule in payload["mapping"].get("rules", []):
inp = rule.get("input", {})
vid = inp.get("videoInputId")
if isinstance(vid, int) and vid > 0 and isinstance(inp.get("ptz"), bool):
ptz_by_id[vid] = inp["ptz"]
else:
name = inp.get("name", "")
match = re.search(r"(\\d+)$", str(name))
if match and isinstance(inp.get("ptz"), bool):
ptz_by_id[int(match.group(1))] = inp["ptz"]
for rule in mapping_for_ids.get("rules", []):
inp = rule.get("input", {})
vid = inp.get("videoInputId")
if isinstance(vid, int) and vid > 0:
camera_ids.add(vid)
else:
name = inp.get("name", "")
match = re.search(r"(\\d+)$", str(name))
if match:
camera_ids.add(int(match.group(1)))
for field in rule.get("fields", []):
if field.get("name") == "VideoInput":
val = field.get("value")
if isinstance(val, int) and val > 0:
camera_ids.add(val)
elif isinstance(val, str) and val.isdigit():
camera_ids.add(int(val))
for out in rule.get("outputs", []):
action = out.get("action", "")
match = re.search(r"Camera:\\s*(\\d+)", action)
if match:
camera_ids.add(int(match.group(1)))
geviset.ensure_video_inputs(tree, camera_ids, ptz_by_id)
geviset.ensure_global_video_inputs(tree, camera_ids, ptz_by_id)
geviset.ensure_vx3_video_inputs(tree, camera_ids, ptz_by_id)
geviset.prune_video_inputs(tree, camera_ids)
print(
f"EXPORT camera_ids={len(camera_ids)} contains_101027={101027 in camera_ids}",
flush=True,
)
if "gcore" in payload:
geviset.apply_gcore(tree, payload["gcore"])
if "gsc" in payload:
geviset.apply_gsc(tree, payload["gsc"])
if "keyboards" in payload:
geviset.apply_keyboards(tree, payload["keyboards"])
if "videoOutputs" in payload:
geviset.apply_video_outputs(tree, payload["videoOutputs"])
if "logging" in payload:
geviset.apply_logging(tree, payload["logging"])
data = geviset.save_set_bytes(tree)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
return StreamingResponse(
iter([data]),
media_type="application/octet-stream",
headers={"Content-Disposition": "attachment; filename=updated.set"},
)
@app.post("/api/excel/servers")
async def import_servers(file: UploadFile = File(...)):
if not file.filename.lower().endswith(".xlsx"):
raise HTTPException(status_code=400, detail="Only .xlsx files are supported")
contents = await file.read()
try:
parsed = excel.parse_servers_xlsx(contents)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
return parsed
@app.post("/api/excel/action-mappings")
async def import_action_mappings(file: UploadFile = File(...)):
if not file.filename.lower().endswith(".xlsx"):
raise HTTPException(status_code=400, detail="Only .xlsx files are supported")
contents = await file.read()
try:
parsed = excel.parse_actions_xlsx(contents)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
return parsed
@app.post("/api/excel/keyboards")
async def import_keyboards(file: UploadFile = File(...)):
if not file.filename.lower().endswith(".xlsx"):
raise HTTPException(status_code=400, detail="Only .xlsx files are supported")
contents = await file.read()
try:
parsed = excel.parse_keyboards_xlsx(contents)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
return parsed
@app.post("/api/excel/video-outputs")
async def export_video_outputs(payload: dict):
try:
tree = payload.get("tree")
if tree is None:
raise ValueError("Missing tree in payload")
outputs = geviset.extract_video_outputs(tree)
data = excel.build_video_outputs_xlsx(outputs)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
return StreamingResponse(
iter([data]),
media_type="application/octet-stream",
headers={"Content-Disposition": "attachment; filename=video_outputs.xlsx"},
)
@app.post("/api/excel/video-outputs/import")
async def import_video_outputs(file: UploadFile = File(...)):
if not file.filename.lower().endswith(".xlsx"):
raise HTTPException(status_code=400, detail="Only .xlsx files are supported")
contents = await file.read()
try:
parsed = excel.parse_video_outputs_xlsx(contents)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
return parsed
@app.post("/api/excel/logging")
async def export_logging(payload: dict):
try:
tree = payload.get("tree")
if tree is None:
raise ValueError("Missing tree in payload")
rows = geviset.extract_logging(tree)
data = excel.build_logging_xlsx(rows)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
return StreamingResponse(
iter([data]),
media_type="application/octet-stream",
headers={"Content-Disposition": "attachment; filename=logging.xlsx"},
)
@app.post("/api/excel/logging/import")
async def import_logging(file: UploadFile = File(...)):
if not file.filename.lower().endswith(".xlsx"):
raise HTTPException(status_code=400, detail="Only .xlsx files are supported")
contents = await file.read()
try:
parsed = excel.parse_logging_xlsx(contents)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
return parsed
@app.post("/api/batch/build")
async def build_from_excel(
base_set: UploadFile = File(...),
servers: UploadFile = File(None),
actions: UploadFile = File(None),
outputs: UploadFile = File(None),
keyboards: UploadFile = File(None),
logging: UploadFile = File(None),
):
if not base_set.filename.lower().endswith(".set"):
raise HTTPException(status_code=400, detail="Base file must be .set")
try:
tree = geviset.load_set_bytes(await base_set.read())
if actions and actions.filename:
if not actions.filename.lower().endswith(".xlsx"):
raise HTTPException(status_code=400, detail="Actions must be .xlsx")
actions_parsed = excel.parse_actions_xlsx(await actions.read())
mapping = geviset.extract_mapping(tree)
with tempfile.TemporaryDirectory() as tmpdir:
map_in = Path(tmpdir) / "mapping_in.json"
rows_in = Path(tmpdir) / "actions_rows.json"
map_out = Path(tmpdir) / "mapping_out.json"
map_in.write_text(json.dumps(mapping), encoding="utf-8")
rows_in.write_text(json.dumps(actions_parsed["rows"]), encoding="utf-8")
subprocess.check_call(
["node", str(_SCRIPT_PATH), str(map_in), str(rows_in), str(_TEMPLATE_RULES), str(map_out)]
)
mapping_updated = json.loads(map_out.read_text(encoding="utf-8"))
geviset.apply_mapping(tree, mapping_updated)
mapping_for_ids = geviset.extract_mapping(tree)
camera_ids = set()
ptz_by_id = {}
for rule in mapping_updated.get("rules", []):
inp = rule.get("input", {})
vid = inp.get("videoInputId")
if isinstance(vid, int) and vid > 0 and isinstance(inp.get("ptz"), bool):
ptz_by_id[vid] = inp["ptz"]
else:
name = inp.get("name", "")
match = re.search(r"(\\d+)$", str(name))
if match and isinstance(inp.get("ptz"), bool):
ptz_by_id[int(match.group(1))] = inp["ptz"]
for rule in mapping_for_ids.get("rules", []):
inp = rule.get("input", {})
vid = inp.get("videoInputId")
if isinstance(vid, int) and vid > 0:
camera_ids.add(vid)
else:
name = inp.get("name", "")
match = re.search(r"(\\d+)$", str(name))
if match:
camera_ids.add(int(match.group(1)))
for field in rule.get("fields", []):
if field.get("name") == "VideoInput":
val = field.get("value")
if isinstance(val, int) and val > 0:
camera_ids.add(val)
elif isinstance(val, str) and val.isdigit():
camera_ids.add(int(val))
for out in rule.get("outputs", []):
action = out.get("action", "")
match = re.search(r"Camera:\\s*(\\d+)", action)
if match:
camera_ids.add(int(match.group(1)))
geviset.ensure_video_inputs(tree, camera_ids, ptz_by_id)
geviset.ensure_global_video_inputs(tree, camera_ids, ptz_by_id)
geviset.ensure_vx3_video_inputs(tree, camera_ids, ptz_by_id)
geviset.prune_video_inputs(tree, camera_ids)
if servers and servers.filename:
if not servers.filename.lower().endswith(".xlsx"):
raise HTTPException(status_code=400, detail="Servers must be .xlsx")
servers_parsed = excel.parse_servers_xlsx(await servers.read())
gcore_list = [s for s in servers_parsed["servers"] if s.get("type") == "gcore"]
gsc_list = [s for s in servers_parsed["servers"] if s.get("type") == "geviscope"]
for idx, s in enumerate(gcore_list, start=1):
s["id"] = str(idx)
for idx, s in enumerate(gsc_list, start=1):
s["id"] = str(idx)
bundle_gcore = geviset.extract_gcore(tree)
bundle_gsc = geviset.extract_gsc(tree)
bundle_gcore["servers"] = gcore_list
bundle_gsc["servers"] = gsc_list
geviset.apply_gcore(tree, bundle_gcore)
geviset.apply_gsc(tree, bundle_gsc)
if outputs and outputs.filename:
if not outputs.filename.lower().endswith(".xlsx"):
raise HTTPException(status_code=400, detail="Outputs must be .xlsx")
outputs_parsed = excel.parse_video_outputs_xlsx(await outputs.read())
geviset.apply_video_outputs(tree, outputs_parsed["outputs"])
if keyboards and keyboards.filename:
if not keyboards.filename.lower().endswith(".xlsx"):
raise HTTPException(status_code=400, detail="Keyboards must be .xlsx")
keyboards_parsed = excel.parse_keyboards_xlsx(await keyboards.read())
geviset.apply_keyboards(tree, keyboards_parsed["keyboards"])
if logging and logging.filename:
if not logging.filename.lower().endswith(".xlsx"):
raise HTTPException(status_code=400, detail="Logging must be .xlsx")
logging_parsed = excel.parse_logging_xlsx(await logging.read())
geviset.apply_logging(tree, logging_parsed["rows"])
data = geviset.save_set_bytes(tree)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc))
return StreamingResponse(
iter([data]),
media_type="application/octet-stream",
headers={"Content-Disposition": "attachment; filename=combined.set"},
)
static_dir = os.getenv("STATIC_DIR")
if static_dir and os.path.isdir(static_dir):
app.mount("/", StaticFiles(directory=static_dir, html=True), name="static")

4
backend/requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
fastapi==0.115.0
uvicorn==0.30.6
python-multipart==0.0.9
openpyxl==3.1.5

9
docker-compose.yml Normal file
View File

@@ -0,0 +1,9 @@
version: "3.8"
services:
gevisetweb:
build: .
ports:
- "8002:8002"
environment:
- STATIC_DIR=/app/static

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GeViSet Web</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

1731
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
frontend/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "geviset-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.2",
"typescript": "^5.6.2",
"vite": "^5.4.8"
}
}

158
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,158 @@
import { useState } from "react";
const API_BASE = (() => {
const override = import.meta.env.VITE_API_BASE;
if (override) return override;
if (window.location.port === "5173") {
return `${window.location.protocol}//${window.location.hostname}:8002`;
}
return window.location.origin;
})();
const BUILD_STAMP = new Date().toISOString();
type Slot = {
id: string;
label: string;
accept: string;
file: File | null;
setFile: (file: File | null) => void;
required?: boolean;
};
function formatSize(file: File) {
const kb = file.size / 1024;
if (kb < 1024) return `${kb.toFixed(1)} KB`;
return `${(kb / 1024).toFixed(1)} MB`;
}
export default function App() {
const [status, setStatus] = useState<string | null>(null);
const [busyLabel, setBusyLabel] = useState<string | null>(null);
const [baseFile, setBaseFile] = useState<File | null>(null);
const [serversFile, setServersFile] = useState<File | null>(null);
const [actionsFile, setActionsFile] = useState<File | null>(null);
const [outputsFile, setOutputsFile] = useState<File | null>(null);
const [keyboardsFile, setKeyboardsFile] = useState<File | null>(null);
const [loggingFile, setLoggingFile] = useState<File | null>(null);
const slots: Slot[] = [
{ id: "base", label: "Base .set", accept: ".set", file: baseFile, setFile: setBaseFile, required: true },
{ id: "servers", label: "Servers", accept: ".xlsx", file: serversFile, setFile: setServersFile },
{ id: "actions", label: "Actions", accept: ".xlsx", file: actionsFile, setFile: setActionsFile },
{ id: "outputs", label: "Outputs", accept: ".xlsx", file: outputsFile, setFile: setOutputsFile },
{ id: "keyboards", label: "Keyboards", accept: ".xlsx", file: keyboardsFile, setFile: setKeyboardsFile },
{ id: "logging", label: "Logging", accept: ".xlsx", file: loggingFile, setFile: setLoggingFile },
];
const handleBuild = async () => {
if (!baseFile) {
setStatus("Select a base .set file.");
return;
}
const body = new FormData();
body.append("base_set", baseFile);
if (serversFile) body.append("servers", serversFile);
if (actionsFile) body.append("actions", actionsFile);
if (outputsFile) body.append("outputs", outputsFile);
if (keyboardsFile) body.append("keyboards", keyboardsFile);
if (loggingFile) body.append("logging", loggingFile);
setStatus("Building combined .set...");
setBusyLabel("Building .set");
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 300000);
try {
const res = await fetch(`${API_BASE}/api/batch/build`, {
method: "POST",
body,
signal: controller.signal,
});
if (!res.ok) {
setStatus(await res.text());
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "combined.set";
link.click();
URL.revokeObjectURL(url);
setStatus("Combined .set downloaded.");
} catch (error: any) {
if (error?.name === "AbortError") {
setStatus("Build timed out.");
} else {
setStatus("Build failed.");
}
} finally {
window.clearTimeout(timeoutId);
setBusyLabel(null);
}
};
return (
<div className="shell simple">
<header className="topbar">
<div className="topbar__brand">
<div className="logo">GV</div>
<div>
<div className="topbar__title">GeViSet Builder</div>
<div className="topbar__subtitle">Batch .set generator <EFBFBD> {BUILD_STAMP}</div>
</div>
</div>
<div className="topbar__actions">
<div className="statusbar">
{busyLabel && <span className="spinner" />}
{busyLabel ?? status ?? "Ready."}
</div>
</div>
</header>
<main className="simple__body">
<section className="card">
<div className="card__header">
<div>
<h2>Build Combined .set</h2>
<p>Select the base .set and any Excel files you want to apply. Only selected files are used.</p>
</div>
<button className="chip" onClick={handleBuild}>
Build .set
</button>
</div>
<div className="card__content">
{slots.map((slot) => (
<div key={slot.id} className="file-block">
<label className="file-row">
{slot.label}{slot.required ? " *" : ""}
<input
type="file"
accept={slot.accept}
onChange={(e) => {
const file = e.target.files?.[0] || null;
slot.setFile(file);
e.target.value = "";
}}
/>
</label>
{slot.file ? (
<div className="file-meta">
<span className="file-name">{slot.file.name}</span>
<span className="file-size">{formatSize(slot.file)}</span>
<button className="chip chip--ghost" onClick={() => slot.setFile(null)}>
Clear
</button>
</div>
) : (
<div className="file-meta file-meta--empty">Not selected</div>
)}
</div>
))}
</div>
</section>
</main>
</div>
);
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

531
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,531 @@
@import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap");
:root {
--bg: #0a0d12;
--panel: #0e141d;
--panel-2: #0b1118;
--grid: #1c2634;
--edge: #2a374a;
--text: #e6edf6;
--muted: #90a0b3;
--accent: #4de3b8;
--accent-2: #3aa0ff;
--danger: #ff5d5d;
--chip: #121a26;
--chip-border: #2b394f;
font-family: "IBM Plex Sans", sans-serif;
color: var(--text);
background-color: var(--bg);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
linear-gradient(180deg, rgba(18, 25, 35, 0.7) 0%, rgba(8, 11, 16, 0.9) 100%),
repeating-linear-gradient(
90deg,
rgba(255, 255, 255, 0.02) 0px,
rgba(255, 255, 255, 0.02) 1px,
transparent 1px,
transparent 28px
);
}
input,
button {
font-family: inherit;
color: inherit;
}
.shell {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.topbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 18px;
border-bottom: 1px solid var(--edge);
background: linear-gradient(180deg, #101723 0%, #0a0d12 100%);
box-shadow: inset 0 -1px 0 rgba(255, 255, 255, 0.03);
}
.topbar__brand {
display: flex;
gap: 12px;
align-items: center;
}
.logo {
width: 36px;
height: 36px;
border-radius: 6px;
display: grid;
place-items: center;
font-weight: 600;
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-2) 100%);
color: #041017;
letter-spacing: 0.04em;
}
.topbar__title {
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.12em;
}
.topbar__subtitle {
font-size: 11px;
color: var(--muted);
}
.topbar__actions {
display: flex;
gap: 10px;
align-items: center;
}
.statusbar {
font-size: 11px;
color: var(--muted);
padding: 4px 10px;
border-radius: 4px;
border: 1px solid var(--edge);
background: rgba(10, 15, 22, 0.8);
text-transform: uppercase;
letter-spacing: 0.08em;
display: inline-flex;
align-items: center;
gap: 8px;
}
.spinner {
width: 10px;
height: 10px;
border-radius: 50%;
border: 2px solid rgba(77, 227, 184, 0.25);
border-top-color: var(--accent);
animation: spin 0.8s linear infinite;
}
.shell__body {
display: grid;
grid-template-columns: 240px 1fr;
min-height: 0;
flex: 1;
}
.simple__body {
flex: 1;
padding: 24px;
display: flex;
justify-content: center;
align-items: flex-start;
}
.card {
width: min(900px, 100%);
background: var(--panel);
border: 1px solid var(--edge);
border-radius: 10px;
padding: 18px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35);
}
.card__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
border-bottom: 1px solid var(--edge);
padding-bottom: 12px;
}
.card__header h2 {
margin: 0;
font-size: 18px;
}
.card__header p {
margin: 6px 0 0;
color: var(--muted);
font-size: 12px;
}
.card__content {
margin-top: 16px;
display: grid;
gap: 14px;
}
.file-block {
display: grid;
gap: 6px;
}
.file-meta {
display: flex;
align-items: center;
gap: 10px;
font-size: 12px;
color: var(--muted);
flex-wrap: wrap;
}
.file-meta--empty {
color: rgba(144, 160, 179, 0.7);
font-style: italic;
}
.file-name {
color: var(--text);
font-family: \"IBM Plex Mono\", monospace;
}
.file-size {
font-family: \"IBM Plex Mono\", monospace;
}
.sidebar {
border-right: 1px solid var(--edge);
padding: 14px;
background: var(--panel-2);
display: grid;
gap: 12px;
align-content: start;
}
.sidebar__section {
padding: 10px;
border-radius: 6px;
background: rgba(12, 18, 26, 0.9);
border: 1px solid var(--edge);
align-self: start;
}
.sidebar__title {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--muted);
margin-bottom: 10px;
}
.sidebar__hint {
font-size: 10px;
color: var(--muted);
margin-top: 8px;
line-height: 1.4;
}
.stat {
display: flex;
justify-content: space-between;
padding: 6px 0;
border-bottom: 1px solid var(--edge);
font-size: 12px;
}
.stat:last-child {
border-bottom: none;
}
.stat strong {
color: var(--accent);
font-family: "IBM Plex Mono", monospace;
}
.workspace {
padding: 14px 16px;
display: grid;
gap: 12px;
overflow: auto;
}
.panel {
padding: 12px;
border-radius: 6px;
background: var(--panel);
border: 1px solid var(--edge);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
}
.panel--dark {
background: #0a0f16;
}
.panel__header {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.panel__header h2 {
margin: 0 0 2px;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.panel__header p {
margin: 0;
color: var(--muted);
font-size: 11px;
}
.panel__actions {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.tab {
border: 1px solid var(--chip-border);
background: var(--chip);
color: var(--muted);
padding: 5px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.tab--active {
color: var(--text);
border-color: var(--accent);
box-shadow: inset 0 0 0 1px rgba(77, 227, 184, 0.4);
}
.chip {
border: 1px solid var(--chip-border);
background: var(--chip);
color: var(--text);
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 11px;
display: inline-flex;
align-items: center;
gap: 6px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.chip input,
.file-row input {
display: none;
}
.chip--ghost {
background: transparent;
border-color: var(--edge);
color: var(--muted);
}
.chip--danger {
border-color: rgba(255, 93, 93, 0.5);
color: #ffd1d1;
background: rgba(255, 93, 93, 0.15);
}
.chip:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.file-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 8px;
border-radius: 4px;
background: #0b121b;
border: 1px solid var(--edge);
font-size: 11px;
cursor: pointer;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.file-row--button {
width: 100%;
justify-content: flex-start;
gap: 6px;
color: var(--text);
}
.search {
border: 1px solid var(--edge);
border-radius: 4px;
padding: 5px 8px;
min-width: 160px;
background: #0b121b;
font-size: 11px;
}
.table {
margin-top: 10px;
display: grid;
gap: 4px;
font-family: "IBM Plex Mono", monospace;
font-size: 11px;
}
.table__head,
.table__row {
display: grid;
gap: 4px;
}
.table--servers .table__head,
.table--servers .table__row {
grid-template-columns: 1.1fr 1fr 0.8fr 0.5fr 0.45fr 0.45fr 0.5fr;
}
.table--actions .table__head,
.table--actions .table__row {
grid-template-columns: 1.1fr 0.9fr 1.1fr 0.9fr 1.2fr;
}
.table--keyboards .table__head,
.table--keyboards .table__row {
grid-template-columns: 1.2fr 1fr 0.6fr;
}
.table--outputs .table__head,
.table--outputs .table__row {
grid-template-columns: 0.5fr 0.6fr 1.6fr;
}
.table__cell {
padding: 5px 6px;
border-radius: 4px;
background: #0b121b;
color: inherit;
border: 1px solid transparent;
text-align: left;
}
.table__cell--head {
font-weight: 600;
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.1em;
cursor: pointer;
color: var(--muted);
background: transparent;
border: 1px solid var(--edge);
}
.table__cell--text {
background: transparent;
border: 1px dashed var(--edge);
}
.table__cell--actions {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
background: transparent;
border: none;
padding: 0;
}
.input {
border: 1px solid transparent;
background: #0b121b;
}
.input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 1px rgba(77, 227, 184, 0.25);
}
.toggle {
display: flex;
align-items: center;
}
.toggle input {
display: none;
}
.toggle span {
width: 30px;
height: 16px;
border-radius: 999px;
background: #182232;
position: relative;
border: 1px solid var(--edge);
}
.toggle span::after {
content: "";
width: 12px;
height: 12px;
position: absolute;
top: 1px;
left: 1px;
border-radius: 50%;
background: #d7e3f1;
transition: transform 0.2s ease;
}
.toggle input:checked + span {
background: rgba(77, 227, 184, 0.3);
}
.toggle input:checked + span::after {
transform: translateX(14px);
background: var(--accent);
}
@media (max-width: 1024px) {
.shell__body {
grid-template-columns: 1fr;
}
.sidebar {
border-right: none;
border-bottom: 1px solid var(--edge);
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
}
@media (max-width: 900px) {
.table__head,
.table__row {
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
}
.topbar {
flex-direction: column;
align-items: flex-start;
gap: 10px;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

File diff suppressed because it is too large Load Diff

16
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

10
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,10 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
host: "0.0.0.0",
port: 5173
}
});

BIN
samples/01_base.set Normal file

Binary file not shown.

BIN
samples/02_test.xlsx Normal file

Binary file not shown.

BIN
samples/03_exported.set Normal file

Binary file not shown.

BIN
samples/04_GeViSet.set Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
samples/260109_MKS_P6.set Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
samples/260114_MKS_P6.set Normal file

Binary file not shown.

Binary file not shown.

BIN
samples/260115_MKS_P6.set Normal file

Binary file not shown.

Binary file not shown.

BIN
samples/260127_MKS_ALL.set Normal file

Binary file not shown.

BIN
samples/260128_MKS_ALL.set Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
samples/Client GeViIO.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

BIN
samples/combined (1).set Normal file

Binary file not shown.

BIN
samples/combined (2).set Normal file

Binary file not shown.

BIN
samples/updated (70).set Normal file

Binary file not shown.

Binary file not shown.

BIN
samples/updated (71).set Normal file

Binary file not shown.

Binary file not shown.

BIN
samples/updated (72).set Normal file

Binary file not shown.

BIN
samples/updated (73).set Normal file

Binary file not shown.

BIN
samples/updated (74).set Normal file

Binary file not shown.

BIN
samples/updated (75).set Normal file

Binary file not shown.

BIN
samples/updated (76).set Normal file

Binary file not shown.

BIN
samples/updated (77).set Normal file

Binary file not shown.

BIN
samples/updated (78).set Normal file

Binary file not shown.

400
scripts/build_mapping.js Normal file
View File

@@ -0,0 +1,400 @@
const fs = require('fs');
const mapping = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
const rows = JSON.parse(fs.readFileSync(process.argv[3], 'utf8'));
const templateRules = JSON.parse(fs.readFileSync(process.argv[4], 'utf8'));
const isTelemetryTemplate = (rule) => {
const filters = (rule.filters || []).map((f) => f.name);
const fields = (rule.fields || []).map((f) => f.name);
return (
filters.includes('.VideoInput') &&
!filters.includes('.Position') &&
!filters.includes('.VideoOutput') &&
fields.includes('VideoInput') &&
!fields.includes('Position')
);
};
const deriveActionName = (inputCaption) => {
if (!inputCaption) return '';
let name = inputCaption.trim();
name = name.replace(/^gevi[\s_]+/i, '');
name = name.replace(/[_\s]\d+$/i, '');
return name.trim();
};
const normalize = (value) => (value || '').trim().toLowerCase();
const normalizeInputName = (value) => (value || '').trim().toLowerCase().replace(/\s+/g, ' ');
const findTemplate = (kind) => {
if (kind === 'telemetry') {
const preferred = mapping.rules.find((rule) => {
if (!isTelemetryTemplate(rule)) return false;
const name = String(rule.input?.name || '');
return /^GeVi\s+(Pan|Tilt|Zoom|Focus)/i.test(name);
});
if (preferred) return preferred;
return (
mapping.rules.find((rule) => isTelemetryTemplate(rule)) ||
(templateRules.telemetry || []).find((rule) => isTelemetryTemplate(rule)) ||
null
);
}
return (
mapping.rules.find((rule) => {
const filters = (rule.filters || []).map((f) => f.name);
const fields = (rule.fields || []).map((f) => f.name);
return (
filters.includes('.SwitchMode') &&
filters.includes('.VideoInput') &&
filters.includes('.VideoOutput') &&
fields.includes('SwitchMode') &&
fields.includes('VideoOutput')
);
}) ||
(templateRules.crossswitch || []).find((rule) => {
const filters = (rule.filters || []).map((f) => f.name);
const fields = (rule.fields || []).map((f) => f.name);
return (
filters.includes('.SwitchMode') &&
filters.includes('.VideoInput') &&
filters.includes('.VideoOutput') &&
fields.includes('SwitchMode') &&
fields.includes('VideoOutput')
);
}) ||
null
);
};
const defaultPosFallback =
(templateRules.defaultpos || []).find((rule) => {
const filters = (rule.filters || []).map((f) => f.name);
const fields = (rule.fields || []).map((f) => f.name);
return filters.includes('.Position') || fields.includes('Position');
}) || null;
const fallbackTemplate = mapping.rules[0];
const telemetryTemplate = findTemplate('telemetry') || fallbackTemplate;
const crossSwitchTemplate = findTemplate('crossswitch') || fallbackTemplate;
const defaultPosTemplate =
mapping.rules.find((rule) => {
const filters = (rule.filters || []).map((f) => f.name);
const fields = (rule.fields || []).map((f) => f.name);
return filters.includes('.Position') || fields.includes('Position');
}) ||
defaultPosFallback ||
fallbackTemplate;
const findTelemetryActionTemplate = (actionName) => {
if (!actionName) return null;
const needle = actionName.toLowerCase();
return (
mapping.rules.find((rule) => {
const name = String(rule.input?.name || '').toLowerCase();
if (!name.startsWith('gevi ')) return false;
if (!name.includes(needle)) return false;
if (name.includes('prepos')) return false;
return isTelemetryTemplate(rule);
}) ||
(templateRules.telemetry || []).find((rule) => {
const name = String(rule.input?.name || '').toLowerCase();
if (!name.startsWith('gevi ')) return false;
if (!name.includes(needle)) return false;
if (name.includes('prepos')) return false;
return isTelemetryTemplate(rule);
}) ||
null
);
};
const findDefaultPosTemplate = (actionName) => {
if (!actionName) return null;
const needle = actionName.toLowerCase();
return (
mapping.rules.find((rule) => {
const name = String(rule.input?.name || '').toLowerCase();
const filters = (rule.filters || []).map((f) => f.name);
const fields = (rule.fields || []).map((f) => f.name);
if (!name.startsWith('gevi ')) return false;
if (!name.includes(needle)) return false;
return filters.includes('.Position') || fields.includes('Position');
}) ||
(templateRules.defaultpos || []).find((rule) => {
const name = String(rule.input?.name || '').toLowerCase();
const filters = (rule.filters || []).map((f) => f.name);
const fields = (rule.fields || []).map((f) => f.name);
if (!name.startsWith('gevi ')) return false;
if (!name.includes(needle)) return false;
return filters.includes('.Position') || fields.includes('Position');
}) ||
null
);
};
const outputKey = (inputName, o) => [normalize(inputName), normalize(o.actionField), normalize(o.action), normalize(o.server)].join('|');
const ruleIndexByName = new Map();
const ruleIndexByCamera = new Map();
const ruleIndexByAction = new Map();
const existingKeys = new Set();
const flagTemplates = new Map();
const fieldFlagCounts = new Map();
mapping.rules.forEach((rule, idx) => {
const normalized = normalizeInputName(rule.input.name);
ruleIndexByName.set(normalized, idx);
const match = normalized.match(/(\d+)$/);
if (match) {
const cam = match[1];
if (!ruleIndexByCamera.has(cam)) ruleIndexByCamera.set(cam, []);
ruleIndexByCamera.get(cam).push(idx);
}
for (const out of rule.outputs || []) {
existingKeys.add(outputKey(rule.input.name, out));
const key = [normalize(out.actionField), normalize(out.action)].join('|');
if (!flagTemplates.has(key)) {
flagTemplates.set(key, { ...out.flags });
}
const fieldKey = normalize(out.actionField);
const flagPair = `${out.flags.primary}|${out.flags.secondary}`;
if (!fieldFlagCounts.has(fieldKey)) fieldFlagCounts.set(fieldKey, new Map());
const counts = fieldFlagCounts.get(fieldKey);
counts.set(flagPair, (counts.get(flagPair) || 0) + 1);
}
});
const fallbackFlags = (actionField) => {
const counts = fieldFlagCounts.get(normalize(actionField));
if (!counts) return { primary: 0, secondary: 0 };
let best = '';
let bestCount = -1;
counts.forEach((count, key) => {
if (count > bestCount) {
bestCount = count;
best = key;
}
});
const [primary, secondary] = best.split('|').map((val) => Number(val));
return { primary: primary || 0, secondary: secondary || 0 };
};
let maxRuleId = Math.max(0, ...mapping.rules.map((r) => Number(r.id || 0)));
const isCrossSwitchAction = (actionName, category) => /crossswitch/i.test(actionName) || /crossbar/i.test(category);
const DEFAULTPOS_ACTIONS = {
MoveToDefaultPosition: 'DefaultPosCallUp',
ClearDefaultPosition: 'DefaultPosClear',
SaveDefaultPosition: 'DefaultPosSave',
};
const normalizeOutputAction = (actionName) => DEFAULTPOS_ACTIONS[actionName] || actionName;
const isDefaultPosAction = (actionName) => Object.prototype.hasOwnProperty.call(DEFAULTPOS_ACTIONS, actionName);
const isFocusSpeedAction = (actionName) => /focusnear|focusfar/i.test(actionName);
const isFocusInputAction = (actionName) => /focusnear|focusfar|focusstop|focus/i.test(actionName);
const parseSpeed = (value) => {
if (!value) return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
};
const buildAction = (actionName, cameraId, speed) => {
if (!actionName) return '';
const params = [];
if (cameraId) params.push('Comment: ""');
if (cameraId) params.push(`Camera: ${cameraId}`);
if (speed !== undefined) params.push(`Speed: ${speed}`);
if (params.length) return `@ ${actionName} (${params.join(', ')})`;
return `@ ${actionName} ()`;
};
for (const row of rows) {
const output1 = row.outputs.find((o) => o.kind === 'output1');
const inputName = row.caption || `Camera ${row.cameraId}`;
const inputCategoryRaw = String(output1?.category ?? '');
const inputActionRaw = String(output1?.action ?? '').trim();
const inputActionName = inputActionRaw || deriveActionName(inputName);
const actionName = inputActionName;
const inputCategory = inputCategoryRaw.toLowerCase();
const cameraId = String(row.cameraId ?? '').trim();
const outputs = [];
const seenOutputs = new Set();
const serverType = String(row.serverType ?? '').toLowerCase();
const isGscTarget = serverType.includes('geviscope') || serverType.includes('gsc');
const isGcoreTarget = serverType.includes('g-core') || serverType.includes('gcore');
const prefer = isGscTarget ? 'gsc' : isGcoreTarget ? 'gcore' : 'both';
const gscOut = prefer !== 'gcore' ? row.outputs.find((o) => o.kind === 'gsc') : null;
if (gscOut) {
const gscActionRaw = String(gscOut.action ?? actionName).trim();
const gscActionName = normalizeOutputAction(gscActionRaw);
const gscCamera = String(gscOut.ptzHead ?? '').trim();
const gscSpeed = parseSpeed(gscOut.speed);
const gscSpeedValue = isFocusSpeedAction(gscActionName) ? gscSpeed : undefined;
const gscCategory = String(gscOut.category ?? '').toLowerCase();
const crossSwitch = isCrossSwitchAction(gscActionName, inputCategory) || /viewer/.test(gscCategory);
const gscCaption = gscOut.caption || `GSC ${gscActionName}${cameraId ? `_${cameraId}` : ''}`;
const gscServer = gscOut.server || (String(row.serverType ?? '').toLowerCase().includes('geviscope') ? row.server : '');
if (actionName || gscOut.caption || gscOut.category || gscOut.server || String(row.serverType ?? '').toLowerCase().includes('geviscope')) {
const gscAction = crossSwitch ? '@ ViewerConnectLive ()' : buildAction(gscActionName, gscCamera, gscSpeedValue);
const gscFlags = flagTemplates.get([normalize('GscAction'), normalize(gscAction)].join('|')) || fallbackFlags('GscAction');
const out = {
id: '1',
name: gscCaption,
flags: gscFlags,
actionField: 'GscAction',
serverField: 'GscServer',
action: gscAction,
server: gscServer || '',
};
const key = outputKey(inputName, out);
if (!seenOutputs.has(key) && !existingKeys.has(key)) {
outputs.push(out);
seenOutputs.add(key);
existingKeys.add(key);
}
}
}
const gcoreOut = prefer !== 'gsc' ? row.outputs.find((o) => o.kind === 'gcore') : null;
if (gcoreOut) {
const gcoreActionRaw = String(gcoreOut.action ?? actionName).trim();
const gcoreActionName = normalizeOutputAction(gcoreActionRaw);
const gcoreCamera = String(gcoreOut.ptzHead ?? '').trim();
const gcoreSpeed = parseSpeed(gcoreOut.speed);
const gcoreSpeedValue = isFocusSpeedAction(gcoreActionName) ? gcoreSpeed : undefined;
const gcoreCategory = String(gcoreOut.category ?? '').toLowerCase();
const crossSwitch = isCrossSwitchAction(gcoreActionName, inputCategory) || /viewer/.test(gcoreCategory);
const gcoreCaption = gcoreOut.caption || `GNG ${gcoreActionName}${cameraId ? `_${cameraId}` : ''}`;
const gcoreServer = gcoreOut.server || (String(row.serverType ?? '').toLowerCase().includes('g-core') ? row.server : '');
if (actionName || gcoreOut.caption || gcoreOut.category || gcoreOut.server || String(row.serverType ?? '').toLowerCase().includes('g-core')) {
const gcoreAction = crossSwitch ? '@ ViewerConnectLive (Comment: "")' : buildAction(gcoreActionName, gcoreCamera, gcoreSpeedValue);
const gcoreFlags = flagTemplates.get([normalize('GCoreAction'), normalize(gcoreAction)].join('|')) || fallbackFlags('GCoreAction');
const out = {
id: '1',
name: gcoreCaption,
flags: gcoreFlags,
actionField: 'GCoreAction',
serverField: 'GCoreServer',
action: gcoreAction,
server: gcoreServer || '',
};
const key = outputKey(inputName, out);
if (!seenOutputs.has(key) && !existingKeys.has(key)) {
outputs.push(out);
seenOutputs.add(key);
existingKeys.add(key);
}
}
}
const kind = isCrossSwitchAction(actionName, inputCategory)
? 'crossswitch'
: isDefaultPosAction(actionName)
? 'defaultpos'
: 'telemetry';
let chosenTemplate = null;
if (kind === 'crossswitch') {
chosenTemplate = crossSwitchTemplate;
} else if (kind === 'defaultpos') {
chosenTemplate = findDefaultPosTemplate(normalizeOutputAction(actionName) || actionName) || defaultPosTemplate;
} else {
chosenTemplate = findTelemetryActionTemplate(actionName) || telemetryTemplate;
}
const nextFilters = chosenTemplate?.filters
? chosenTemplate.filters.map((f) => ({ ...f }))
: [];
const baseFilters = nextFilters;
const isTelemetry = kind === 'telemetry';
const nextFilterSet = isTelemetry
? baseFilters.map((f) => (f.name === '.VideoInput' ? { ...f, value: true } : f))
: baseFilters;
const baseFields = chosenTemplate?.fields ? [...chosenTemplate.fields] : [];
const nextFields = isTelemetry
? [...baseFields.filter((f) => f.name !== 'Position' && f.name !== 'VideoOutput'), ...(baseFields.some((f) => f.name === 'VideoInput') ? [] : [{ name: 'VideoInput', type: 'int32', value: 0 }])]
: baseFields;
const cleanedNextFields = isFocusInputAction(actionName) ? nextFields.filter((f) => f.name !== 'Temp') : nextFields;
const actionKey = actionName && cameraId ? `action:${normalize(actionName)}|cam:${cameraId}` : null;
let existingIdx = (actionKey ? ruleIndexByAction.get(actionKey) : undefined) ?? ruleIndexByName.get(normalizeInputName(inputName));
if (existingIdx === undefined && actionName && cameraId) {
const candidates = ruleIndexByCamera.get(cameraId) || [];
const actionNeedle = normalize(actionName);
for (const idx of candidates) {
const ruleName = normalizeInputName(mapping.rules[idx].input.name);
if (ruleName.includes(actionNeedle) && ruleName.endsWith(cameraId)) {
existingIdx = idx;
break;
}
}
}
if (existingIdx !== undefined) {
const existingRule = mapping.rules[existingIdx];
const existingOutputs = existingRule.outputs || [];
let nextId = Math.max(0, ...existingOutputs.map((o) => Number(o.id || 0))) + 1;
const mergedOutputs = [...existingOutputs];
for (const out of outputs) {
const key = outputKey(existingRule.input.name, out);
if (!existingKeys.has(key)) {
mergedOutputs.push({ ...out, id: String(nextId) });
nextId += 1;
existingKeys.add(key);
}
}
const existingFilters = isTelemetry
? (existingRule.filters || []).filter((f) => f.name !== '.Position' && f.name !== '.VideoOutput').map((f) => (f.name === '.VideoInput' ? { ...f, value: true } : f))
: (existingRule.filters || []).map((f) => (f.name === '.VideoInput' ? { ...f, value: true } : f));
if (isTelemetry && !existingFilters.some((f) => f.name === '.VideoInput')) {
existingFilters.push({ name: '.VideoInput', type: 'bool', value: true });
}
const existingFields = isTelemetry
? (existingRule.fields || []).filter((f) => f.name !== 'Position' && f.name !== 'VideoOutput')
: (existingRule.fields || []);
if (isFocusInputAction(actionName)) {
for (let i = existingFields.length - 1; i >= 0; i -= 1) {
if (existingFields[i]?.name === 'Temp') existingFields.splice(i, 1);
}
}
if (isTelemetry && !existingFields.some((f) => f.name === 'VideoInput')) {
existingFields.push({ name: 'VideoInput', type: 'int32', value: 0 });
}
mapping.rules[existingIdx] = {
...existingRule,
input: {
...existingRule.input,
videoInputId: existingRule.input.videoInputId ?? (cameraId ? Number(cameraId) : undefined),
ptz: typeof row.ptz === 'string' ? row.ptz.toUpperCase() === 'O' : existingRule.input.ptz,
temp: isFocusInputAction(actionName) ? undefined : existingRule.input.temp,
},
outputs: mergedOutputs,
filters: existingFilters,
fields: existingFields,
};
} else {
maxRuleId += 1;
const ruleId = String(maxRuleId);
mapping.rules.push({
id: ruleId,
input: {
name: inputName,
flags: chosenTemplate?.input?.flags || { primary: 0, secondary: 0 },
videoInputId: cameraId ? Number(cameraId) : undefined,
ptz: typeof row.ptz === 'string' ? row.ptz.toUpperCase() === 'O' : undefined,
temp: isFocusInputAction(actionName) ? undefined : chosenTemplate?.input?.temp,
},
outputs,
_order: chosenTemplate?._order,
filters: nextFilterSet,
fields: cleanedNextFields,
});
ruleIndexByName.set(normalizeInputName(inputName), mapping.rules.length - 1);
if (actionKey) ruleIndexByAction.set(actionKey, mapping.rules.length - 1);
}
}
fs.writeFileSync(process.argv[5], JSON.stringify(mapping));