Blueforce-Linux/web/render.py
emirhanmamak-blueforce 337db15d5b feat(web): add the central fleet panel the operator asked for first
The fleet has ~700 devices and no way to see them: the device console manages one
machine from its own keyboard, and the question "which devices are installed, and
which wave is each in" had no surface at all. This adds it as the first screen.

Python standard library only -- http.server, html.escape, string.Template. No
framework and no third-party import, because the fleet installs offline from a
committed package pool that contains no web framework, so fastapi/uvicorn/flask
would have made the panel uninstallable on the devices it manages. That was
decided with the user over the FastAPI they first picked, and a test asserts it:
the module imports only stdlib plus its own siblings.

What it shows: the dark header with the target and the privilege level; a summary
of counts by state with one loud figure; the device list with a state badge per
row, monospace ids and hostnames so columns line up, non-ready devices sorted
first, and a filter bar; the selected device's detail with its four-channel
verification, since READY is refused until every channel is verified (docs/27).
Nothing is colour-only: every badge carries its Turkish state word.

Data has no live source yet, so a missing inventory file falls back to synthetic
records that the page labels as demo in two places -- the panel never presents
invented devices as real ones.

Two defects found by reviewing a real render, both fixed here with a test that
fails when the bug returns (proven by mutation: each assertion goes red against
the reintroduced bug, green after):
- --state-provisioned resolved to var(--info), and --info is --accent, so a
  KURULDU badge and the selected row were the same blue. The state now resolves
  to its own colour, and the test resolves var() aliases before comparing --
  comparing the raw declarations does not detect this, which is how the first
  version of the test passed against the bug.
- the wave cell printed the bare code and then a label that already contains it,
  so one device read "P2 P2 — PILOT-2". Each wave is named once, now.

Also fixes two entries in docs/27 that had become untrue: it claimed a clone
carries the packages and the installer downloads nothing, which stopped being the
case when the pool moved to Git LFS and bf-bootstrap began cloning without
smudge. The real flow -- light clone, payload from a local mirror or a targeted
`git lfs pull` -- is documented instead.

Tests: tests/test_web_static.py, 112 tests, collected by tests/run-all.sh
(12/12 PASS overall).
2026-09-16 22:39:50 +03:00

