Compare commits
4
Commits
92c9b245f6
...
v1.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7cdb4452f | ||
|
|
5cdde85d55 | ||
|
|
cbf45ff004 | ||
|
|
cf232e313f |
@@ -12,9 +12,27 @@ jobs:
|
||||
build:
|
||||
name: Build & push images
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# node-26#49: this build's release, vMAJOR.MINOR.PATCH. Tagged only once
|
||||
# the deploy is healthy (job "tag" below), so a failed deploy's number is
|
||||
# reused by the next push.
|
||||
- name: Work out the release version
|
||||
id: version
|
||||
run: |
|
||||
git fetch --force --tags origin
|
||||
TAG="$(bash scripts/next-version.sh)"
|
||||
if [ -z "$TAG" ]; then
|
||||
TAG="$(git tag --points-at HEAD | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -t. -k3,3n | tail -1)"
|
||||
fi
|
||||
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
echo "release ${TAG}"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -33,6 +51,7 @@ jobs:
|
||||
push: true
|
||||
build-args: |
|
||||
GIT_SHA=${{ gitea.sha }}
|
||||
DRB_VERSION=${{ steps.version.outputs.version }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/c2-core:latest
|
||||
${{ env.REGISTRY }}/c2-core:${{ gitea.sha }}
|
||||
@@ -459,3 +478,26 @@ jobs:
|
||||
PY
|
||||
curl -sS -X POST -H "Content-Type: application/json" \
|
||||
--data @/tmp/payload.json "$WEBHOOK" || echo "notification POST failed"
|
||||
|
||||
# node-26#49: a healthy deploy is a release. Tag it vMAJOR.MINOR.PATCH with
|
||||
# the number the build stamped into /health.
|
||||
tag:
|
||||
name: Tag release
|
||||
needs: [build, deploy]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Tag this commit
|
||||
env:
|
||||
VERSION: ${{ needs.build.outputs.version }}
|
||||
run: |
|
||||
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { echo "no release version from build ('$VERSION')"; exit 1; }
|
||||
git fetch --force --tags origin
|
||||
if git rev-parse -q --verify "refs/tags/v$VERSION" >/dev/null; then
|
||||
echo "v$VERSION already exists"; exit 0
|
||||
fi
|
||||
git tag "v$VERSION"
|
||||
git push origin "refs/tags/v$VERSION"
|
||||
echo "released v$VERSION"
|
||||
|
||||
@@ -13,5 +13,8 @@ COPY tests/ ./tests/
|
||||
# -- exactly how production served two-day-old code for two days.
|
||||
ARG GIT_SHA=unknown
|
||||
ENV GIT_SHA=$GIT_SHA
|
||||
# The release tag this build becomes (node-26#49), e.g. 1.1.4.
|
||||
ARG DRB_VERSION=untagged
|
||||
ENV DRB_VERSION=$DRB_VERSION
|
||||
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -6,6 +6,7 @@ import paho.mqtt.client as mqtt
|
||||
from app.config import settings
|
||||
from app.internal.logger import logger
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal import radios
|
||||
from app.internal import talkgroups
|
||||
from app.internal.tenancy import FOUNDING_ORG_ID
|
||||
|
||||
@@ -130,6 +131,7 @@ class MQTTHandler:
|
||||
"sdr_count": payload.get("sdr_count"), # None until reported, never a guessed 1
|
||||
"disk_free_bytes": payload.get("disk_free_bytes"),
|
||||
"disk_total_bytes": payload.get("disk_total_bytes"),
|
||||
"version": payload.get("version"), # installed release (node-26#49)
|
||||
"enforce_override_timeout": payload.get("enforce_override_timeout", True),
|
||||
"is_overridden": False,
|
||||
"override_system_id": None,
|
||||
@@ -167,7 +169,7 @@ class MQTTHandler:
|
||||
# records to — a full disk silently stopped node-002 for 17 days.
|
||||
for key in ("secondary_sdr_mode", "secondary_sdr_priority", "secondary_sdr_running",
|
||||
"sdr_pins", "sdr_devices", "op25_sdr_serial", "radio_channels",
|
||||
"disk_free_bytes", "disk_total_bytes"):
|
||||
"disk_free_bytes", "disk_total_bytes", "version"):
|
||||
if key in payload:
|
||||
updates[key] = payload[key]
|
||||
if "sdr_count" in payload:
|
||||
@@ -307,6 +309,12 @@ class MQTTHandler:
|
||||
updates["org_id"] = node["org_id"]
|
||||
if payload.get("audio_url"):
|
||||
updates["audio_url"] = payload["audio_url"]
|
||||
# The node usually learns the talker after call_start went out; only
|
||||
# call_start used to write srcaddr, so most calls kept None
|
||||
# (server-26#304).
|
||||
if payload.get("srcaddr") is not None:
|
||||
updates["srcaddr"] = payload["srcaddr"]
|
||||
updates.update(radios.call_fields(payload.get("radios")))
|
||||
|
||||
await fstore.doc_set("calls", call_id, updates)
|
||||
logger.info(f"Call end: {call_id}")
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Every radio heard on a call, as the edge node reports it (server-26#304).
|
||||
|
||||
The node sends `radios`: a list of {radio, start, end}, one entry per
|
||||
transmission, offsets in seconds into the uploaded audio file, so a talker's
|
||||
wall-clock time is audio_start_epoch + offset (the same mapping the node uses
|
||||
for the audio itself). It arrives twice — on
|
||||
/upload (as a JSON string form field, because the pipeline starts there) and
|
||||
on the MQTT call_end — and both go through `call_fields` so the call doc gets
|
||||
the same shape either way.
|
||||
|
||||
`radio_ids` is the distinct IDs in first-heard order, kept alongside so a
|
||||
correlator or query can ask "was radio X on this call" without walking the list.
|
||||
"""
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from app.internal.logger import logger
|
||||
|
||||
# A 600 s recording polled every 0.5 s cannot honestly hold more than this.
|
||||
MAX_TRANSMISSIONS = 200
|
||||
|
||||
|
||||
def _num(value: Any) -> Optional[float]:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return number if number >= 0 else None
|
||||
|
||||
|
||||
def parse(value: Any) -> List[Dict[str, Any]]:
|
||||
"""The node's radio list, validated. Accepts a list or its JSON string; junk entries are dropped."""
|
||||
if value is None or value == "":
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except ValueError:
|
||||
logger.warning(f"radios: not JSON, ignored ({value[:80]!r})")
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
out: List[Dict[str, Any]] = []
|
||||
for item in value[:MAX_TRANSMISSIONS]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
try:
|
||||
radio = int(item.get("radio"))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
start, end = _num(item.get("start")), _num(item.get("end"))
|
||||
if radio <= 0 or start is None or end is None:
|
||||
continue
|
||||
out.append({"radio": radio, "start": start, "end": max(start, end)})
|
||||
return out
|
||||
|
||||
|
||||
def call_fields(value: Any) -> Dict[str, Any]:
|
||||
"""Call-doc fields for a radio list; empty when the node sent none (older nodes, analog)."""
|
||||
radios = parse(value)
|
||||
if not radios:
|
||||
return {}
|
||||
return {"radios": radios, "radio_ids": list(dict.fromkeys(r["radio"] for r in radios))}
|
||||
@@ -187,6 +187,7 @@ app.include_router(discord_oauth.router) # auth per-endpoint (org owner / signe
|
||||
# build stamp baked in by the Dockerfile, not configuration anyone sets or
|
||||
# tunes, and keeping it out of Settings avoids implying it can be changed.
|
||||
_GIT_SHA = os.getenv("GIT_SHA", "unknown")
|
||||
_VERSION = os.getenv("DRB_VERSION", "untagged")
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@@ -197,6 +198,7 @@ async def health():
|
||||
# CI asserts this equals the commit it just deployed. Without it a
|
||||
# deploy can "succeed" while the previous container is still serving.
|
||||
"git_sha": _GIT_SHA,
|
||||
"version": _VERSION,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form, HTTPExce
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from app.internal.storage import upload_audio
|
||||
from app.internal import dedup
|
||||
from app.internal import radios
|
||||
from app.internal import firestore as fstore
|
||||
from app.internal import clock
|
||||
from app.internal.logger import logger
|
||||
@@ -24,6 +25,7 @@ async def upload_call_audio(
|
||||
talkgroup_id: Optional[int] = Form(None),
|
||||
talkgroup_name: Optional[str] = Form(None),
|
||||
system_id: Optional[str] = Form(None),
|
||||
radios_json: Optional[str] = Form(None, alias="radios"),
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Security(_bearer),
|
||||
):
|
||||
"""
|
||||
@@ -101,6 +103,14 @@ async def upload_call_audio(
|
||||
updates = {"audio_gcs_uri": gcs_uri}
|
||||
if node and node.get("org_id"):
|
||||
updates["org_id"] = node["org_id"]
|
||||
# Every radio heard on the call (server-26#304). Written here, not
|
||||
# only on call_end, because the pipeline below starts before the
|
||||
# node publishes call_end.
|
||||
radio_fields = radios.call_fields(radios_json)
|
||||
updates.update(radio_fields)
|
||||
if radio_fields and existing.get("srcaddr") is None:
|
||||
# Same meaning call_end gives it: the latest talker.
|
||||
updates["srcaddr"] = radio_fields["radios"][-1]["radio"]
|
||||
await fstore.doc_set("calls", call_id, updates)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not update call {call_id} with audio_gcs_uri: {e}")
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Every radio on a call reaches the call doc (server-26#304) — from /upload,
|
||||
which starts the pipeline, and from call_end, which used to drop srcaddr.
|
||||
"""
|
||||
import json
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.internal import radios
|
||||
from app.internal.mqtt_handler import MQTTHandler
|
||||
from app.main import app
|
||||
from app.routers import upload
|
||||
|
||||
LIST = [{"radio": 555, "start": 0.0, "end": 1.0}, {"radio": 777, "start": 2.0, "end": 2.5},
|
||||
{"radio": 555, "start": 3.5, "end": 4.0}]
|
||||
|
||||
|
||||
def test_parse_accepts_json_and_drops_junk():
|
||||
raw = json.dumps(LIST + [{"radio": 0, "start": 1, "end": 2}, {"radio": "x"}, "junk",
|
||||
{"radio": 9, "start": -1, "end": 2}, {"radio": 8, "start": 5, "end": 4}])
|
||||
assert radios.parse(raw) == LIST + [{"radio": 8, "start": 5.0, "end": 5.0}]
|
||||
assert radios.parse("not json") == []
|
||||
assert radios.parse({"radio": 1}) == []
|
||||
assert radios.parse(None) == []
|
||||
|
||||
|
||||
def test_call_fields_keeps_distinct_ids_in_first_heard_order():
|
||||
assert radios.call_fields(LIST)["radio_ids"] == [555, 777]
|
||||
assert radios.call_fields([]) == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_end_writes_srcaddr_and_radios():
|
||||
"""call_start used to be the only writer of srcaddr; the talker is usually learned later."""
|
||||
handler = MQTTHandler()
|
||||
with patch("app.internal.mqtt_handler.fstore") as fs:
|
||||
fs.doc_get_cached = AsyncMock(return_value=None)
|
||||
fs.doc_set = AsyncMock()
|
||||
await handler._on_call_end("node-01", {"call_id": "c1", "srcaddr": 555, "radios": LIST})
|
||||
updates = fs.doc_set.call_args[0][2]
|
||||
assert updates["srcaddr"] == 555
|
||||
assert updates["radios"] == LIST
|
||||
assert updates["radio_ids"] == [555, 777]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_end_without_radios_leaves_them_alone():
|
||||
"""An older node sends neither field; a merge write must not blank what /upload stored."""
|
||||
handler = MQTTHandler()
|
||||
with patch("app.internal.mqtt_handler.fstore") as fs:
|
||||
fs.doc_get_cached = AsyncMock(return_value=None)
|
||||
fs.doc_set = AsyncMock()
|
||||
await handler._on_call_end("node-01", {"call_id": "c1", "srcaddr": None})
|
||||
updates = fs.doc_set.call_args[0][2]
|
||||
assert "srcaddr" not in updates and "radios" not in updates
|
||||
|
||||
|
||||
def _upload(existing_call, form_radios):
|
||||
store = {
|
||||
"node_keys": {"api_key": "k"},
|
||||
"nodes": {"approval_status": "approved", "org_id": "org"},
|
||||
"calls": existing_call,
|
||||
}
|
||||
writes = []
|
||||
|
||||
async def doc_get(c, i):
|
||||
return store.get(c)
|
||||
|
||||
async def doc_set(c, i, data, merge=True):
|
||||
writes.append((c, data))
|
||||
|
||||
data = {"call_id": "c1", "node_id": "n1"}
|
||||
if form_radios is not None:
|
||||
data["radios"] = form_radios
|
||||
with patch.object(upload.fstore, "doc_get", doc_get), \
|
||||
patch.object(upload.fstore, "doc_set", doc_set), \
|
||||
patch.object(upload, "upload_audio", AsyncMock(return_value="gs://b/c1.mp3")), \
|
||||
patch.object(upload.dedup, "find_duplicate_of", AsyncMock(return_value="other")):
|
||||
resp = TestClient(app).post(
|
||||
"/upload", data=data,
|
||||
files={"file": ("a.mp3", b"\x00\x01", "audio/mpeg")},
|
||||
headers={"Authorization": "Bearer k"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
return writes[0][1]
|
||||
|
||||
|
||||
def test_upload_stores_the_radio_list_before_the_pipeline_runs():
|
||||
first = _upload({}, json.dumps(LIST))
|
||||
assert first["radios"] == LIST
|
||||
assert first["radio_ids"] == [555, 777]
|
||||
# call_start had no talker yet: take the latest one, as call_end would.
|
||||
assert first["srcaddr"] == 555
|
||||
|
||||
|
||||
def test_upload_keeps_an_existing_srcaddr_and_tolerates_no_list():
|
||||
assert "srcaddr" not in _upload({"srcaddr": 999}, json.dumps(LIST))
|
||||
assert "radios" not in _upload({}, None)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""node-26#49 — releases are visible: /health reports the server's, checkin carries the node's."""
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.internal.mqtt_handler import MQTTHandler
|
||||
from app.main import app
|
||||
|
||||
|
||||
def test_health_reports_the_release():
|
||||
with patch("app.main._VERSION", "1.1.4"):
|
||||
body = TestClient(app).get("/health").json()
|
||||
assert body["version"] == "1.1.4"
|
||||
assert "git_sha" in body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_checkin_stores_the_node_release():
|
||||
with patch("app.internal.mqtt_handler.fstore") as fs:
|
||||
fs.doc_get = AsyncMock(return_value=None)
|
||||
fs.doc_set = AsyncMock()
|
||||
await MQTTHandler()._handle_checkin("n1", {"name": "Pi", "version": "1.1.10"})
|
||||
assert fs.doc_set.call_args[0][2]["version"] == "1.1.10"
|
||||
@@ -115,7 +115,7 @@ export function CallRow({ call, systemName, isAdmin }: Props) {
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs">
|
||||
{hasAudio ? (
|
||||
<span className="text-blue-400">▶</span>
|
||||
<span className="text-accent">▶</span>
|
||||
) : (
|
||||
<span className="text-ink-muted">—</span>
|
||||
)}
|
||||
|
||||
@@ -1351,7 +1351,7 @@ function FanIncidentLayer({
|
||||
<a
|
||||
href={`/incidents/${inc.incident_id}`}
|
||||
onClick={(e) => { e.stopPropagation(); window.location.href = `/incidents/${inc.incident_id}`; e.preventDefault(); }}
|
||||
className="text-xs text-blue-600 hover:underline block mt-0.5"
|
||||
className="text-xs text-indigo-600 hover:underline block mt-0.5"
|
||||
>
|
||||
View incident →
|
||||
</a>
|
||||
@@ -1505,7 +1505,7 @@ function IncidentPathLayer({
|
||||
<span className="text-xs text-ink-muted">Play this call</span>
|
||||
</div>
|
||||
)}
|
||||
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1">
|
||||
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-indigo-600 hover:underline block mt-1">
|
||||
View incident →
|
||||
</a>
|
||||
{/* Gate A / A2 (server-26#46) — the stop location and its
|
||||
|
||||
@@ -14,7 +14,7 @@ import type { CameraProviderAdmin, LicenceStatus } from "@/lib/types";
|
||||
|
||||
const LICENCE_STYLE: Record<LicenceStatus, { label: string; cls: string }> = {
|
||||
commercial_ok: { label: "Commercial OK", cls: "bg-green-900 text-green-300 border-green-700" },
|
||||
attribution_required: { label: "Attribution required", cls: "bg-sky-950 text-sky-300 border-sky-700" },
|
||||
attribution_required: { label: "Attribution required", cls: "bg-indigo-950 text-indigo-300 border-indigo-700" },
|
||||
agreement_required: { label: "Requires agreement — not signed", cls: "bg-red-950 text-red-300 border-red-700" },
|
||||
non_commercial: { label: "Non-commercial only", cls: "bg-red-950 text-red-300 border-red-700" },
|
||||
unverified: { label: "Licence unverified", cls: "bg-amber-950 text-amber-300 border-amber-700" },
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { LicenceStatus, OutageProviderAdmin } from "@/lib/types";
|
||||
|
||||
const LICENCE_STYLE: Record<LicenceStatus, { label: string; cls: string }> = {
|
||||
commercial_ok: { label: "Commercial OK", cls: "bg-green-900 text-green-300 border-green-700" },
|
||||
attribution_required: { label: "Attribution required", cls: "bg-sky-950 text-sky-300 border-sky-700" },
|
||||
attribution_required: { label: "Attribution required", cls: "bg-indigo-950 text-indigo-300 border-indigo-700" },
|
||||
agreement_required: { label: "Requires agreement — not signed", cls: "bg-red-950 text-red-300 border-red-700" },
|
||||
non_commercial: { label: "Non-commercial only", cls: "bg-red-950 text-red-300 border-red-700" },
|
||||
unverified: { label: "Licence unverified", cls: "bg-amber-950 text-amber-300 border-amber-700" },
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# node-26#49 (versioning, shared with Client/scripts) — the release tag this commit should get, or nothing if it has one.
|
||||
#
|
||||
# VERSION holds MAJOR.MINOR and is edited by hand. PATCH is automatic: one
|
||||
# more than the highest existing vMAJOR.MINOR.PATCH tag, compared as an
|
||||
# integer (so .10 follows .9), starting at 0 for a new MAJOR.MINOR.
|
||||
#
|
||||
# Usage: scripts/next-version.sh (from the repo root, tags fetched)
|
||||
set -euo pipefail
|
||||
|
||||
base="$(tr -d ' \r\n' < VERSION)"
|
||||
[[ "$base" =~ ^[0-9]+\.[0-9]+$ ]] || { echo "VERSION must be MAJOR.MINOR, got '$base'" >&2; exit 1; }
|
||||
pattern="^v${base//./\\.}\\.[0-9]+$"
|
||||
|
||||
if git tag --points-at HEAD | grep -qE "$pattern"; then
|
||||
exit 0 # this commit is already released
|
||||
fi
|
||||
|
||||
last="$(git tag -l "v${base}.*" | grep -E "$pattern" | sed "s/^v${base}\.//" | sort -n | tail -1 || true)"
|
||||
echo "v${base}.$(( ${last:--1} + 1 ))"
|
||||
Reference in New Issue
Block a user