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).
1083 lines
36 KiB
Python
1083 lines
36 KiB
Python
"""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
|