Compare commits
2 commits
9c7b69e395
...
6c80bbfc8c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c80bbfc8c | ||
|
|
5c07499e99 |
4 changed files with 187 additions and 5 deletions
12
Dockerfile
12
Dockerfile
|
|
@ -2,6 +2,17 @@
|
||||||
|
|
||||||
ARG PYTHON_VERSION=3.12-slim
|
ARG PYTHON_VERSION=3.12-slim
|
||||||
|
|
||||||
|
# Resolve the git SHA from the build context's .git checkout. Coolify builds
|
||||||
|
# from source and sets SOURCE_COMMIT to the literal "unknown", so the CI
|
||||||
|
# GIT_SHA build-arg isn't present on those builds — this stage recovers it.
|
||||||
|
FROM python:${PYTHON_VERSION} AS gitsha
|
||||||
|
WORKDIR /src
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends git \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY .git ./.git
|
||||||
|
RUN git rev-parse --short=12 HEAD > /git_sha 2>/dev/null || echo unknown > /git_sha
|
||||||
|
|
||||||
FROM python:${PYTHON_VERSION} AS builder
|
FROM python:${PYTHON_VERSION} AS builder
|
||||||
|
|
||||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
|
@ -35,6 +46,7 @@ RUN apt-get update \
|
||||||
&& useradd --create-home --shell /bin/sh --uid 1000 app
|
&& useradd --create-home --shell /bin/sh --uid 1000 app
|
||||||
|
|
||||||
COPY --from=builder /opt/venv /opt/venv
|
COPY --from=builder /opt/venv /opt/venv
|
||||||
|
COPY --from=gitsha /git_sha /etc/git_sha
|
||||||
|
|
||||||
WORKDIR /srv/app
|
WORKDIR /srv/app
|
||||||
COPY app/ ./app/
|
COPY app/ ./app/
|
||||||
|
|
|
||||||
147
db/migrations/20260601000022_live_view_low_accuracy_flag.sql
Normal file
147
db/migrations/20260601000022_live_view_low_accuracy_flag.sql
Normal file
|
|
@ -0,0 +1,147 @@
|
||||||
|
-- migrate:up
|
||||||
|
--
|
||||||
|
-- Surface low-accuracy fixes to the dashboard. The projector now refuses to
|
||||||
|
-- let an LBS/WIFI fix overwrite a *fresh* GPS fix, but a vehicle whose only
|
||||||
|
-- recent fix is LBS/WIFI still shows at the (approximate) cell-tower/wifi
|
||||||
|
-- location. Expose a `low_accuracy` boolean per feature so the UI can ring
|
||||||
|
-- those markers and label them "approximate" instead of presenting a possibly
|
||||||
|
-- kilometres-off position as if it were a GPS fix.
|
||||||
|
--
|
||||||
|
-- Identical to the prior definition apart from the single new property.
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION serve.fn_live_view(filters jsonb)
|
||||||
|
RETURNS jsonb
|
||||||
|
LANGUAGE plpgsql STABLE
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
fresh_window interval := COALESCE((filters->>'fresh_window')::interval, interval '24 hours');
|
||||||
|
offline_after interval := COALESCE((filters->>'offline_after')::interval, interval '5 minutes');
|
||||||
|
move_speed_kmh numeric := COALESCE((filters->>'move_speed_kmh')::numeric, 5);
|
||||||
|
p_cost_centre text := filters->>'cost_centre';
|
||||||
|
p_assigned_city text := filters->>'assigned_city';
|
||||||
|
p_vehicle_numbers text[] := CASE
|
||||||
|
WHEN filters ? 'vehicle_numbers'
|
||||||
|
THEN ARRAY(SELECT jsonb_array_elements_text(filters->'vehicle_numbers'))
|
||||||
|
ELSE NULL
|
||||||
|
END;
|
||||||
|
result jsonb;
|
||||||
|
BEGIN
|
||||||
|
WITH candidates AS (
|
||||||
|
SELECT
|
||||||
|
lp.imei, lp.occurred_at, lp.geom, lp.speed_kmh, lp.direction_deg,
|
||||||
|
lp.mc_type, lp.current_mileage_km, lp.gps_signal, lp.satellites,
|
||||||
|
lp.device_name, lp.pos_type,
|
||||||
|
d.device_type, d.activation_at,
|
||||||
|
v.vehicle_id, v.plate, v.cost_centre, v.assigned_city
|
||||||
|
FROM state.live_positions lp
|
||||||
|
JOIN domain.devices d ON d.imei = lp.imei
|
||||||
|
JOIN domain.vehicles v ON v.vehicle_id = d.vehicle_id
|
||||||
|
WHERE d.lifecycle = 'active'
|
||||||
|
AND (p_cost_centre IS NULL OR v.cost_centre = p_cost_centre)
|
||||||
|
AND (p_assigned_city IS NULL OR v.assigned_city = p_assigned_city)
|
||||||
|
AND (p_vehicle_numbers IS NULL OR v.plate = ANY (p_vehicle_numbers))
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT c.*, ROW_NUMBER() OVER (
|
||||||
|
PARTITION BY c.vehicle_id
|
||||||
|
ORDER BY
|
||||||
|
CASE c.device_type WHEN 'tracker' THEN 0 ELSE 1 END,
|
||||||
|
CASE WHEN c.occurred_at > now() - fresh_window THEN 0 ELSE 1 END,
|
||||||
|
c.occurred_at DESC,
|
||||||
|
c.activation_at DESC NULLS LAST
|
||||||
|
) AS rn
|
||||||
|
FROM candidates c
|
||||||
|
),
|
||||||
|
deduped AS (SELECT * FROM ranked WHERE rn = 1),
|
||||||
|
enriched AS (
|
||||||
|
SELECT d.*,
|
||||||
|
CASE
|
||||||
|
WHEN d.occurred_at <= now() - offline_after THEN 'offline'
|
||||||
|
WHEN d.speed_kmh IS NOT NULL AND d.speed_kmh > move_speed_kmh THEN 'moving'
|
||||||
|
ELSE 'parked'
|
||||||
|
END AS operational_state,
|
||||||
|
serve._cost_centre_color(d.cost_centre) AS cost_centre_color,
|
||||||
|
EXTRACT(EPOCH FROM (now() - d.occurred_at))::int AS age_sec,
|
||||||
|
round(ST_Y(d.geom)::numeric, 4) AS lat_rounded,
|
||||||
|
round(ST_X(d.geom)::numeric, 4) AS lng_rounded
|
||||||
|
FROM deduped d
|
||||||
|
),
|
||||||
|
with_addr AS (
|
||||||
|
SELECT e.*, g.address, g.address_short
|
||||||
|
FROM enriched e
|
||||||
|
LEFT JOIN state.geocoded_positions g
|
||||||
|
ON g.lat_rounded = e.lat_rounded
|
||||||
|
AND g.lng_rounded = e.lng_rounded
|
||||||
|
),
|
||||||
|
summary AS (
|
||||||
|
SELECT jsonb_build_object(
|
||||||
|
'total_active', count(*),
|
||||||
|
'moving', count(*) FILTER (WHERE operational_state = 'moving'),
|
||||||
|
'parked', count(*) FILTER (WHERE operational_state = 'parked'),
|
||||||
|
'offline', count(*) FILTER (WHERE operational_state = 'offline'),
|
||||||
|
'below_freshness_slo', count(*) FILTER (
|
||||||
|
WHERE occurred_at <= now() - interval '90 seconds'
|
||||||
|
),
|
||||||
|
'as_of', to_char(now() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||||
|
) AS s
|
||||||
|
FROM with_addr
|
||||||
|
),
|
||||||
|
features AS (
|
||||||
|
SELECT COALESCE(jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'type', 'Feature',
|
||||||
|
'geometry', ST_AsGeoJSON(e.geom)::jsonb,
|
||||||
|
'properties', jsonb_build_object(
|
||||||
|
'vehicle_id', e.vehicle_id,
|
||||||
|
'plate', e.plate,
|
||||||
|
'plate_short', serve._label_short(e.device_name, e.plate),
|
||||||
|
'driver_name', serve._driver_name(e.device_name),
|
||||||
|
'imei', e.imei,
|
||||||
|
'device_type', e.device_type,
|
||||||
|
'device_name', e.device_name,
|
||||||
|
'mc_type', e.mc_type,
|
||||||
|
'pos_type', e.pos_type,
|
||||||
|
'low_accuracy', (e.pos_type IN ('LBS', 'WIFI')),
|
||||||
|
'cost_centre', e.cost_centre,
|
||||||
|
'cost_centre_color', e.cost_centre_color,
|
||||||
|
'assigned_city', e.assigned_city,
|
||||||
|
'address', e.address,
|
||||||
|
'address_short', e.address_short,
|
||||||
|
'occurred_at', to_char(e.occurred_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
|
||||||
|
'age_sec', e.age_sec,
|
||||||
|
'speed_kmh', e.speed_kmh,
|
||||||
|
'heading_deg', e.direction_deg,
|
||||||
|
'gps_signal', e.gps_signal,
|
||||||
|
'satellites', e.satellites,
|
||||||
|
'current_mileage_km', e.current_mileage_km,
|
||||||
|
'operational_state', e.operational_state,
|
||||||
|
'style_class', 'vehicle-' || e.operational_state,
|
||||||
|
'marker_color', CASE WHEN e.operational_state = 'moving'
|
||||||
|
THEN e.cost_centre_color
|
||||||
|
ELSE '#9ca3af' END,
|
||||||
|
'show_arrow', (e.operational_state = 'moving' AND e.direction_deg IS NOT NULL)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
), '[]'::jsonb) AS feats
|
||||||
|
FROM with_addr e
|
||||||
|
),
|
||||||
|
slo_block AS (
|
||||||
|
SELECT COALESCE(jsonb_object_agg(
|
||||||
|
metric,
|
||||||
|
jsonb_build_object('threshold', threshold, 'current', current_value, 'status', status)
|
||||||
|
), '{}'::jsonb) AS ss
|
||||||
|
FROM slo.v_current_status
|
||||||
|
)
|
||||||
|
SELECT jsonb_build_object(
|
||||||
|
'summary', (SELECT s FROM summary),
|
||||||
|
'geojson', jsonb_build_object('type', 'FeatureCollection', 'features', (SELECT feats FROM features)),
|
||||||
|
'slo_status', (SELECT ss FROM slo_block)
|
||||||
|
)
|
||||||
|
INTO result;
|
||||||
|
RETURN result;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- migrate:down
|
||||||
|
|
||||||
|
DROP FUNCTION IF EXISTS serve.fn_live_view(jsonb);
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
|
# Fall back to the git SHA baked into the image at build time when the runtime
|
||||||
|
# env didn't supply a real one. Coolify builds from source and injects
|
||||||
|
# SOURCE_COMMIT="unknown", so without this /health would always report
|
||||||
|
# image_sha="unknown".
|
||||||
|
if [ -z "${APP_GIT_SHA:-}" ] || [ "${APP_GIT_SHA}" = "unknown" ]; then
|
||||||
|
APP_GIT_SHA="$(cat /etc/git_sha 2>/dev/null || echo unknown)"
|
||||||
|
export APP_GIT_SHA
|
||||||
|
fi
|
||||||
|
|
||||||
ROLE="${APP_ROLE:-gateway}"
|
ROLE="${APP_ROLE:-gateway}"
|
||||||
|
|
||||||
case "$ROLE" in
|
case "$ROLE" in
|
||||||
|
|
|
||||||
|
|
@ -197,12 +197,23 @@ export function initMap(elementId, opts = {}) {
|
||||||
['==', ['get', 'operational_state'], 'parked'], 0.75,
|
['==', ['get', 'operational_state'], 'parked'], 0.75,
|
||||||
0.55,
|
0.55,
|
||||||
],
|
],
|
||||||
'circle-stroke-color': '#0b1220',
|
// Low-accuracy (LBS/wifi) fixes get an amber ring so an approximate
|
||||||
|
// position reads as approximate at a glance — the dot keeps its
|
||||||
|
// state/cost-centre colour, only the stroke signals "don't trust the
|
||||||
|
// exact spot". Everything else keeps the dark hairline stroke.
|
||||||
|
'circle-stroke-color': [
|
||||||
|
'case',
|
||||||
|
['==', ['get', 'low_accuracy'], true], '#f59e0b',
|
||||||
|
'#0b1220',
|
||||||
|
],
|
||||||
'circle-stroke-width': [
|
'circle-stroke-width': [
|
||||||
'interpolate', ['linear'], ['zoom'],
|
'case',
|
||||||
5, 0.5,
|
['==', ['get', 'low_accuracy'], true], 2.5,
|
||||||
12, 1.5,
|
['interpolate', ['linear'], ['zoom'],
|
||||||
16, 2,
|
5, 0.5,
|
||||||
|
12, 1.5,
|
||||||
|
16, 2,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -407,6 +418,8 @@ function _popupHtml(props) {
|
||||||
|
|
||||||
const sourceLine = [props.mc_type, props.device_type].filter(Boolean).join(' · ');
|
const sourceLine = [props.mc_type, props.device_type].filter(Boolean).join(' · ');
|
||||||
const ageLine = `last fix ${_formatAge(props.age_sec)} · ${_formatLocal(props.occurred_at)}`;
|
const ageLine = `last fix ${_formatAge(props.age_sec)} · ${_formatLocal(props.occurred_at)}`;
|
||||||
|
const approxLine = props.low_accuracy
|
||||||
|
? `approximate location (${_esc(props.pos_type || 'non-GPS')} fix)` : null;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="popup-card">
|
<div class="popup-card">
|
||||||
|
|
@ -417,6 +430,7 @@ function _popupHtml(props) {
|
||||||
${props.driver_name ? `<div class="popup-driver">${_esc(props.driver_name)}</div>` : ''}
|
${props.driver_name ? `<div class="popup-driver">${_esc(props.driver_name)}</div>` : ''}
|
||||||
${tagLine ? `<div class="popup-meta">${_esc(tagLine)}</div>` : ''}
|
${tagLine ? `<div class="popup-meta">${_esc(tagLine)}</div>` : ''}
|
||||||
${addressLine ? `<div class="popup-address">${_esc(addressLine)}</div>` : ''}
|
${addressLine ? `<div class="popup-address">${_esc(addressLine)}</div>` : ''}
|
||||||
|
${approxLine ? `<div class="popup-row popup-approx" style="color:#f59e0b">${approxLine}</div>` : ''}
|
||||||
${headingLine ? `<div class="popup-row">${_esc(headingLine)}</div>` : ''}
|
${headingLine ? `<div class="popup-row">${_esc(headingLine)}</div>` : ''}
|
||||||
${mileageLine ? `<div class="popup-row">${_esc(mileageLine)}</div>` : ''}
|
${mileageLine ? `<div class="popup-row">${_esc(mileageLine)}</div>` : ''}
|
||||||
<div class="popup-row">${_esc(ageLine)}</div>
|
<div class="popup-row">${_esc(ageLine)}</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue