Blueforce-Linux/web/fleet_data.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

1083 lines
36 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.

"""Fleet inventory model for the central Blueforce panel (Python stdlib only).
Read-only by construction: this module reads one inventory JSON document (or
falls back to clearly labelled synthetic demo data), turns it into the device
records the panel renders, and does nothing else. It never writes to disk, never
opens a network connection and never invents a state.
Product rules this module enforces, because they are the fleet's rules
(docs/02 §9, docs/18 §9, docs/27 §9):
* one identity per dealer number, derived, never guessed: ``BF-<no>`` for the
human surface and ``bf-<no>`` for the hostname, ``^[0-9]{8}$``;
* the state machine is ``PROVISIONED_OFFLINE -> ENROLLED -> READY`` with
``FAILED`` and ``ABSENT`` outside the sequence, and the panel never promotes a
device -- a stage claim the centre has not made stays unmade here;
* ``READY`` is refused while any of the four channels is unverified: such a
record is surfaced as ``HATA`` with the reason, not as a claim the fleet
cannot back (docs/27);
* a channel check that failed means the device needs a person, so the device is
``HATA``;
* a record that cannot be identified is never dropped silently -- it leaves an
``InvalidRecord`` that the panel shows.
Vocabulary, Turkish words and colour tokens are taken from docs/DESIGN.md;
tests/test_web_static.py asserts this module still matches that document, so the
two operator surfaces cannot drift apart.
"""
from __future__ import annotations
import json
import os
import re
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Iterable, Mapping, Sequence
# --------------------------------------------------------------------------
# Identity (docs/02)
# --------------------------------------------------------------------------
DEALER_PATTERN = re.compile(r"^[0-9]{8}$")
DEVICE_ID_PREFIX = "BF-"
HOSTNAME_PREFIX = "bf-"
# --------------------------------------------------------------------------
# Status vocabulary (docs/DESIGN.md "Status vocabulary") -- one word per state,
# Turkish, used identically by the device console and this panel.
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class StateSpec:
"""One state of the fleet vocabulary, as docs/DESIGN.md defines it."""
state: str
"""Machine name used by the state machine: PROVISIONED_OFFLINE, ENROLLED..."""
word: str
"""The Turkish word an operator reads. Both surfaces show this word."""
token: str
"""Colour token in web/static/tokens.css. Never a literal colour here."""
soft_token: str
"""Soft (fill) form of the same colour, for badge backgrounds."""
meaning: str
"""Turkish one-liner: what the state means to the operator."""
@property
def token_suffix(self) -> str:
"""``--state-provisioned`` -> ``provisioned`` (used in CSS class names)."""
return self.token[len("--state-"):]
STATE_VOCABULARY: tuple[StateSpec, ...] = (
StateSpec(
"PROVISIONED_OFFLINE",
"KURULDU",
"--state-provisioned",
"--info-soft",
"Çevrimdışı kuruldu; merkeze kayıtlı değil.",
),
StateSpec(
"ENROLLED",
"KAYITLI",
"--state-enrolled",
"--warn-soft",
"Kayıtlı; dört kanalın hepsi doğrulanmadı.",
),
StateSpec(
"READY",
"HAZIR",
"--state-ready",
"--ok-soft",
"Dört kanal da doğrulandı; tek sevk edilebilir durum.",
),
StateSpec(
"FAILED",
"HATA",
"--state-failed",
"--danger-soft",
"Bir denetim başarısız; cihaz bir insan bekliyor.",
),
StateSpec(
"ABSENT",
"YOK",
"--state-absent",
"--idle-soft",
"Erişilemiyor ya da hiç görülmedi.",
),
)
STATE_BY_NAME: dict[str, StateSpec] = {spec.state: spec for spec in STATE_VOCABULARY}
#: Lookup by the Turkish word, for the surfaces that read a word back.
STATE_BY_WORD: dict[str, StateSpec] = {spec.word: spec for spec in STATE_VOCABULARY}
# Sorted so that non-ready devices surface first (docs/DESIGN.md: "the fleet
# table sorts non-ready first, so the list is readable in greyscale"). FAILED
# and ABSENT need a person; READY -- the only shippable state -- is last.
STATE_ORDER: tuple[str, ...] = (
"FAILED",
"ABSENT",
"PROVISIONED_OFFLINE",
"ENROLLED",
"READY",
)
# --------------------------------------------------------------------------
# Rollout waves (docs/18 §9)
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class Wave:
"""One rollout wave: ``LAB(2) -> P1(5) -> P2(20) -> W1(50) -> W2(100) -> PROD``."""
code: str
label: str
target: int
order: int
aliases: tuple[str, ...] = ()
@property
def full_label(self) -> str:
return f"{self.code} — {self.label}" if self.label != self.code else self.code
WAVES: tuple[Wave, ...] = (
Wave("LAB", "LAB", 2, 0, ("lab",)),
Wave("P1", "PILOT-1", 5, 1, ("pilot_1", "pilot-1", "pilot1")),
Wave("P2", "PILOT-2", 20, 2, ("pilot_2", "pilot-2", "pilot2")),
Wave("W1", "WAVE-1", 50, 3, ("wave_1", "wave-1", "wave1")),
Wave("W2", "WAVE-2", 100, 4, ("wave_2", "wave-2", "wave2")),
Wave("PROD", "PROD", 523, 5, ("production", "prod")),
)
WAVE_BY_CODE: dict[str, Wave] = {wave.code: wave for wave in WAVES}
# --------------------------------------------------------------------------
# Channels (docs/27: READY requires every channel verified)
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class ChannelSpec:
"""One of the four remote-access channels every device must verify."""
key: str
label: str
hint: str
CHANNELS: tuple[ChannelSpec, ...] = (
ChannelSpec("ssh_over_wg", "SSH-over-WG", "WireGuard üzerinden SSH (K-13)"),
ChannelSpec("xrdp", "xRDP", "GNOME masaüstü oturumu (K-22)"),
ChannelSpec("rustdesk", "RustDesk", "RustDesk OSS istemcisi"),
ChannelSpec("meshcentral", "MeshCentral", "MeshCentral ajanı"),
)
CHANNEL_STATUSES: tuple[str, ...] = ("verified", "pending", "failed", "unknown", "missing")
# Turkish word per channel status. The panel never shows a bare colour: the word
# is what carries the meaning (docs/DESIGN.md rule 2).
CHANNEL_STATUS_WORDS: dict[str, str] = {
"verified": "doğrulandı",
"pending": "bekliyor",
"failed": "başarısız",
"unknown": "bilinmiyor",
"missing": "yok",
}
# Severity class for the local stylesheet. Tokens only, no colours in Python.
CHANNEL_STATUS_TONES: dict[str, str] = {
"verified": "ok",
"pending": "warn",
"failed": "danger",
"unknown": "idle",
"missing": "idle",
}
# --------------------------------------------------------------------------
# Document contract
# --------------------------------------------------------------------------
SUPPORTED_SCHEMA_VERSION = 1
DATA_PATH_ENV = "BF_FLEET_DATA"
DEFAULT_DATA_PATH = Path(__file__).resolve().parent / "data" / "fleet.json"
DEMO_SOURCE_LABEL = "sentetik demo veri (gerçek filo envanteri bağlı değil)"
# Synthetic records for a first look at the panel without a live fleet. They go
# through the same parse path as a real document, and the UI labels every page
# that renders them -- demo data must never be mistaken for fleet state.
DEMO_DEVICES: tuple[Mapping[str, Any], ...] = (
{
"dealer_id": "12010101",
"wave": "LAB",
"state": "READY",
"channels": {
"ssh_over_wg": {"status": "verified", "checked_at": "2026-09-15T09:10:00+03:00"},
"xrdp": {"status": "verified", "checked_at": "2026-09-15T09:12:00+03:00"},
"rustdesk": {"status": "verified", "checked_at": "2026-09-15T09:15:00+03:00"},
"meshcentral": {"status": "verified", "checked_at": "2026-09-15T09:18:00+03:00"},
},
"last_seen": "2026-09-16T08:40:00+03:00",
},
{
"dealer_id": "12010102",
"wave": "LAB",
"state": "ENROLLED",
"channels": {
"ssh_over_wg": {"status": "verified", "checked_at": "2026-09-16T08:02:00+03:00"},
"xrdp": {"status": "pending"},
"rustdesk": {"status": "verified", "checked_at": "2026-09-16T08:05:00+03:00"},
"meshcentral": {"status": "pending"},
},
"last_seen": "2026-09-16T08:41:00+03:00",
},
{
"dealer_id": "12010193",
"wave": "pilot_1",
"state": "READY",
"channels": {
"ssh_over_wg": {"status": "verified", "checked_at": "2026-09-14T10:00:00+03:00"},
"xrdp": {"status": "verified", "checked_at": "2026-09-14T10:02:00+03:00"},
"rustdesk": {"status": "verified", "checked_at": "2026-09-14T10:04:00+03:00"},
"meshcentral": {"status": "verified", "checked_at": "2026-09-14T10:06:00+03:00"},
},
"last_seen": "2026-09-16T09:05:00+03:00",
},
{
"dealer_id": "12010194",
"wave": "P1",
"state": "ENROLLED",
"channels": {
"ssh_over_wg": {"status": "verified", "checked_at": "2026-09-16T07:30:00+03:00"},
"xrdp": {"status": "verified", "checked_at": "2026-09-16T07:32:00+03:00"},
"rustdesk": {"status": "pending"},
"meshcentral": {"status": "verified", "checked_at": "2026-09-16T07:35:00+03:00"},
},
"last_seen": "2026-09-16T09:04:00+03:00",
},
{
"dealer_id": "12010195",
"wave": "P1",
"state": "PROVISIONED_OFFLINE",
"channels": {},
"last_seen": "2026-09-13T16:20:00+03:00",
},
{
"dealer_id": "12010204",
"wave": "P2",
"state": "PROVISIONED_OFFLINE",
"channels": {},
"last_seen": "2026-09-12T11:00:00+03:00",
},
{
"dealer_id": "12010205",
"wave": "P2",
"state": "ENROLLED",
"channels": {
"ssh_over_wg": {"status": "verified", "checked_at": "2026-09-16T06:10:00+03:00"},
"xrdp": {"status": "failed", "checked_at": "2026-09-16T06:12:00+03:00"},
"rustdesk": {"status": "verified", "checked_at": "2026-09-16T06:14:00+03:00"},
"meshcentral": {"status": "unknown"},
},
"last_seen": "2026-09-16T06:20:00+03:00",
},
{
"dealer_id": "12010206",
"wave": "P2",
"state": "PROVISIONED_OFFLINE",
"channels": {},
"last_seen": "2026-09-11T14:45:00+03:00",
},
{
"dealer_id": "12010207",
"wave": "W1",
"state": "PROVISIONED_OFFLINE",
"channels": {},
"last_seen": "2026-09-10T10:05:00+03:00",
},
{
"dealer_id": "12010208",
"wave": "W1",
"state": "ABSENT",
"channels": {},
"last_seen": None,
"note": "Envanterde var, merkez hiç görmedi.",
},
{
"dealer_id": "12010209",
"wave": "W2",
"state": "ABSENT",
"channels": {},
"last_seen": None,
},
{
"dealer_id": "12010210",
"wave": "PROD",
"state": "PROVISIONED_OFFLINE",
"channels": {},
"last_seen": "2026-09-09T09:00:00+03:00",
},
{
"dealer_id": "12010211",
"wave": "W2",
"state": "READY",
"channels": {
"ssh_over_wg": {"status": "verified", "checked_at": "2026-09-15T13:00:00+03:00"},
"xrdp": {"status": "verified", "checked_at": "2026-09-15T13:02:00+03:00"},
"rustdesk": {"status": "verified", "checked_at": "2026-09-15T13:04:00+03:00"},
"meshcentral": {"status": "verified", "checked_at": "2026-09-15T13:06:00+03:00"},
},
"last_seen": "2026-09-16T09:01:00+03:00",
},
)
# --------------------------------------------------------------------------
# Records
# --------------------------------------------------------------------------
@dataclass(frozen=True)
class ChannelCheck:
"""Verification state of one channel on one device."""
key: str
label: str
hint: str
status: str
checked_at: str | None = None
@property
def status_word(self) -> str:
return CHANNEL_STATUS_WORDS[self.status]
@property
def tone(self) -> str:
return CHANNEL_STATUS_TONES[self.status]
@property
def is_verified(self) -> bool:
return self.status == "verified"
@property
def checked_at_text(self) -> str:
"""Never a bare ``None`` on screen: an operator reads Turkish words."""
return self.checked_at or "kayıt yok"
@property
def status_word_title(self) -> str:
return f"{self.label}: {self.status_word}"
@dataclass(frozen=True)
class InvalidRecord:
"""A record that could not be identified. Shown, never silently dropped."""
record_ref: str
reason: str
@dataclass(frozen=True)
class Device:
"""One fleet device, as the panel describes it."""
device_id: str
hostname: str
dealer_id: str
wave_code: str
state: str
channels: tuple[ChannelCheck, ...] = ()
last_seen: str | None = None
note: str | None = None
claimed_state: str | None = None
reason: str | None = None
notes: tuple[str, ...] = field(default=())
@property
def spec(self) -> StateSpec:
return STATE_BY_NAME[self.state]
@property
def state_label(self) -> str:
return self.spec.word
@property
def state_meaning(self) -> str:
return self.spec.meaning
@property
def state_token_suffix(self) -> str:
return self.spec.token_suffix
@property
def wave(self) -> Wave:
return WAVE_BY_CODE[self.wave_code]
@property
def wave_label(self) -> str:
return self.wave.full_label
@property
def is_ready(self) -> bool:
return self.state == "READY"
@property
def unverified_channels(self) -> tuple[str, ...]:
return tuple(check.label for check in self.channels if not check.is_verified)
@property
def verified_channel_count(self) -> int:
return sum(1 for check in self.channels if check.is_verified)
@property
def last_seen_text(self) -> str:
return self.last_seen or "kayıt yok"
@property
def ready_gate_note(self) -> str:
"""The Turkish sentence that explains the READY gate for this device."""
if self.is_ready:
return "Bu cihaz HAZIR: dört kanalın dördü de doğrulandı (docs/27)."
unverified = self.unverified_channels
if not unverified:
return (
"Dört kanal da doğrulandı, ancak merkez kaydı HAZIR demiyor. "
"Panel durumu yükseltmez (docs/27)."
)
return (
"HAZIR için eksik kanallar: " + ", ".join(unverified) +
". READY yalnız dördü de doğrulandığında verilir (docs/27)."
)
@dataclass(frozen=True)
class FleetSnapshot:
"""Everything the panel knows: devices, refused records and the source."""
devices: tuple[Device, ...]
invalid: tuple[InvalidRecord, ...]
source_kind: str # "file" | "demo" | "error"
source_path: str | None
source_label: str
error: str | None
loaded_at: str
generated_at: str | None = None
schema_version: int | None = None
@property
def is_demo(self) -> bool:
return self.source_kind == "demo"
@property
def is_file(self) -> bool:
return self.source_kind == "file"
@property
def is_error(self) -> bool:
return self.source_kind == "error"
@property
def total(self) -> int:
return len(self.devices)
@property
def counts(self) -> dict[str, int]:
"""Count of devices per Turkish state word, zero filled, word order kept."""
counted = {spec.word: 0 for spec in STATE_VOCABULARY}
for device in self.devices:
counted[device.state_label] += 1
return counted
@property
def ready_count(self) -> int:
return self.counts["HAZIR"]
@property
def not_ready_count(self) -> int:
return self.total - self.ready_count
@property
def wave_counts(self) -> dict[str, int]:
counted = {wave.code: 0 for wave in WAVES}
for device in self.devices:
counted[device.wave_code] += 1
return counted
@property
def source_kind_word(self) -> str:
return {
"file": "envanter dosyası",
"demo": "sentetik demo veri",
"error": "okunamadı",
}[self.source_kind]
@property
def banner_word(self) -> str:
"""One Turkish word that names what the operator is looking at."""
return {"file": "ENVANTER", "demo": "DEMO VERİ", "error": "VERİ YOK"}[self.source_kind]
@property
def banner_tone(self) -> str:
return {"file": "info", "demo": "warn", "error": "danger"}[self.source_kind]
@property
def banner_text(self) -> str:
"""The sentence under the word: what the data is, and what it is not."""
if self.is_demo:
return (
f"Gerçek filo envanteri bağlı değil: aşağıdaki {self.total} kayıt "
"sentetiktir ve hiçbir cihazı temsil etmez. Gerçek veri için paneli "
"«--data <envanter.json>» ile başlatın."
)
if self.is_file:
generated = self.generated_at or "bildirilmemiş"
return (
f"Kaynak: {self.source_path} · şema sürümü {self.schema_version} · "
f"dosya üretimi: {generated} · yükleme: {self.loaded_at}"
)
return f"{self.error} Demo veriye düşülmedi; filo durumu bilinmiyor."
@dataclass(frozen=True)
class FleetFilter:
"""What the operator narrowed the list to."""
query: str = ""
state: str = "" # machine state name, "" means every state
wave: str = "" # wave code, "" means every wave
only_not_ready: bool = False
@property
def is_active(self) -> bool:
return bool(self.query or self.state or self.wave or self.only_not_ready)
def matches(self, device: Device) -> bool:
if self.state and device.state != self.state:
return False
if self.wave and device.wave_code != self.wave:
return False
if self.only_not_ready and device.is_ready:
return False
if self.query:
needle = fold(self.query)
haystack = " ".join((device.device_id, device.hostname, device.dealer_id))
if needle not in fold(haystack):
return False
return True
@property
def description(self) -> str:
"""Turkish summary of the filter, for the header band and the empty note."""
parts: list[str] = []
if self.query:
parts.append(f"metin «{self.query}»")
if self.state:
parts.append(f"durum {STATE_BY_NAME[self.state].word}")
if self.wave:
parts.append(f"dalga {self.wave}")
if self.only_not_ready:
parts.append("yalnız hazır olmayanlar")
return " · ".join(parts) if parts else "filtre yok"
# --------------------------------------------------------------------------
# Text helpers (Turkish aware: the vocabulary contains ı/İ/ş/ğ/ç/ö/ü)
# --------------------------------------------------------------------------
_FOLD_TABLE = str.maketrans(
{
"ı": "i", "İ": "i", "I": "i", "ş": "s", "Ş": "s", "ğ": "g", "Ğ": "g",
"ç": "c", "Ç": "c", "ö": "o", "Ö": "o", "ü": "u", "Ü": "u",
}
)
def fold(text: str) -> str:
"""Case- and accent-insensitive key for operator input (Turkish included).
``HAZIR``, ``Hazır`` and ``hazir`` must all mean the same filter value, and
``str.lower()`` alone does not get there: ``"HAZIR".lower()`` is ``hazir``
while ``"hazır".lower()`` is ``hazır``.
"""
return text.translate(_FOLD_TABLE).lower()
def _text(value: Any) -> str | None:
"""Non-empty string, or ``None`` -- the panel never shows a bare ``None``."""
if isinstance(value, str):
stripped = value.strip()
return stripped or None
if isinstance(value, bool):
return None
if isinstance(value, (int, float)):
return str(value)
return None
def first_param(params: Mapping[str, Any], key: str) -> str:
"""First value of a query parameter, in both ``str`` and ``list`` shapes."""
value = params.get(key)
if value is None:
return ""
if isinstance(value, (list, tuple)):
value = value[0] if value else ""
return _text(str(value)) or ""
def timestamp_now() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
def _alias_index(items: Iterable[tuple[str, str]]) -> dict[str, str]:
"""Build a folded lookup table, complaining loudly about collisions."""
table: dict[str, str] = {}
for key, value in items:
folded = fold(key)
existing = table.get(folded)
if existing is not None and existing != value:
raise ValueError(f"alias collision: {key!r} -> {value!r} / {existing!r}")
table[folded] = value
return table
STATE_ALIASES: dict[str, str] = _alias_index(
[(spec.state, spec.state) for spec in STATE_VOCABULARY]
+ [(spec.word, spec.state) for spec in STATE_VOCABULARY]
+ [(spec.token_suffix, spec.state) for spec in STATE_VOCABULARY]
)
WAVE_ALIASES: dict[str, str] = _alias_index(
[(wave.code, wave.code) for wave in WAVES]
+ [(alias, wave.code) for wave in WAVES for alias in wave.aliases]
)
CHANNEL_ALIASES: dict[str, str] = _alias_index(
[(spec.key, spec.key) for spec in CHANNELS]
+ [(spec.label, spec.key) for spec in CHANNELS]
+ [
("ssh", "ssh_over_wg"),
("ssh-over-wg", "ssh_over_wg"),
("ssh_over_wireguard", "ssh_over_wg"),
("wireguard-ssh", "ssh_over_wg"),
("rdp", "xrdp"),
("rust_desk", "rustdesk"),
("mesh", "meshcentral"),
]
)
CHANNEL_STATUS_ALIASES: dict[str, str] = _alias_index(
[(status, status) for status in CHANNEL_STATUSES]
+ [(word, status) for status, word in CHANNEL_STATUS_WORDS.items()]
)
# --------------------------------------------------------------------------
# Parsing
# --------------------------------------------------------------------------
def _record_ref(record: Any, index: int) -> str:
if isinstance(record, Mapping):
for key in ("device_id", "dealer_id", "bayi_no", "hostname"):
value = _text(record.get(key))
if value:
return value
return f"kayıt {index + 1}"
def _parse_channel(raw: Any) -> tuple[str, str | None, str | None]:
"""-> (status, checked_at, advisory). Unknown shapes never crash the panel."""
if raw is None:
return "missing", None, None
if isinstance(raw, str):
claimed, checked_at = raw, None
elif isinstance(raw, Mapping):
claimed = _text(raw.get("status")) or "unknown"
checked_at = _text(raw.get("checked_at")) or _text(raw.get("checked"))
else:
return "unknown", None, "Kanal kaydı okunamadı: nesne ya da metin bekleniyor."
status = CHANNEL_STATUS_ALIASES.get(fold(claimed))
if status is None:
return "unknown", checked_at, f"Bilinmeyen kanal durumu yok sayıldı: «{claimed}»."
return status, checked_at, None
def parse_record(record: Any, index: int = 0) -> tuple[Device | None, InvalidRecord | None]:
"""Turn one raw inventory record into a ``Device`` or a refused record.
A record is refused only when it cannot be identified at all; anything the
panel can name it shows, with the reason it could not be trusted attached.
"""
ref = _record_ref(record, index)
if not isinstance(record, Mapping):
return None, InvalidRecord(ref, "Kayıt nesne değil; JSON nesnesi bekleniyor.")
dealer_id = _text(record.get("dealer_id")) or _text(record.get("dealer")) \
or _text(record.get("bayi_no"))
if not dealer_id:
return None, InvalidRecord(ref, "Bayi no yok; cihaz kimliği türetilemez (docs/02 §9).")
if not DEALER_PATTERN.match(dealer_id):
return None, InvalidRecord(
ref,
f"Bayi no 8 hane değil: «{dealer_id}» (kural ^[0-9]{{8}}$).",
)
device_id = DEVICE_ID_PREFIX + dealer_id
hostname = HOSTNAME_PREFIX + dealer_id
declared_id = _text(record.get("device_id"))
if declared_id and declared_id != device_id:
return None, InvalidRecord(
ref, f"Cihaz kimliği kurala uymuyor: «{declared_id}» yerine «{device_id}» olmalı (docs/02)."
)
declared_host = _text(record.get("hostname"))
if declared_host and declared_host != hostname:
return None, InvalidRecord(
ref, f"Hostname kurala uymuyor: «{declared_host}» yerine «{hostname}» olmalı (docs/02)."
)
wave_raw = _text(record.get("wave")) or _text(record.get("dalga"))
if not wave_raw:
return None, InvalidRecord(ref, "Dalga yok; cihaz bir yayılım dalgasına bağlanamaz (docs/18).")
wave_code = WAVE_ALIASES.get(fold(wave_raw))
if wave_code is None:
return None, InvalidRecord(
ref, f"Bilinmeyen dalga: «{wave_raw}» (LAB, P1, P2, W1, W2, PROD)."
)
notes: list[str] = []
state_raw = _text(record.get("state")) or _text(record.get("durum"))
claimed_state: str | None = None
reason: str | None = None
if state_raw is None:
state = "FAILED"
reason = (
"Durum alanı yok. Panel durum uydurmaz; cihaz HATA olarak işaretlendi "
"(docs/27)."
)
else:
resolved = STATE_ALIASES.get(fold(state_raw))
if resolved is None:
state = "FAILED"
claimed_state = state_raw
reason = (
f"Durum alanı tanınmıyor: «{state_raw}». Panel durum uydurmaz; cihaz "
"HATA olarak işaretlendi (docs/27)."
)
else:
state = resolved
raw_channels = record.get("channels")
statuses: dict[str, tuple[str, str | None]] = {}
if raw_channels is None:
notes.append("Kanallar bildirilmemiş; dört kanal «yok» sayıldı (docs/27).")
elif not isinstance(raw_channels, Mapping):
notes.append("«channels» alanı nesne değil; dört kanal «yok» sayıldı (docs/27).")
else:
for raw_key, raw_value in raw_channels.items():
key = CHANNEL_ALIASES.get(fold(str(raw_key)))
if key is None:
notes.append(f"Bilinmeyen kanal anahtarı yok sayıldı: «{raw_key}».")
continue
status, checked_at, advisory = _parse_channel(raw_value)
statuses[key] = (status, checked_at)
if advisory:
notes.append(advisory)
checks: list[ChannelCheck] = []
for spec in CHANNELS:
status, checked_at = statuses.get(spec.key, ("missing", None))
checks.append(ChannelCheck(spec.key, spec.label, spec.hint, status, checked_at))
# READY gate (docs/27 §9.4): the claim is refused while a channel is
# unverified. The panel does not downgrade the word to a warning -- a claim
# the fleet cannot back is a failure and needs a person.
if state == "READY":
blocked = [check.label for check in checks if not check.is_verified]
if blocked:
claimed_state = "READY"
state = "FAILED"
reason = (
"HAZIR iddiası reddedildi: " + ", ".join(blocked) +
" doğrulanmadı. READY yalnız dört kanalın hepsi doğrulandığında "
"verilir (docs/27)."
)
# A channel check that failed means the device needs a person.
failed = [check.label for check in checks if check.status == "failed"]
if failed and state not in ("FAILED",):
reason = (
"Kanal doğrulaması başarısız: " + ", ".join(failed) +
". Cihaz bir insan bekliyor (docs/27)."
)
state = "FAILED"
# The panel never promotes: a record may lag behind the centre, never lead it.
if state in ("PROVISIONED_OFFLINE", "ENROLLED") and all(
check.is_verified for check in checks
):
notes.append(
"Dört kanal da doğrulandı, ancak merkez kaydı daha ileri bir durum "
"demiyor; panel durumu yükseltmez (docs/27)."
)
return (
Device(
device_id=device_id,
hostname=hostname,
dealer_id=dealer_id,
wave_code=wave_code,
state=state,
channels=tuple(checks),
last_seen=_text(record.get("last_seen")) or _text(record.get("son_gorulme")),
note=_text(record.get("note")),
claimed_state=claimed_state,
reason=reason,
notes=tuple(notes),
),
None,
)
def parse_records(records: Sequence[Any]) -> tuple[tuple[Device, ...], tuple[InvalidRecord, ...]]:
"""Parse a whole document body; duplicate dealer numbers are refused (docs/02)."""
devices: list[Device] = []
invalid: list[InvalidRecord] = []
seen: dict[str, str] = {}
for index, record in enumerate(records):
device, refused = parse_record(record, index)
if refused is not None:
invalid.append(refused)
continue
assert device is not None
if device.dealer_id in seen:
invalid.append(
InvalidRecord(
device.device_id,
"Yinelenen bayi no; ikinci kayıt reddedildi (docs/02 §8).",
)
)
continue
seen[device.dealer_id] = device.device_id
devices.append(device)
return tuple(devices), tuple(invalid)
# --------------------------------------------------------------------------
# Loading
# --------------------------------------------------------------------------
def _error_snapshot(path: Path | None, error: str, stamp: str) -> FleetSnapshot:
return FleetSnapshot(
devices=(),
invalid=(),
source_kind="error",
source_path=str(path) if path is not None else None,
source_label=f"veri okunamadı: {path}" if path is not None else "veri okunamadı",
error=error,
loaded_at=stamp,
)
def demo_snapshot(stamp: str | None = None) -> FleetSnapshot:
"""Labelled synthetic data, for a first look with no fleet attached."""
devices, invalid = parse_records(DEMO_DEVICES)
return FleetSnapshot(
devices=devices,
invalid=invalid,
source_kind="demo",
source_path=None,
source_label=DEMO_SOURCE_LABEL,
error=None,
loaded_at=stamp or timestamp_now(),
generated_at=None,
schema_version=SUPPORTED_SCHEMA_VERSION,
)
def load_fleet(
path: str | Path | None = None,
*,
env: Mapping[str, str] | None = None,
now: str | None = None,
default_path: str | Path | None = None,
) -> FleetSnapshot:
"""Read one inventory document into a ``FleetSnapshot``.
Fall-back rules, deliberately different for the two cases:
* no document requested and the default location is empty -> labelled demo
data, so a first look is possible without a fleet;
* a document *was* requested (``path`` or ``BF_FLEET_DATA``) but cannot be
read, is not JSON, or breaks the schema -> an error snapshot. A broken
feed never becomes demo data and never becomes an empty fleet.
``env``, ``now`` and ``default_path`` exist so a test can pin the source and
the clock; the panel itself passes none of them.
"""
environment = os.environ if env is None else env
stamp = now or timestamp_now()
requested: str | None = None
if path is not None:
requested = str(path)
else:
from_env = _text(environment.get(DATA_PATH_ENV))
if from_env:
requested = from_env
explicit = requested is not None
target = Path(requested) if requested is not None else Path(
str(default_path) if default_path is not None else str(DEFAULT_DATA_PATH)
)
if not target.is_file():
if explicit:
return _error_snapshot(
target,
f"Envanter dosyası bulunamadı: {target}. Panel demo veriye düşmedi; "
"filo durumu bilinmiyor.",
stamp,
)
return demo_snapshot(stamp)
try:
document = json.loads(target.read_text(encoding="utf-8"))
except OSError as exc:
return _error_snapshot(
target,
f"Envanter dosyası okunamadı: {target} ({exc.strerror or exc}).",
stamp,
)
except json.JSONDecodeError as exc:
return _error_snapshot(
target,
f"Envanter dosyası JSON olarak çözümlenemedi: {target} "
f"(satır {exc.lineno}, sütun {exc.colno}).",
stamp,
)
if not isinstance(document, Mapping):
return _error_snapshot(
target, f"Envanter belgesi nesne değil: {target} (JSON nesnesi bekleniyor).", stamp
)
schema_version = document.get("schema_version")
if not isinstance(schema_version, int) or isinstance(schema_version, bool):
return _error_snapshot(
target,
f"Envanter belgesinde schema_version yok ya da sayı değil: {target}. "
f"Bu panel şema sürümü {SUPPORTED_SCHEMA_VERSION} okur.",
stamp,
)
if schema_version > SUPPORTED_SCHEMA_VERSION:
return _error_snapshot(
target,
f"Desteklenmeyen şema sürümü: {schema_version}; bu panel "
f"{SUPPORTED_SCHEMA_VERSION} okur. Panel fail-closed durdu.",
stamp,
)
records = document.get("devices")
if not isinstance(records, list):
return _error_snapshot(
target, f"Envanter belgesinde «devices» listesi yok: {target}.", stamp
)
devices, invalid = parse_records(records)
return FleetSnapshot(
devices=devices,
invalid=invalid,
source_kind="file",
source_path=str(target),
source_label=f"envanter dosyası: {target}",
error=None,
loaded_at=stamp,
generated_at=_text(document.get("generated_at")),
schema_version=schema_version,
)
# --------------------------------------------------------------------------
# Ordering and filtering
# --------------------------------------------------------------------------
def sort_devices(devices: Iterable[Device]) -> tuple[Device, ...]:
"""Non-ready first (docs/DESIGN.md), then wave order, then identity.
Reading order for an operator: what needs a person, then the rollout order,
with the id as the last, stable tie-breaker.
"""
return tuple(
sorted(
devices,
key=lambda device: (
STATE_ORDER.index(device.state),
device.wave.order,
device.device_id,
),
)
)
def filter_devices(devices: Iterable[Device], criteria: FleetFilter) -> tuple[Device, ...]:
return tuple(device for device in devices if criteria.matches(device))
def parse_filter(params: Mapping[str, Any]) -> tuple[FleetFilter, tuple[str, ...]]:
"""Normalise operator input; unknown values are ignored *visibly*."""
warnings: list[str] = []
state_raw = first_param(params, "durum")
state = ""
if state_raw:
state = STATE_ALIASES.get(fold(state_raw), "")
if not state:
warnings.append(
f"Bilinmeyen durum filtresi yok sayıldı: «{state_raw}». "
"Sözlük: " + ", ".join(spec.word for spec in STATE_VOCABULARY) + "."
)
wave_raw = first_param(params, "dalga")
wave = ""
if wave_raw:
wave = WAVE_ALIASES.get(fold(wave_raw), "")
if not wave:
warnings.append(
f"Bilinmeyen dalga filtresi yok sayıldı: «{wave_raw}». "
"Dalgalar: " + ", ".join(w.code for w in WAVES) + "."
)
only_not_ready = fold(first_param(params, "hazir_degil")) in ("1", "var", "true", "on", "evet")
criteria = FleetFilter(
query=first_param(params, "q"),
state=state,
wave=wave,
only_not_ready=only_not_ready,
)
return criteria, tuple(warnings)
def state_counts(devices: Iterable[Device]) -> dict[str, int]:
counted = {spec.word: 0 for spec in STATE_VOCABULARY}
for device in devices:
counted[device.state_label] += 1
return counted
def wave_counts(devices: Iterable[Device]) -> dict[str, int]:
counted = {wave.code: 0 for wave in WAVES}
for device in devices:
counted[device.wave_code] += 1
return counted