717 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Server-rendered pages for the central Blueforce panel (Python stdlib only).
Rendering happens on the server through ``string.Template`` from the standard
library. No template engine is imported: ``jinja2`` is not installable on an
air-gapped fleet (PRODUCT.md, "Stack"). Every page is plain HTML; the palette
lives in the local stylesheets and this module never writes a colour, a webfont
or a URL into the markup -- ``tests/test_web_static.py`` enforces that.
Layout contract (docs/DESIGN.md, FIRST VIEWPORT): the dark header band carries
the product name, the target identity and the privilege level on the right; then
the data-source banner, the summary strip with the one large figure, the fleet
list (a state badge per row, non-ready first, filter bar, selected row in the
accent-soft fill) and the selected device's detail region with the channel
verification list.
Colour is never the only carrier of meaning: every badge carries its Turkish
word, so the page survives greyscale.
"""
from __future__ import annotations
import html
from dataclasses import dataclass
from pathlib import Path
from string import Template
from urllib.parse import urlencode
from typing import Any, Mapping, Sequence
from . import PANEL_NAME, PANEL_SURFACE, PANEL_VERSION
from . import fleet_data
from .fleet_data import (
CHANNELS,
CHANNEL_STATUS_WORDS,
STATE_VOCABULARY,
WAVES,
Device,
FleetFilter,
FleetSnapshot,
Wave,
)
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
STATIC_DIR = Path(__file__).resolve().parent / "static"
FLEET_PATH = "/"
ABOUT_PATH = "/hakkinda"
# Short Turkish note per state, for the quiet part of the summary strip. The
# long meaning lives on the about page and in the badge tooltip.
STATE_SHORT_NOTES: dict[str, str] = {
"KURULDU": "kuruldu, kayıt bekliyor",
"KAYITLI": "kanallar eksik",
"HAZIR": "sevk edilebilir",
"HATA": "bir insan gerekiyor",
"YOK": "hiç görülmedi",
}
# The panel's own rules, shown to the operator instead of being a comment.
PANEL_RULES: tuple[str, ...] = (
"Panel yalnız okuma yapar: hiçbir uç nokta komut çalıştırmaz, dosya yazmaz ve cihazda değişiklik yapmaz.",
"Dış kaynak yok: yazı tipi, CDN veya internet erişimi kullanılmaz; stil dosyaları yereldir (air-gapped filo).",
"Durum uydurulmaz: «HAZIR» iddiası dört kanal doğrulanmadıkça reddedilir ve cihaz HATA olur (docs/27).",
"Kanal denetimi başarısız olduysa cihaz HATA sayılır: cihaz bir insan bekliyor demektir.",
"Panel hiçbir durumu yükseltmez; HAZIR kararı merkezindir (KAYITLI -> HAZIR).",
"Kimliği kurulamayan kayıt sessizce düşürülmez; «Geçersiz kayıtlar» altında nedeniyle görünür (docs/02).",
"Envanter dosyası okunamazsa panel demo veriye düşmez; «VERİ YOK» der ve filo durumunu bilinmiyor olarak bırakır.",
)
_TEMPLATE_CACHE: dict[str, Template] = {}
def template(name: str) -> Template:
"""Load and cache one local template by file name."""
cached = _TEMPLATE_CACHE.get(name)
if cached is None:
cached = Template((TEMPLATES_DIR / name).read_text(encoding="utf-8"))
_TEMPLATE_CACHE[name] = cached
return cached
def render(name: str, **context: Any) -> str:
"""Fill one template. A missing placeholder raises instead of rendering empty."""
return template(name).substitute(**context)
def esc(value: Any) -> str:
"""Escape anything that reaches the markup; inventory text is operator input."""
return html.escape(str(value), quote=True)
# --------------------------------------------------------------------------
# Privilege (docs/DESIGN.md "Privilege")
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class Privilege:
"""The panel's privilege level: a state on screen, not a buried sentence."""
level: str
word: str
reason: str
@property
def is_read_only(self) -> bool:
return self.level == PRIVILEGE_READ_ONLY_LEVEL
PRIVILEGE_READ_ONLY_LEVEL = "read-only"
PRIVILEGE_FULL_LEVEL = "full"
PRIVILEGES: dict[str, Privilege] = {
PRIVILEGE_READ_ONLY_LEVEL: Privilege(
PRIVILEGE_READ_ONLY_LEVEL,
"Salt-okunur",
"Yazma reddedilir: bu panel yalnız okuma yapar (GET, HEAD).",
),
PRIVILEGE_FULL_LEVEL: Privilege(
PRIVILEGE_FULL_LEVEL,
"Tam yetki",
"Yazma yetkisi açık, ancak bu sürümde yazma uç noktası uygulanmadı; hiçbir uç nokta cihazda değişiklik yapmaz.",
),
}
def privilege_from_level(level: str) -> Privilege:
try:
return PRIVILEGES[level]
except KeyError: # pragma: no cover - argparse restricts the value
raise ValueError(f"bilinmeyen yetki düzeyi: {level!r}") from None
@dataclass(frozen=True)
class ServerInfo:
"""Facts about the running panel, for the about/durum page."""
host: str
port: int
privilege: Privilege
python_version: str
started_at: str
data_path: str | None = None
@property
def bind_address(self) -> str:
return f"{self.host}:{self.port}"
# --------------------------------------------------------------------------
# Query (server-side filtering: no JavaScript ships with this panel)
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class PanelQuery:
"""The operator's request: a filter, an optional explicit device."""
criteria: FleetFilter
device: str = ""
warnings: tuple[str, ...] = ()
@classmethod
def parse(cls, params: Mapping[str, Any]) -> "PanelQuery":
criteria, warnings = fleet_data.parse_filter(params)
device = fleet_data.first_param(params, "cihaz") or fleet_data.first_param(params, "device")
return cls(criteria=criteria, device=device, warnings=warnings)
@property
def has_filter(self) -> bool:
return self.criteria.is_active
def _params(self, device: str | None) -> dict[str, str]:
params: dict[str, str] = {}
if self.criteria.query:
params["q"] = self.criteria.query
if self.criteria.state:
params["durum"] = self.criteria.state
if self.criteria.wave:
params["dalga"] = self.criteria.wave
if self.criteria.only_not_ready:
params["hazir_degil"] = "1"
if device:
params["cihaz"] = device
return params
def href(self, path: str = FLEET_PATH, *, device: str | None = None) -> str:
"""Link that keeps the current filter, optionally selecting a device."""
query = urlencode(self._params(device))
return f"{path}?{query}" if query else path
@property
def reload_href(self) -> str:
"""The primary action: re-read the inventory, keep the filter."""
return self.href(FLEET_PATH)
# --------------------------------------------------------------------------
# Fragments
# --------------------------------------------------------------------------
def _route_link(href: str) -> str:
return esc(href)
def _summary(snapshot: FleetSnapshot, shown: Sequence[Device]) -> dict[str, str]:
counts = fleet_data.state_counts(shown)
ready = counts["HAZIR"]
cells = "".join(
render(
"summary_cell.html",
count=str(counts[spec.word]),
word=spec.word,
note=STATE_SHORT_NOTES[spec.word],
)
for spec in STATE_VOCABULARY
)
return {
"lead_figure": str(ready),
"lead_label": "HAZIR — tek sevk edilebilir durum",
"lead_note": (
f"Gösterilen {len(shown)} cihaz içinde: hazır {ready}, hazır değil "
f"{len(shown) - ready}."
),
"summary_cells": cells,
}
def _device_rows(shown: Sequence[Device], selected_id: str, query: PanelQuery) -> str:
rows: list[str] = []
for device in shown:
is_selected = device.device_id == selected_id
rows.append(
render(
"device_row.html",
row_selected_class=" fleet-row--selected" if is_selected else "",
row_selected_attr=' aria-current="true"' if is_selected else "",
state_token_suffix=device.state_token_suffix,
state_meaning=esc(f"{device.state_label}: {device.state_meaning}"),
state_label=esc(device.state_label),
device_id=esc(device.device_id),
hostname=esc(device.hostname),
wave_code=esc(device.wave_code),
wave_label=esc(device.wave_label),
wave_name=esc(device.wave.label),
last_seen_text=esc(device.last_seen_text),
select_href=esc(query.href(FLEET_PATH, device=device.device_id)),
)
)
return "\n".join(rows)
def _channel_rows(device: Device) -> str:
rows: list[str] = []
for check in device.channels:
rows.append(
render(
"channel_row.html",
label=esc(check.label),
tone=check.tone,
status_word=esc(check.status_word),
checked_at_text=esc(check.checked_at_text),
hint=esc(check.hint),
)
)
return "\n".join(rows)
def _detail(
snapshot: FleetSnapshot,
shown: Sequence[Device],
selection: Device | None,
query: PanelQuery,
case: str,
) -> str:
if case == "unknown":
return render(
"detail_unknown.html",
device_id=esc(query.device),
reset_href=esc(FLEET_PATH),
)
if case == "empty" or selection is None:
if snapshot.total == 0:
empty = (
"Envanterde cihaz kaydı yok. Veri kaynağı sayfanın üstünde ve "
"«Hakkında ve durum» sayfasında yazılı."
)
else:
empty = (
"Bu filtreye uyan cihaz yok · uygulanan filtre: "
f"{esc(query.criteria.description)}."
)
return render("detail_empty.html", empty_text=empty)
if snapshot.is_demo:
record_source = "sentetik demo kaydı (gerçek cihaz değil)"
elif snapshot.source_path:
record_source = f"envanter dosyası: {esc(snapshot.source_path)}"
else:
record_source = "envanter dosyası"
selection_note_block = ""
if selection not in shown:
selection_note_block = render(
"selection_note.html",
text=(
"Seçili cihaz geçerli filtreye uymuyor; ayrıntısı yine de "
"gösteriliyor."
),
)
reason_block = ""
if selection.reason:
reason = selection.reason
if selection.claimed_state:
reason += f" Bildirilen durum: {selection.claimed_state}."
reason_block = render("reason_block.html", text=esc(reason))
note_block = ""
if selection.note:
note_block = render("note_block.html", text=esc(selection.note))
notes_block = ""
if selection.notes:
items = "\n".join(
render("notes_item.html", text=esc(note)) for note in selection.notes
)
notes_block = render("notes_list.html", items=items)
return render(
"detail.html",
state_token_suffix=selection.state_token_suffix,
state_meaning=esc(f"{selection.state_label}: {selection.state_meaning}"),
state_label=esc(selection.state_label),
selection_note_block=selection_note_block,
reason_block=reason_block,
note_block=note_block,
notes_block=notes_block,
device_id=esc(selection.device_id),
hostname=esc(selection.hostname),
dealer_id=esc(selection.dealer_id),
wave_code=esc(selection.wave_code),
wave_label=esc(selection.wave_label),
wave_name=esc(selection.wave.label),
wave_target=str(selection.wave.target),
last_seen_text=esc(selection.last_seen_text),
record_source=record_source,
ready_gate_note=esc(selection.ready_gate_note),
channel_rows=_channel_rows(selection),
channel_count_note=(
f"Doğrulanan kanal: {selection.verified_channel_count}/{len(CHANNELS)}. "
"HAZIR yalnız dördü de «doğrulandı» olduğunda verilir (docs/27)."
),
)
def _filter_bar(snapshot: FleetSnapshot, shown: Sequence[Device], query: PanelQuery) -> str:
state_options = [render("option.html", value="", selected="", label="Tüm durumlar")]
for spec in STATE_VOCABULARY:
state_options.append(
render(
"option.html",
value=esc(spec.state),
selected=' selected' if query.criteria.state == spec.state else "",
label=esc(f"{spec.word} — {STATE_SHORT_NOTES[spec.word]}"),
)
)
wave_options = [render("option.html", value="", selected="", label="Tüm dalgalar")]
for wave in WAVES:
wave_options.append(
render(
"option.html",
value=esc(wave.code),
selected=' selected' if query.criteria.wave == wave.code else "",
label=esc(f"{wave.full_label} — hedef {wave.target} cihaz"),
)
)
return render(
"filter_bar.html",
form_action=FLEET_PATH,
query=esc(query.criteria.query),
state_options="\n".join(state_options),
wave_options="\n".join(wave_options),
only_not_ready_checked=' checked' if query.criteria.only_not_ready else "",
reset_href=esc(FLEET_PATH),
shown_count=str(len(shown)),
total_count=str(snapshot.total),
filter_description=esc(query.criteria.description),
)
def _wave_strip(snapshot: FleetSnapshot) -> str:
counts = fleet_data.wave_counts(snapshot.devices)
items = " · ".join(
render(
"wave_item.html",
code=esc(wave.code),
count=str(counts[wave.code]),
target=str(wave.target),
)
for wave in WAVES
)
return render("wave_strip.html", wave_items=items)
def _warnings_block(warnings: Sequence[str]) -> str:
if not warnings:
return ""
items = "\n".join(render("warning_item.html", text=esc(text)) for text in warnings)
return render("warnings.html", warning_items=items)
def _invalid_block(snapshot: FleetSnapshot) -> str:
if not snapshot.invalid:
return ""
items = "\n".join(
render("invalid_item.html", record_ref=esc(record.record_ref), reason=esc(record.reason))
for record in snapshot.invalid
)
return render(
"invalid_block.html",
invalid_count=str(len(snapshot.invalid)),
invalid_items=items,
)
def _empty_note(snapshot: FleetSnapshot, shown: Sequence[Device], query: PanelQuery) -> str:
if shown:
return ""
if snapshot.total == 0:
return render(
"empty_note.html",
text="Envanterde gösterilecek cihaz yok.",
)
return render(
"empty_note.html",
text=f"Bu filtreye uyan cihaz yok · uygulanan filtre: {esc(query.criteria.description)}.",
)
# --------------------------------------------------------------------------
# Selections
# --------------------------------------------------------------------------
def resolve_selection(
snapshot: FleetSnapshot, shown: Sequence[Device], query: PanelQuery
) -> tuple[Device | None, str]:
"""-> (device, case) with case in ``selected`` / ``unknown`` / ``empty``.
No explicit device means the first row of the list is the selection, so the
first viewport is never half empty; an explicit id the inventory does not
know is reported as ``unknown`` rather than invented.
"""
if query.device:
wanted = fleet_data.fold(query.device)
for device in snapshot.devices:
if wanted in (
fleet_data.fold(device.device_id),
fleet_data.fold(device.hostname),
device.dealer_id,
):
return device, "selected"
return None, "unknown"
if shown:
return shown[0], "selected"
return None, "empty"
def target_identity(
snapshot: FleetSnapshot,
shown: Sequence[Device],
selection: Device | None,
query: PanelQuery,
) -> str:
"""The header band's target identity: what this page is about."""
if query.device:
if selection is not None:
return f"{selection.device_id} · {selection.hostname}"
return f"{query.device} (envanterde yok)"
if query.criteria.wave:
wave: Wave = fleet_data.WAVE_BY_CODE[query.criteria.wave]
return f"Dalga {wave.full_label} · hedef {wave.target} cihaz"
if query.criteria.state:
spec = fleet_data.STATE_BY_NAME[query.criteria.state]
return f"Durum {spec.word} · gösterilen {len(shown)} cihaz"
if query.criteria.is_active:
return f"Filtre · gösterilen {len(shown)} / {snapshot.total} cihaz"
return f"Tüm filo · {snapshot.total} cihaz"
# --------------------------------------------------------------------------
# Shell
# --------------------------------------------------------------------------
def _shell(
*,
page_title: str,
content: str,
snapshot: FleetSnapshot,
privilege: Privilege,
server_info: ServerInfo,
target: str,
nav_section: str,
primary_href: str,
) -> str:
footer_source = f"Veri kaynağı: {snapshot.source_label}"
footer_meta = (
f"{PANEL_SURFACE} {PANEL_VERSION} · veri yüklemesi: {snapshot.loaded_at} · "
f"yetki: {privilege.word} · Python {server_info.python_version}"
)
return render(
"base.html",
page_title=esc(page_title),
panel_name=esc(PANEL_NAME),
panel_surface=esc(PANEL_SURFACE),
target_identity=esc(target),
privilege_level=privilege.level,
privilege_word=esc(privilege.word),
privilege_reason=esc(privilege.reason),
primary_action=render("primary_action.html", href=esc(primary_href)),
gated_action=render("gated_action.html", reason=esc(privilege.reason)),
nav_fleet_attr=' aria-current="page"' if nav_section == "fleet" else "",
nav_about_attr=' aria-current="page"' if nav_section == "about" else "",
content=content,
footer_source=esc(footer_source),
footer_meta=esc(footer_meta),
)
# --------------------------------------------------------------------------
# Pages
# --------------------------------------------------------------------------
def render_fleet_page(
snapshot: FleetSnapshot,
params: Mapping[str, Any] | None = None,
*,
privilege: Privilege | None = None,
server_info: ServerInfo | None = None,
) -> str:
"""The first screen: which devices are installed, and in which wave."""
query = PanelQuery.parse(params or {})
shown = fleet_data.sort_devices(
fleet_data.filter_devices(snapshot.devices, query.criteria)
)
selection, case = resolve_selection(snapshot, shown, query)
selected_id = selection.device_id if selection is not None and selection in shown else ""
summary = _summary(snapshot, shown)
content = render(
"fleet.html",
data_banner_tone=snapshot.banner_tone,
data_banner_word=esc(snapshot.banner_word),
data_banner_text=esc(snapshot.banner_text),
warnings_block=_warnings_block(query.warnings),
list_count_text=f"{len(shown)} / {snapshot.total} cihaz",
filter_bar=_filter_bar(snapshot, shown, query),
wave_strip=_wave_strip(snapshot),
invalid_block=_invalid_block(snapshot),
device_rows=_device_rows(shown, selected_id, query),
empty_note=_empty_note(snapshot, shown, query),
device_detail=_detail(snapshot, shown, selection, query, case),
**summary,
)
return _shell(
page_title=f"{PANEL_NAME} — {PANEL_SURFACE} · Filo",
content=content,
snapshot=snapshot,
privilege=privilege or PRIVILEGES[PRIVILEGE_READ_ONLY_LEVEL],
server_info=server_info or _fallback_server_info(privilege),
target=target_identity(snapshot, shown, selection, query),
nav_section="fleet",
primary_href=query.reload_href,
)
def render_about_page(
snapshot: FleetSnapshot,
*,
privilege: Privilege | None = None,
server_info: ServerInfo | None = None,
) -> str:
"""The panel's own facts: version, data source and the rules it applies."""
privilege = privilege or PRIVILEGES[PRIVILEGE_READ_ONLY_LEVEL]
server_info = server_info or _fallback_server_info(privilege)
vocabulary_rows = "\n".join(
render(
"about_vocabulary_row.html",
state_token_suffix=spec.token_suffix,
word=esc(spec.word),
token=esc(spec.token),
meaning=esc(spec.meaning),
)
for spec in STATE_VOCABULARY
)
counts = fleet_data.wave_counts(snapshot.devices)
wave_rows = "\n".join(
render(
"about_wave_row.html",
code=esc(wave.code),
label=esc(wave.label),
target=str(wave.target),
in_panel=str(counts[wave.code]),
)
for wave in WAVES
)
channel_rows = "\n".join(
render(
"about_channel_row.html",
label=esc(spec.label),
key=esc(spec.key),
hint=esc(spec.hint),
)
for spec in CHANNELS
)
rule_items = "\n".join(render("rule_item.html", text=esc(rule)) for rule in PANEL_RULES)
content = render(
"about.html",
panel_name=esc(PANEL_NAME),
panel_surface=esc(PANEL_SURFACE),
panel_version=esc(PANEL_VERSION),
python_version=esc(server_info.python_version),
bind_address=esc(server_info.bind_address),
privilege_level=privilege.level,
privilege_word=esc(privilege.word),
privilege_reason=esc(privilege.reason),
started_at=esc(server_info.started_at),
data_banner_tone=snapshot.banner_tone,
data_banner_word=esc(snapshot.banner_word),
data_banner_text=esc(snapshot.banner_text),
source_kind_word=esc(snapshot.source_kind_word),
source_path_text=esc(snapshot.source_path or "kullanılmadı (demo veri)"),
schema_version_text=esc(str(snapshot.schema_version) if snapshot.schema_version else "yok"),
generated_at_text=esc(snapshot.generated_at or "bildirilmemiş"),
loaded_at=esc(snapshot.loaded_at),
total_text=str(snapshot.total),
ready_text=f"{snapshot.ready_count} hazır · {snapshot.not_ready_count} hazır değil",
invalid_count_text=str(len(snapshot.invalid)),
error_text=esc(snapshot.error or "yok"),
vocabulary_rows=vocabulary_rows,
wave_rows=wave_rows,
channel_rows=channel_rows,
rule_items=rule_items,
)
return _shell(
page_title=f"{PANEL_NAME} — {PANEL_SURFACE} · Hakkında ve durum",
content=content,
snapshot=snapshot,
privilege=privilege,
server_info=server_info,
target=f"Panel · sürüm {PANEL_VERSION}",
nav_section="about",
primary_href=ABOUT_PATH,
)
def render_refused_page(
method: str,
*,
snapshot: FleetSnapshot,
privilege: Privilege,
server_info: ServerInfo,
) -> str:
content = render(
"refused_body.html",
method=esc(method),
privilege_word=esc(privilege.word),
)
return _shell(
page_title=f"{PANEL_NAME} — Yöntem reddedildi",
content=content,
snapshot=snapshot,
privilege=privilege,
server_info=server_info,
target=f"Reddedilen istek · {method}",
nav_section="",
primary_href=FLEET_PATH,
)
def render_not_found_page(
request_path: str,
*,
snapshot: FleetSnapshot,
privilege: Privilege,
server_info: ServerInfo,
) -> str:
content = render("not_found_body.html", request_path=esc(request_path))
return _shell(
page_title=f"{PANEL_NAME} — Sayfa yok",
content=content,
snapshot=snapshot,
privilege=privilege,
server_info=server_info,
target=f"Tanımsız adres · {request_path}",
nav_section="",
primary_href=FLEET_PATH,
)
def _fallback_server_info(privilege: Privilege | None) -> ServerInfo:
"""Used when a page is rendered outside the server (tests, --check)."""
import sys
level = privilege.level if privilege is not None else PRIVILEGE_READ_ONLY_LEVEL
return ServerInfo(
host="127.0.0.1",
port=0,
privilege=PRIVILEGES[level],
python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
started_at=fleet_data.timestamp_now(),
)