558 lines
19 KiB
Python
558 lines
19 KiB
Python
"""API FastAPI del Runtime server.
|
||
|
||
- WebSocket: `/ws/realtime` — pub/sub de tag updates + alarm events
|
||
- REST: `/tags`, `/tags/{id}/history`, `/alarms`, `/health`, `/project`
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import logging
|
||
from contextlib import asynccontextmanager, suppress
|
||
from datetime import datetime
|
||
from typing import Any
|
||
|
||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||
from fastapi.responses import HTMLResponse, Response
|
||
|
||
from vmssailor.runtime.server.runtime_app import RuntimeApp
|
||
from vmssailor.version import __version__
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def create_app(runtime: RuntimeApp) -> FastAPI:
|
||
"""Construye la FastAPI app y monta endpoints."""
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(_app: FastAPI):
|
||
await runtime.start()
|
||
try:
|
||
yield
|
||
finally:
|
||
await runtime.stop()
|
||
|
||
app = FastAPI(
|
||
title="VMS-Sailor Runtime API",
|
||
version=__version__,
|
||
description="On-board Runtime API: tags, history, alarms, control.",
|
||
lifespan=lifespan,
|
||
)
|
||
app.state.runtime = runtime
|
||
|
||
# ----- Root + favicon (UI amigable de bienvenida) -----------------
|
||
|
||
@app.get("/", response_class=HTMLResponse, include_in_schema=False)
|
||
def root() -> str:
|
||
return _RENDER_ROOT_HTML(runtime)
|
||
|
||
@app.get("/favicon.ico", include_in_schema=False)
|
||
def favicon() -> Response:
|
||
# SVG inline para evitar dependencias en disco
|
||
svg = _FAVICON_SVG.encode("utf-8")
|
||
return Response(content=svg, media_type="image/svg+xml")
|
||
|
||
# ----- Health ------------------------------------------------------
|
||
|
||
@app.get("/health")
|
||
def health() -> dict[str, Any]:
|
||
return {
|
||
"status": "ok",
|
||
"vmssailor_version": __version__,
|
||
"tag_store": runtime.tag_store.stats(),
|
||
"historian": runtime.historian.stats(),
|
||
"active_alarms": len(runtime.alarm_engine.active_alarms()),
|
||
"project": {
|
||
"id": runtime.project.id,
|
||
"name": runtime.project.name,
|
||
"vessel": runtime.project.vessel.name,
|
||
},
|
||
}
|
||
|
||
# ----- Project ----------------------------------------------------
|
||
|
||
@app.get("/project")
|
||
def project() -> dict[str, Any]:
|
||
p = runtime.project
|
||
return {
|
||
"id": p.id,
|
||
"name": p.name,
|
||
"customer": p.customer,
|
||
"vessel": {
|
||
"id": p.vessel.id,
|
||
"name": p.vessel.name,
|
||
"type": p.vessel.type.value,
|
||
"loa_m": p.vessel.length_overall_m,
|
||
"beam_m": p.vessel.beam_max_m,
|
||
"draft_m": p.vessel.draft_m,
|
||
},
|
||
"systems_enabled": [s.value for s in p.systems_enabled],
|
||
"stats": p.stats(),
|
||
}
|
||
|
||
# ----- Tags --------------------------------------------------------
|
||
|
||
@app.get("/tags")
|
||
def list_tags() -> list[dict[str, Any]]:
|
||
out: list[dict[str, Any]] = []
|
||
for tag_id, tag in runtime.tag_store.all_tags().items():
|
||
tv = runtime.tag_store.get(tag_id)
|
||
out.append(
|
||
{
|
||
"id": tag_id,
|
||
"description": tag.description,
|
||
"unit_si": tag.unit_si.value,
|
||
"controllable": tag.controllable,
|
||
"value": tv.value if tv else None,
|
||
"quality": tv.quality.value if tv else None,
|
||
"timestamp": tv.timestamp.isoformat() if tv else None,
|
||
"range_normal_min": tag.range_normal_min,
|
||
"range_normal_max": tag.range_normal_max,
|
||
}
|
||
)
|
||
return out
|
||
|
||
@app.get("/tags/{tag_id}")
|
||
def get_tag(tag_id: str) -> dict[str, Any]:
|
||
tag = runtime.tag_store.get_tag(tag_id)
|
||
tv = runtime.tag_store.get(tag_id)
|
||
if tag is None or tv is None:
|
||
raise HTTPException(status_code=404, detail=f"Tag '{tag_id}' no encontrado.")
|
||
return {
|
||
"id": tag_id,
|
||
"description": tag.description,
|
||
"unit_si": tag.unit_si.value,
|
||
"value": tv.value,
|
||
"quality": tv.quality.value,
|
||
"timestamp": tv.timestamp.isoformat(),
|
||
"alarms": [a.model_dump(mode="json") for a in tag.alarms],
|
||
}
|
||
|
||
@app.get("/tags/{tag_id}/history")
|
||
def history(
|
||
tag_id: str,
|
||
since: str | None = None,
|
||
until: str | None = None,
|
||
limit: int = 1000,
|
||
) -> list[dict[str, Any]]:
|
||
if tag_id not in runtime.tag_store:
|
||
raise HTTPException(status_code=404, detail=f"Tag '{tag_id}' no encontrado.")
|
||
try:
|
||
since_dt = datetime.fromisoformat(since) if since else None
|
||
until_dt = datetime.fromisoformat(until) if until else None
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="Formato de fecha inválido. Use ISO 8601 (ej: 2024-01-15T10:00:00).")
|
||
return runtime.historian.query(tag_id, since=since_dt, until=until_dt, limit=limit)
|
||
|
||
# ----- Alarms ------------------------------------------------------
|
||
|
||
@app.get("/alarms")
|
||
def alarms(state: str | None = None) -> list[dict[str, Any]]:
|
||
active = runtime.alarm_engine.active_alarms()
|
||
if state == "active":
|
||
return [a.model_dump(mode="json") for a in active]
|
||
all_events = runtime.alarm_events
|
||
return [a.model_dump(mode="json") for a in all_events]
|
||
|
||
@app.post("/alarms/{alarm_id}/ack")
|
||
def ack_alarm(alarm_id: str, user: str = "anonymous") -> dict[str, Any]:
|
||
acked = runtime.alarm_engine.ack(alarm_id, user=user)
|
||
if acked is None:
|
||
raise HTTPException(status_code=404, detail=f"Alarma '{alarm_id}' no activa.")
|
||
return acked.model_dump(mode="json")
|
||
|
||
# ----- Log Book ----------------------------------------------------
|
||
|
||
@app.get("/logbook")
|
||
def logbook_entries(
|
||
kind: str | None = None,
|
||
since: str | None = None,
|
||
until: str | None = None,
|
||
limit: int = 200,
|
||
) -> list[dict[str, Any]]:
|
||
from vmssailor.runtime.server.logbook import LogEntryKind
|
||
|
||
try:
|
||
kind_enum = LogEntryKind(kind) if kind else None
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail=f"Kind inválido: {kind}")
|
||
try:
|
||
since_dt = datetime.fromisoformat(since) if since else None
|
||
until_dt = datetime.fromisoformat(until) if until else None
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="Formato de fecha inválido. Use ISO 8601 (ej: 2024-01-15T10:00:00).")
|
||
entries = runtime.logbook.query(
|
||
kind=kind_enum, since=since_dt, until=until_dt, limit=limit
|
||
)
|
||
return [
|
||
{
|
||
"id": e.id,
|
||
"kind": e.kind.value,
|
||
"timestamp": e.timestamp.isoformat(),
|
||
"summary": e.summary,
|
||
"payload": e.payload,
|
||
"user": e.user,
|
||
"hash": e.hash[:16] + "…",
|
||
}
|
||
for e in entries
|
||
]
|
||
|
||
@app.post("/logbook")
|
||
def logbook_append_manual(
|
||
kind: str = "manual",
|
||
summary: str = "",
|
||
user: str = "operator",
|
||
) -> dict[str, Any]:
|
||
import asyncio
|
||
|
||
from vmssailor.runtime.server.logbook import LogEntryKind
|
||
|
||
try:
|
||
kind_enum = LogEntryKind(kind)
|
||
except ValueError as err:
|
||
raise HTTPException(status_code=400, detail=f"Kind inválido: {kind}") from err
|
||
if not summary:
|
||
raise HTTPException(status_code=400, detail="summary requerido")
|
||
|
||
async def _do_append():
|
||
return await runtime.logbook.append(kind_enum, summary, user=user)
|
||
|
||
entry = asyncio.run_coroutine_threadsafe(_do_append(), asyncio.get_event_loop()).result(timeout=2)
|
||
return {
|
||
"id": entry.id,
|
||
"kind": entry.kind.value,
|
||
"timestamp": entry.timestamp.isoformat(),
|
||
"hash": entry.hash,
|
||
}
|
||
|
||
@app.get("/logbook/verify")
|
||
def logbook_verify() -> dict[str, Any]:
|
||
ok, broken = runtime.logbook.verify_chain()
|
||
return {"ok": ok, "broken_entry_ids": broken}
|
||
|
||
# ----- WebSocket ---------------------------------------------------
|
||
|
||
@app.websocket("/ws/realtime")
|
||
async def ws_realtime(ws: WebSocket) -> None:
|
||
await ws.accept()
|
||
q = runtime.tag_store.subscribe(maxsize=512)
|
||
# Push current snapshot first
|
||
for tv in runtime.tag_store.all_values().values():
|
||
await ws.send_json(tv.to_event_dict())
|
||
try:
|
||
while True:
|
||
try:
|
||
tv = await asyncio.wait_for(q.get(), timeout=30.0)
|
||
await ws.send_json(tv.to_event_dict())
|
||
except TimeoutError:
|
||
await ws.send_json({"type": "heartbeat"})
|
||
except WebSocketDisconnect:
|
||
pass
|
||
finally:
|
||
runtime.tag_store.unsubscribe(q)
|
||
with suppress(Exception):
|
||
await ws.close()
|
||
|
||
return app
|
||
|
||
|
||
# ===========================================================================
|
||
# Root HTML (UI amigable de bienvenida con dashboard vivo)
|
||
# ===========================================================================
|
||
|
||
|
||
_FAVICON_SVG = """<?xml version="1.0" encoding="UTF-8"?>
|
||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||
<rect width="64" height="64" rx="14" fill="#04111F"/>
|
||
<circle cx="32" cy="32" r="22" fill="none" stroke="#00D9FF" stroke-width="2.2"/>
|
||
<path d="M 32 10 L 30 6 L 34 6 Z" fill="#00D9FF"/>
|
||
<g transform="translate(32,33) scale(0.55)">
|
||
<path d="M -32 6 C -28 -1, -22 -5, -14 -6 L 22 -6 L 30 -2 L 30 6 L 24 11 L -26 11 Z" fill="#E6EAF0"/>
|
||
<path d="M -10 -6 L -4 -14 L 14 -14 L 18 -6 Z" fill="#FFFFFF"/>
|
||
<path d="M -8 -10 L -3 -12 L 13 -12 L 16 -10" stroke="#00D9FF" stroke-width="0.9" fill="none"/>
|
||
</g>
|
||
</svg>"""
|
||
|
||
|
||
def _RENDER_ROOT_HTML(runtime: RuntimeApp) -> str:
|
||
project = runtime.project
|
||
tag_stats = runtime.tag_store.stats()
|
||
active_alarms = len(runtime.alarm_engine.active_alarms())
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="es">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>VMS-Sailor Runtime · {project.name}</title>
|
||
<link rel="icon" type="image/svg+xml" href="/favicon.ico">
|
||
<style>
|
||
:root {{
|
||
--abyss: #04111F; --midnight: #0A1A2E; --steel: #1A2B42; --iron: #2C3E5C;
|
||
--fog: #7C8B9F; --sand: #E6EAF0; --foam: #F2F5F9;
|
||
--cyan: #00D9FF; --cyan-deep: #1B7FB5; --horizon: #5BC0EB;
|
||
--ok: #00E08A; --warn: #FFB020; --emergency: #FF3B47;
|
||
}}
|
||
*, *::before, *::after {{ box-sizing: border-box; }}
|
||
html, body {{
|
||
margin: 0; padding: 0; min-height: 100vh;
|
||
background: linear-gradient(135deg, #04111F 0%, #0A1A2E 60%, #1A2B42 100%);
|
||
color: var(--sand);
|
||
font-family: -apple-system, "Segoe UI", Roboto, system-ui, sans-serif;
|
||
font-size: 14px;
|
||
}}
|
||
.container {{ max-width: 1280px; margin: 0 auto; padding: 32px; }}
|
||
|
||
.hero {{
|
||
display: flex; align-items: center; gap: 24px;
|
||
padding: 24px 32px;
|
||
background: linear-gradient(135deg, rgba(0,217,255,0.10), rgba(0,217,255,0.02));
|
||
border: 1px solid rgba(0,217,255,0.25);
|
||
border-radius: 16px;
|
||
margin-bottom: 24px;
|
||
}}
|
||
.hero-logo {{
|
||
width: 84px; height: 84px;
|
||
display: flex; align-items: center; justify-content: center;
|
||
filter: drop-shadow(0 8px 24px rgba(0,217,255,0.4));
|
||
flex-shrink: 0;
|
||
}}
|
||
.hero h1 {{
|
||
margin: 0;
|
||
font-size: 32px; font-weight: 700; letter-spacing: -1px;
|
||
color: var(--foam);
|
||
}}
|
||
.hero h1 .accent {{ color: var(--cyan); font-weight: 300; }}
|
||
.hero .subtitle {{
|
||
color: var(--horizon); margin-top: 4px;
|
||
font-size: 13px; letter-spacing: 2px; text-transform: uppercase;
|
||
}}
|
||
.hero .vessel-info {{
|
||
color: var(--sand); margin-top: 8px;
|
||
font-family: "JetBrains Mono", Consolas, monospace; font-size: 12px;
|
||
}}
|
||
.hero .pill {{
|
||
display: inline-flex; align-items: center; gap: 6px;
|
||
padding: 4px 10px; margin-left: auto;
|
||
background: rgba(0,224,138,0.15); border: 1px solid rgba(0,224,138,0.4);
|
||
border-radius: 999px; color: var(--ok);
|
||
font-size: 12px; font-weight: 700; letter-spacing: 0.5px;
|
||
}}
|
||
.pill .dot {{
|
||
width: 8px; height: 8px; border-radius: 50%;
|
||
background: var(--ok); box-shadow: 0 0 10px var(--ok);
|
||
animation: pulse 2s ease-in-out infinite;
|
||
}}
|
||
@keyframes pulse {{ 50% {{ opacity: 0.5; transform: scale(1.4); }} }}
|
||
|
||
.stats-row {{
|
||
display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px;
|
||
margin-bottom: 24px;
|
||
}}
|
||
.stat {{
|
||
padding: 16px 18px;
|
||
background: var(--midnight); border: 1px solid var(--steel);
|
||
border-radius: 10px;
|
||
}}
|
||
.stat .label {{
|
||
font-size: 10px; letter-spacing: 2px; text-transform: uppercase;
|
||
color: var(--fog); font-weight: 700;
|
||
}}
|
||
.stat .value {{
|
||
font-family: "JetBrains Mono", Consolas, monospace;
|
||
font-size: 28px; font-weight: 600; color: var(--foam);
|
||
margin-top: 6px; letter-spacing: -0.5px;
|
||
}}
|
||
.stat .value.alarm {{ color: var(--emergency); }}
|
||
.stat .sub {{ font-size: 11px; color: var(--fog); margin-top: 2px; }}
|
||
|
||
h2 {{
|
||
font-size: 12px; letter-spacing: 2px; text-transform: uppercase;
|
||
color: var(--fog); font-weight: 700;
|
||
margin: 24px 0 12px 0;
|
||
}}
|
||
|
||
.actions {{
|
||
display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||
gap: 12px; margin-bottom: 24px;
|
||
}}
|
||
.action {{
|
||
display: block; text-decoration: none;
|
||
padding: 16px 18px;
|
||
background: var(--midnight); border: 1px solid var(--steel);
|
||
border-radius: 10px;
|
||
transition: all 160ms;
|
||
}}
|
||
.action:hover {{
|
||
border-color: var(--cyan); transform: translateY(-2px);
|
||
box-shadow: 0 8px 24px rgba(0,0,0,0.5), 0 0 16px rgba(0,217,255,0.3);
|
||
}}
|
||
.action .title {{
|
||
color: var(--foam); font-weight: 600; font-size: 14px;
|
||
display: flex; align-items: center; gap: 8px;
|
||
}}
|
||
.action .title::after {{ content: "→"; color: var(--cyan); margin-left: auto; }}
|
||
.action .desc {{
|
||
color: var(--fog); font-size: 12px; margin-top: 4px;
|
||
}}
|
||
.action .url {{
|
||
color: var(--cyan); font-family: "JetBrains Mono", Consolas, monospace;
|
||
font-size: 11px; margin-top: 6px;
|
||
}}
|
||
|
||
#tags-grid {{
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||
gap: 8px;
|
||
}}
|
||
.tag-tile {{
|
||
padding: 10px 12px;
|
||
background: var(--midnight); border: 1px solid var(--steel);
|
||
border-radius: 8px;
|
||
font-family: "JetBrains Mono", Consolas, monospace;
|
||
}}
|
||
.tag-tile .tid {{ color: var(--cyan); font-size: 10px; }}
|
||
.tag-tile .val {{
|
||
color: var(--foam); font-size: 18px; font-weight: 600;
|
||
margin-top: 4px;
|
||
}}
|
||
.tag-tile .meta {{
|
||
color: var(--fog); font-size: 9px; margin-top: 2px;
|
||
}}
|
||
|
||
footer {{
|
||
margin-top: 32px; padding-top: 16px;
|
||
border-top: 1px solid var(--steel);
|
||
color: var(--fog); font-size: 11px;
|
||
font-family: "JetBrains Mono", Consolas, monospace;
|
||
display: flex; justify-content: space-between;
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
|
||
<div class="hero">
|
||
<div class="hero-logo">
|
||
<svg viewBox="0 0 120 120" width="84" height="84">
|
||
<circle cx="60" cy="60" r="46" fill="none" stroke="#00D9FF" stroke-width="3"/>
|
||
<path d="M 60 18 L 56 8 L 64 8 Z" fill="#00D9FF"/>
|
||
<g transform="translate(60,60)">
|
||
<path d="M -32 6 C -28 -1, -22 -5, -14 -6 L 22 -6 L 30 -2 L 30 6 L 24 11 L -26 11 Z"
|
||
fill="#E6EAF0" stroke="#04111F" stroke-width="0.8"/>
|
||
<path d="M -10 -6 L -4 -14 L 14 -14 L 18 -6 Z" fill="#FFFFFF" stroke="#04111F" stroke-width="0.6"/>
|
||
<path d="M -2 -14 L 0 -19 L 10 -19 L 12 -14 Z" fill="#FFFFFF" stroke="#04111F" stroke-width="0.5"/>
|
||
<path d="M -8 -10 L -3 -12 L 13 -12 L 16 -10" stroke="#00D9FF" stroke-width="0.9" fill="none"/>
|
||
</g>
|
||
</svg>
|
||
</div>
|
||
<div>
|
||
<h1>VMS<span class="accent"> · </span>Sailor</h1>
|
||
<div class="subtitle">Vessel · Management · System</div>
|
||
<div class="vessel-info">
|
||
{project.vessel.name} · {project.vessel.length_overall_m:.1f} m × {project.vessel.beam_max_m:.1f} m ·
|
||
proyecto: {project.id}
|
||
</div>
|
||
</div>
|
||
<span class="pill"><span class="dot"></span>RUNTIME ACTIVO</span>
|
||
</div>
|
||
|
||
<div class="stats-row">
|
||
<div class="stat">
|
||
<div class="label">Tags activos</div>
|
||
<div class="value">{tag_stats['total_tags']}</div>
|
||
<div class="sub">{tag_stats['by_quality'].get('good', 0)} good · simulator vivo</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="label">Alarmas activas</div>
|
||
<div class="value {'alarm' if active_alarms > 0 else ''}">{active_alarms}</div>
|
||
<div class="sub">{'requieren ACK' if active_alarms else 'sin alarmas'}</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="label">Sistemas</div>
|
||
<div class="value">{len(project.systems_enabled)}</div>
|
||
<div class="sub">habilitados</div>
|
||
</div>
|
||
<div class="stat">
|
||
<div class="label">Equipos</div>
|
||
<div class="value">{len(project.equipment)}</div>
|
||
<div class="sub">en el proyecto</div>
|
||
</div>
|
||
</div>
|
||
|
||
<h2>API Endpoints</h2>
|
||
<div class="actions">
|
||
<a class="action" href="/docs">
|
||
<div class="title">Documentación interactiva (Swagger UI)</div>
|
||
<div class="desc">Probar todos los endpoints de la API en el navegador.</div>
|
||
<div class="url">/docs</div>
|
||
</a>
|
||
<a class="action" href="/health">
|
||
<div class="title">Health</div>
|
||
<div class="desc">Estado del runtime, tag store, historian, alarmas.</div>
|
||
<div class="url">/health</div>
|
||
</a>
|
||
<a class="action" href="/project">
|
||
<div class="title">Project</div>
|
||
<div class="desc">Info del proyecto cargado (buque, sistemas, stats).</div>
|
||
<div class="url">/project</div>
|
||
</a>
|
||
<a class="action" href="/tags">
|
||
<div class="title">Tags (snapshot)</div>
|
||
<div class="desc">Lista completa de tags con valores actuales del simulator.</div>
|
||
<div class="url">/tags</div>
|
||
</a>
|
||
<a class="action" href="/alarms">
|
||
<div class="title">Alarms</div>
|
||
<div class="desc">Histórico de eventos de alarma desde el arranque.</div>
|
||
<div class="url">/alarms</div>
|
||
</a>
|
||
<a class="action" href="/logbook">
|
||
<div class="title">Log Book</div>
|
||
<div class="desc">Bitácora naval con arranques de motor, snapshots, alarmas.</div>
|
||
<div class="url">/logbook</div>
|
||
</a>
|
||
</div>
|
||
|
||
<h2>Tags en vivo (actualizando cada 1 s)</h2>
|
||
<div id="tags-grid">Cargando...</div>
|
||
|
||
<footer>
|
||
<span>VMS-Sailor Runtime · v{__version__}</span>
|
||
<span>Para conectar el cliente desktop: <code>uv run python runtime_client_main.py</code></span>
|
||
</footer>
|
||
</div>
|
||
|
||
<script>
|
||
async function refreshTags() {{
|
||
try {{
|
||
const r = await fetch('/tags');
|
||
const data = await r.json();
|
||
const grid = document.getElementById('tags-grid');
|
||
grid.innerHTML = data.map(t => {{
|
||
const val = typeof t.value === 'number' ? t.value.toFixed(2) : String(t.value ?? '—');
|
||
const unit = t.unit_si && t.unit_si !== 'none' ? ' ' + t.unit_si : '';
|
||
const q = t.quality || '—';
|
||
const color = q === 'good' ? '#00E08A' : '#FFB020';
|
||
return `<div class="tag-tile">
|
||
<div class="tid">${{t.id}}</div>
|
||
<div class="val">${{val}}${{unit}}</div>
|
||
<div class="meta" style="color:${{color}}">q=${{q}}</div>
|
||
</div>`;
|
||
}}).join('');
|
||
}} catch (e) {{
|
||
const msg = document.createTextNode('Error de conexión: ' + String(e));
|
||
const div = document.createElement('div');
|
||
div.style.color = 'var(--emergency)';
|
||
div.appendChild(msg);
|
||
const grid = document.getElementById('tags-grid');
|
||
grid.innerHTML = '';
|
||
grid.appendChild(div);
|
||
}}
|
||
}}
|
||
refreshTags();
|
||
setInterval(refreshTags, 1000);
|
||
</script>
|
||
</body>
|
||
</html>"""
|
||
|