diff --git a/docs/UI-REMAINING-WORK.md b/docs/UI-REMAINING-WORK.md new file mode 100644 index 0000000..a24df90 --- /dev/null +++ b/docs/UI-REMAINING-WORK.md @@ -0,0 +1,61 @@ +# UI work — remaining, measured + +What is left after the first pass on the two operator surfaces. Each item was +observed on a real render, not inferred. Kept as a list so a gap is recorded +rather than remembered. + +## Device console (`bfos/ui_gui.py`) + +Reviewed against a screenshot of the running window (`spectacle`, 1440x900), +with the token values read back out of `bfos/ui_theme.py`. + +**Landed** — grouping into headed regions; the privilege state moved out of a +monospace paragraph into the header and a banner; the duplicate `Çık` removed; +the output well styled as read-only; status colours from the shared tokens. The +theme file carries the same values as `web/static/tokens.css` (`#1d4ed8`, +`#15803d`, `#b45309`) — verified by comparison, not by assertion. + +**Still true at the time of writing:** + +1. **The widgets still read as default ttk.** Inputs, comboboxes, checkbuttons + and the scrollbar keep the renderer's native chrome. `ui_theme.py` selects + `clam` because it honours `background`/`bordercolor` on the elements it + restyles, but a themed *element* is not a redesigned control. Closing this + needs `Style.element_create` work per control, or hand-drawn widgets, and it + is the single largest remaining gap between this console and the web panel. +2. **The product name appears twice.** `root.title("Blueforce Field OS — Yönetim")` + and the in-window header band carry the same words. One of them should go; + the window title is what the taskbar and window switcher show, so the header + band is the better candidate. +3. **`Cihaz durumu: YOK` is ambiguous** when no target is selected: it reads as + a state a device is in rather than "nothing selected yet". +4. **`HAZIR` in the action bar looks like a disabled button** — it is a status + label, but it is styled in the same register as the controls beside it. +5. **The operation list has no unambiguous selected/disabled distinction.** The + first row carries a focus ring while being unavailable; the ring reads as + "actionable" and the reason is only in the row's trailing text. + +## Central panel (`web/`) + +Reviewed against served HTML and captured pages at 1440x900. + +**Fixed in this pass** — the accent and the `KURULDU` state were the same blue +(`--state-provisioned: var(--info)`, and `--info` equals `--accent`), so a +selected row and a state badge were indistinguishable; both now resolve to +different colours, asserted by a test that resolves aliases before comparing. +The wave cell printed the bare code and then a label that already contains it +("P2 P2 — PILOT-2"); it now names each wave once. + +**Still true:** + +1. **No scroll affordance on the device table.** The page reports 13/13 while + only part of the list is in view, and nothing signals that more rows exist. +2. **The read-only privilege is repeated** in the header note and a second line + at the far right; one is enough. +3. **The `HAZIR` count appears in the loud figure and again in the breakdown.** + Defensible (summary then detail), but worth a decision rather than an + accident. +4. **Timestamps are ISO-8601** (`2026-09-16T06:20:00+03:00`). Precise and + sortable, but slow to scan for a human at a glance. +5. **A `HATA` device offers no next action** — the panel says a person is needed + and stops. For a fleet panel this is the workflow gap to close next. diff --git a/tests/test_web_static.py b/tests/test_web_static.py new file mode 100644 index 0000000..aa4b459 --- /dev/null +++ b/tests/test_web_static.py @@ -0,0 +1,1292 @@ +#!/usr/bin/env python3 +"""web panel — static and contract tests (stdlib unittest). + +No port is bound, no browser is opened and no server process is started: the +whole HTTP surface is covered through ``web.panel.route()``, which is a pure +function, and every rendering claim is checked on the rendered bytes. + +What these tests defend (the things that could silently regress): + + * the Turkish status vocabulary still matches docs/DESIGN.md exactly, so the + device console and the central panel cannot drift apart; + * every user-visible string is non-empty, Turkish and free of placeholders; + * no external URL, no webfont, no hex colour and no new colour token anywhere + in the templates or stylesheets: colour comes from tokens.css only; + * the panel imports nothing outside the standard library and can therefore be + installed on the air-gapped fleet it manages; + * only GET/HEAD are served, static assets are allow-listed, and a full sweep + over every endpoint leaves the filesystem untouched; + * the first viewport is ordered and populated as docs/DESIGN.md requires. + +Run: + python3 tests/test_web_static.py -v +""" + +from __future__ import annotations + +import ast +import contextlib +import io +import json +import re +import sys +import tempfile +import unittest +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parents[1] +WEB_DIR = REPO_ROOT / "web" +TEMPLATES_DIR = WEB_DIR / "templates" +STATIC_DIR = WEB_DIR / "static" +TOKENS_CSS = STATIC_DIR / "tokens.css" +DESIGN_DOC = REPO_ROOT / "docs" / "DESIGN.md" + +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +import web # noqa: E402 (path set above) +from web import fleet_data, panel, render, server # noqa: E402 + +# The panel must be the one in this checkout, not some unrelated "web" package. +assert Path(web.__file__).resolve().parent == WEB_DIR, f"yanlis paket: {web.__file__}" + +NOW = "2026-09-16T22:00:00+03:00" +STATE_WORDS = ("KURULDU", "KAYITLI", "HAZIR", "HATA", "YOK") + +# Everything an air-gapped, framework-free surface must never contain. +FORBIDDEN_ASSET_MARKERS = ( + "http://", + "https://", + "//cdn", + "cdn.jsdelivr", + "cdnjs", + "unpkg", + "fonts.googleapis", + "fonts.gstatic", + "@import", + "@font-face", + "src=", + "xlink:href=", +) +FORBIDDEN_IMPORT_ROOTS = ("fastapi", "flask", "jinja2", "uvicorn", "django", "werkzeug", "starlette", "requests") +FORBIDDEN_IMPORTS = ("urllib.request", "http.client", "socket", "subprocess", "shutil", "ftplib") +FORBIDDEN_SOURCE_MARKERS = ("os.system(", "subprocess.", "urllib.request", "requests.", "eval(", "exec(") + +HEX_COLOUR = re.compile(r"#[0-9a-fA-F]{3,8}\b") +CUSTOM_PROPERTY = re.compile(r"^\s*--[A-Za-z0-9-]+\s*:", re.M) +# A custom-property declaration, e.g. `--state-ready: var(--ok);` +CUSTOM_PROPERTY_DECL = re.compile(r"^\s*--([A-Za-z0-9-]+)\s*:\s*([^;]+);", re.M) +# A single `var(--name)` reference inside a value. +VAR_REFERENCE = re.compile(r"var\(\s*--([A-Za-z0-9-]+)\s*\)") +DEFINED_TOKEN = re.compile(r"^\s*(--[a-z0-9-]+)\s*:", re.M) +USED_TOKEN = re.compile(r"var\((--[a-z0-9-]+)\)") +TEXT_TAGS = ("td", "th", "span", "h1", "h2", "h3", "p", "li", "option", "dd", "dt", "button", "a", "caption", "label") +ELEMENT = re.compile(r"<(%s)\b[^>]*>(.*?)" % "|".join(TEXT_TAGS), re.S) +ROW_BADGE = re.compile(r']*>([^<]*)') + + +# -------------------------------------------------------------------------- +# helpers +# -------------------------------------------------------------------------- + + +def token_values(text: str) -> dict[str, str]: + """Every custom-property declaration in a stylesheet, name -> raw value.""" + return { + name: value.strip() + for name, value in CUSTOM_PROPERTY_DECL.findall(text) + } + + +def resolved_colour(name: str, values: dict[str, str], limit: int = 12) -> str | None: + """Follow ``var(--x)`` aliases to the colour a token actually paints. + + Comparing raw declarations is not enough to prove two tokens differ: a + mutation writing ``--state-provisioned: var(--accent)`` leaves the strings + unequal while painting exactly the same blue. Only the resolved value is + evidence. + """ + seen: set[str] = set() + while limit > 0: + value = values.get(name) + if value is None: + return None + reference = VAR_REFERENCE.fullmatch(value) + if reference is None: + return value + name = reference.group(1) + if name in seen: + return value # a cycle: report what was declared rather than loop + seen.add(name) + limit -= 1 + return values.get(name) + + +def design_vocabulary() -> list[tuple[str, str]]: + """Read the status vocabulary table out of docs/DESIGN.md.""" + text = DESIGN_DOC.read_text(encoding="utf-8") + row = re.compile(r"^\|\s*`([^`]+)`\s*\|\s*`(--state-[a-z-]+)`\s*\|", re.M) + return row.findall(text) + + +def text_files() -> list[Path]: + """Templates and stylesheets: everything the browser receives from disk.""" + return sorted(TEMPLATES_DIR.glob("*.html")) + sorted(STATIC_DIR.glob("*.css")) + + +def empty_elements(markup: str) -> list[str]: + """Visible elements that carry no text at all (an empty label is a bug).""" + problems: list[str] = [] + for match in ELEMENT.finditer(markup): + inner = re.sub(r"<[^>]+>", "", match.group(2)) + if not inner.strip(): + problems.append(match.group(0)[:140]) + return problems + + +def tree_fingerprint(root: Path) -> tuple: + """Sizes and mtimes of every source file, ignoring interpreter caches.""" + entries = [] + for path in sorted(root.rglob("*")): + if "__pycache__" in path.parts or not path.is_file(): + continue + stat = path.stat() + entries.append((str(path.relative_to(root)), stat.st_size, stat.st_mtime_ns)) + return tuple(entries) + + +class PanelCase(unittest.TestCase): + """Shared fixture: the labelled demo snapshot and both privilege levels.""" + + @classmethod + def setUpClass(cls) -> None: + cls.snapshot = fleet_data.demo_snapshot(NOW) + cls.read_only = render.privilege_from_level("read-only") + cls.full = render.privilege_from_level("full") + cls.info = render.ServerInfo( + host="127.0.0.1", + port=8787, + privilege=cls.read_only, + python_version="3.13.0", + started_at=NOW, + ) + cls.context = panel.PanelContext( + snapshot=cls.snapshot, privilege=cls.read_only, server_info=cls.info + ) + full_info = render.ServerInfo( + host="127.0.0.1", + port=8787, + privilege=cls.full, + python_version="3.13.0", + started_at=NOW, + ) + cls.full_context = panel.PanelContext( + snapshot=cls.snapshot, privilege=cls.full, server_info=full_info + ) + + # -- request helpers --------------------------------------------------- + def get(self, target: str, method: str = "GET", context: panel.PanelContext | None = None): + return panel.route(context or self.context, method, target) + + def page(self, target: str = "/", method: str = "GET", context: panel.PanelContext | None = None) -> str: + response = self.get(target, method, context) + self.assertEqual(response.status, 200, f"{target}: {response.status} (beklenen 200)") + return response.body.decode("utf-8") + + def list_region(self, page: str) -> str: + """The device table only, so about/detail badges never leak into a scan.""" + start = page.index('data-region="list"') + end = page.index('data-region="detail"') + return page[start:end] + + def row_words(self, page: str) -> list[str]: + return [word for _, word in ROW_BADGE.findall(self.list_region(page))] + + def snapshot_from(self, document: object, *, now: str = NOW, filename: str = "fleet.json"): + """Write a temporary inventory document and load it through the loader.""" + directory = tempfile.TemporaryDirectory(prefix="bf-panel-test-") + self.addCleanup(directory.cleanup) + path = Path(directory.name) / filename + if isinstance(document, str): + path.write_text(document, encoding="utf-8") + else: + path.write_text(json.dumps(document, ensure_ascii=False), encoding="utf-8") + return fleet_data.load_fleet(path, env={}, now=now) + + +# -------------------------------------------------------------------------- +# vocabulary and design contract +# -------------------------------------------------------------------------- + + +class TestVocabularyMatchesDesign(PanelCase): + def test_vocabulary_matches_docs_design_exactly(self) -> None: + """One word per state, shared with the device console; no fifth word.""" + self.assertEqual( + design_vocabulary(), + [(spec.word, spec.token) for spec in fleet_data.STATE_VOCABULARY], + ) + + def test_design_document_still_has_the_five_words(self) -> None: + words = [word for word, _ in design_vocabulary()] + self.assertEqual(words, list(STATE_WORDS)) + + def test_every_word_is_upper_case_turkish_and_non_empty(self) -> None: + for spec in fleet_data.STATE_VOCABULARY: + with self.subTest(state=spec.state): + self.assertTrue(spec.word.strip(), spec.state) + self.assertEqual(spec.word, spec.word.upper()) + self.assertTrue(spec.meaning.strip(), spec.word) + self.assertNotIn("None", spec.meaning) + + def test_tokens_exist_in_tokens_css(self) -> None: + css = TOKENS_CSS.read_text(encoding="utf-8") + for spec in fleet_data.STATE_VOCABULARY: + with self.subTest(state=spec.state): + self.assertIn(f"{spec.token}:", css) + self.assertIn(f"{spec.soft_token}:", css) + + def test_non_ready_states_sort_before_ready(self) -> None: + self.assertEqual(fleet_data.STATE_ORDER[-1], "READY") + self.assertEqual(set(fleet_data.STATE_ORDER), set(fleet_data.STATE_BY_NAME)) + + def test_demo_data_exercises_every_state_word(self) -> None: + counts = self.snapshot.counts + self.assertEqual(list(counts), list(STATE_WORDS)) + for word, count in counts.items(): + with self.subTest(word=word): + self.assertGreaterEqual(count, 1, f"{word} demo veride yok; sözlük test edilemez") + + def test_demo_data_exercises_every_wave(self) -> None: + counts = fleet_data.wave_counts(self.snapshot.devices) + self.assertEqual(list(counts), [wave.code for wave in fleet_data.WAVES]) + for code, count in counts.items(): + with self.subTest(wave=code): + self.assertGreaterEqual(count, 1) + + def test_waves_match_the_rollout_document(self) -> None: + """docs/18: LAB(2) -> P1(5) -> P2(20) -> W1(50) -> W2(100) -> PROD.""" + self.assertEqual( + [(wave.code, wave.target) for wave in fleet_data.WAVES], + [("LAB", 2), ("P1", 5), ("P2", 20), ("W1", 50), ("W2", 100), ("PROD", 523)], + ) + + def test_four_documented_channels(self) -> None: + self.assertEqual( + [channel.label for channel in fleet_data.CHANNELS], + ["SSH-over-WG", "xRDP", "RustDesk", "MeshCentral"], + ) + self.assertEqual( + [channel.key for channel in fleet_data.CHANNELS], + ["ssh_over_wg", "xrdp", "rustdesk", "meshcentral"], + ) + + def test_channel_status_words_are_turkish_and_non_empty(self) -> None: + for status, word in fleet_data.CHANNEL_STATUS_WORDS.items(): + with self.subTest(status=status): + self.assertTrue(word.strip()) + self.assertNotIn("None", word) + self.assertEqual(set(fleet_data.CHANNEL_STATUS_WORDS), set(fleet_data.CHANNEL_STATUSES)) + + +# -------------------------------------------------------------------------- +# no external assets, no invented colour +# -------------------------------------------------------------------------- + + +class TestAssetsAndColour(PanelCase): + def test_no_external_url_or_remote_asset_in_any_template_or_stylesheet(self) -> None: + for path in text_files(): + text = path.read_text(encoding="utf-8").lower() + for marker in FORBIDDEN_ASSET_MARKERS: + with self.subTest(file=path.name, marker=marker): + self.assertNotIn(marker, text) + + def test_no_javascript_ships_with_the_panel(self) -> None: + for path in text_files(): + text = path.read_text(encoding="utf-8").lower() + with self.subTest(file=path.name): + self.assertNotIn(" None: + for path in text_files(): + text = path.read_text(encoding="utf-8") + if path == TOKENS_CSS: + self.assertTrue(HEX_COLOUR.search(text), "tokens.css palet içermiyor") + continue + with self.subTest(file=path.name): + self.assertEqual(HEX_COLOUR.findall(text), [], f"{path.name} hex renk içeriyor") + + def test_templates_and_app_css_declare_no_custom_property(self) -> None: + for path in text_files(): + if path == TOKENS_CSS: + continue + text = path.read_text(encoding="utf-8") + with self.subTest(file=path.name): + self.assertEqual(CUSTOM_PROPERTY.findall(text), [], f"{path.name} belirteç tanımlıyor") + + def test_every_colour_token_used_is_defined_in_tokens_css(self) -> None: + defined = set(DEFINED_TOKEN.findall(TOKENS_CSS.read_text(encoding="utf-8"))) + self.assertTrue(defined) + used: dict[str, str] = {} + for path in text_files(): + if path == TOKENS_CSS: + continue + for token in USED_TOKEN.findall(path.read_text(encoding="utf-8")): + used[token] = path.name + unknown = {token: name for token, name in used.items() if token not in defined} + self.assertEqual(unknown, {}, f"tokens.css'te tanımsız belirteç: {unknown}") + self.assertTrue(used, "hiç belirteç kullanılmıyor") + + def test_the_action_accent_is_not_a_device_state_colour(self) -> None: + """The accent means "the action you came to take" and "the selected row". + + A state that borrows it makes a KURULDU badge and a selected row the same + blue, so the list can no longer say which of the two it is showing. Aliases + are resolved before comparing: a token may chain (``--state-ready`` to + ``--ok``), and ``var(--accent)`` is the accent whatever the string says. + """ + values = token_values(TOKENS_CSS.read_text(encoding="utf-8")) + self.assertIn("accent", values, "tokens.css'te --accent yok") + accent = resolved_colour("accent", values) + states = {n: v for n, v in values.items() if n.startswith("state-")} + self.assertTrue(states, "tokens.css'te cihaz durumu belirteci yok") + for name in states: + with self.subTest(token=name): + resolved = resolved_colour(name, values) + self.assertIsNotNone(resolved, f"--{name} çözümlenemedi") + self.assertNotEqual( + resolved, accent, + f"--{name} ile --accent aynı rengi veriyor ({resolved}); " + "seçili satır ile durum rozeti ayırt edilemez", + ) + + def test_wave_is_rendered_once_and_not_as_the_bare_code_plus_full_label(self) -> None: + """``detail.html`` printed ``$wave_code`` and then ``$wave_label``, and the + label already begins with the code, so one device read "P2 P2 — PILOT-2" + (the list row was the same). A wave cell must name each wave once. + """ + device = next( + d for d in self.snapshot.devices if d.wave_code == "P2" + ) + target = f"/?cihaz={device.device_id}" + body = panel.route(self.context, "GET", target).body.decode("utf-8") + self.assertNotIn("$wave_name", body, "şablon değişkeni değiştirilmedi") + self.assertNotIn("$wave_code", body, "şablon değişkeni değiştirilmedi") + self.assertNotIn(device.wave_code * 2, body, f"dalga kodu iki kez basılıyor: {device.wave_code}") + self.assertIn(device.wave_label, body, "dalga tam etiketi görünmüyor") + + def test_only_local_stylesheets_are_linked(self) -> None: + base = (TEMPLATES_DIR / "base.html").read_text(encoding="utf-8") + linked = re.findall(r']*href="([^"]+)"', base) + self.assertEqual(linked, ["/static/tokens.css", "/static/app.css"]) + + def test_templates_are_utf8_with_turkish_characters(self) -> None: + for path in sorted(TEMPLATES_DIR.glob("*.html")): + text = path.read_text(encoding="utf-8") + with self.subTest(file=path.name): + self.assertTrue(text.strip()) + base = (TEMPLATES_DIR / "base.html").read_text(encoding="utf-8") + self.assertIn('', base) + + def test_state_badge_styles_use_the_shared_tokens(self) -> None: + css = (STATIC_DIR / "app.css").read_text(encoding="utf-8") + for spec in fleet_data.STATE_VOCABULARY: + with self.subTest(state=spec.state): + self.assertIn(f".badge--state-{spec.token_suffix}", css) + self.assertIn("var(--accent-soft)", css) + self.assertIn(".fleet-row--selected", css) + + +# -------------------------------------------------------------------------- +# standard library only: the fleet installs offline +# -------------------------------------------------------------------------- + + +class TestStdlibOnly(PanelCase): + def web_sources(self) -> list[Path]: + return sorted(WEB_DIR.glob("*.py")) + + def test_web_modules_exist(self) -> None: + names = {path.name for path in self.web_sources()} + self.assertEqual( + names, {"__init__.py", "fleet_data.py", "render.py", "panel.py", "server.py"} + ) + + def test_every_import_resolves_to_the_standard_library(self) -> None: + stdlib = set(sys.stdlib_module_names) + for path in self.web_sources(): + tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for node in ast.walk(tree): + if isinstance(node, ast.Import): + roots = [alias.name.split(".")[0] for alias in node.names] + elif isinstance(node, ast.ImportFrom): + if node.level: + continue + roots = [(node.module or "").split(".")[0]] + else: + continue + for root in roots: + with self.subTest(file=path.name, module=root): + self.assertIn(root, stdlib | {"web"}) + + def test_no_third_party_web_framework_is_referenced(self) -> None: + for path in self.web_sources(): + text = path.read_text(encoding="utf-8").lower() + for marker in FORBIDDEN_IMPORT_ROOTS: + with self.subTest(file=path.name, marker=marker): + self.assertNotIn(f"import {marker}", text) + self.assertNotIn(f"from {marker}", text) + + def test_no_outbound_client_and_no_process_execution(self) -> None: + for path in self.web_sources(): + text = path.read_text(encoding="utf-8") + for marker in FORBIDDEN_IMPORTS: + with self.subTest(file=path.name, marker=marker): + self.assertNotIn(f"import {marker}", text) + lowered = text.lower() + for marker in FORBIDDEN_SOURCE_MARKERS: + with self.subTest(file=path.name, marker=marker): + self.assertNotIn(marker, lowered) + + def test_route_is_a_pure_function_with_a_small_signature(self) -> None: + import inspect + + signature = inspect.signature(panel.route) + self.assertEqual(list(signature.parameters), ["context", "method", "target"]) + + def test_server_module_takes_host_and_port_from_the_command_line(self) -> None: + args = server.parse_args([]) + self.assertEqual(args.host, "127.0.0.1") + self.assertEqual(args.host, web.DEFAULT_HOST) + self.assertEqual(args.port, web.DEFAULT_PORT) + self.assertEqual(args.privilege, "read-only") + self.assertEqual(args.data, None) + + def test_check_mode_reports_without_binding_a_port(self) -> None: + buffer = io.StringIO() + with contextlib.redirect_stdout(buffer): + code = server.main(["--check"]) + self.assertEqual(code, 0) + output = buffer.getvalue() + self.assertIn(web.PANEL_VERSION, output) + self.assertIn("Veri kaynağı", output) + self.assertIn("Yetki", output) + self.assertIn("sunucu başlatılmadı", output) + + def test_version_and_privilege_flags(self) -> None: + with contextlib.redirect_stdout(io.StringIO()): + with self.assertRaises(SystemExit) as raised: + server.parse_args(["--version"]) + self.assertEqual(raised.exception.code, 0) + with contextlib.redirect_stderr(io.StringIO()): + with self.assertRaises(SystemExit): + server.parse_args(["--privilege", "root"]) + + def test_data_flag_selects_the_inventory_file(self) -> None: + snapshot = self.snapshot_from(self.example_document()) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "fleet.json" + path.write_text(self.example_document(), encoding="utf-8") + context = server.build_context(server.parse_args(["--data", str(path)])) + self.assertTrue(context.snapshot.is_file) + self.assertEqual(context.snapshot.total, snapshot.total) + self.assertEqual(context.server_info.data_path, str(path)) + + def example_document(self) -> str: + return (WEB_DIR / "data" / "fleet.example.json").read_text(encoding="utf-8") + + def test_example_document_matches_the_panel_schema(self) -> None: + """The shipped example must load cleanly, or it teaches the wrong schema.""" + snapshot = self.snapshot_from(self.example_document()) + self.assertTrue(snapshot.is_file, snapshot.error or "örnek belge okunamadı") + self.assertEqual(snapshot.invalid, ()) + self.assertGreaterEqual(snapshot.total, 5) + self.assertGreater(snapshot.ready_count, 0) + + +# -------------------------------------------------------------------------- +# loader: state machines, refusal paths, provenance +# -------------------------------------------------------------------------- + + +class TestLoader(PanelCase): + READY_RECORD: dict[str, Any] = { + "dealer_id": "12010193", + "wave": "P1", + "state": "READY", + "channels": { + "ssh_over_wg": "verified", + "xrdp": "verified", + "rustdesk": "verified", + "meshcentral": "verified", + }, + } + + def document(self, *records: object) -> dict: + return {"schema_version": 1, "generated_at": NOW, "devices": list(records)} + + def test_missing_default_file_falls_back_to_labelled_demo(self) -> None: + snapshot = fleet_data.load_fleet(None, env={}, now=NOW, default_path="/yok/fleet.json") + self.assertTrue(snapshot.is_demo) + self.assertEqual(snapshot.total, len(fleet_data.DEMO_DEVICES)) + self.assertIn("demo", snapshot.source_label.lower()) + self.assertEqual(snapshot.banner_word, "DEMO VERİ") + self.assertIn("sentetik", snapshot.banner_text) + + def test_missing_explicit_file_is_an_error_and_does_not_become_demo(self) -> None: + snapshot = fleet_data.load_fleet("/yok/boyle/bir/dosya.json", env={}, now=NOW) + self.assertTrue(snapshot.is_error) + self.assertEqual(snapshot.total, 0) + self.assertEqual(snapshot.banner_word, "VERİ YOK") + self.assertIn("bulunamadı", snapshot.error or "") + self.assertIn("Demo veriye düşülmedi", snapshot.banner_text) + + def test_environment_variable_selects_the_document(self) -> None: + document = json.dumps(self.document(self.READY_RECORD)) + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "fleet.json" + path.write_text(document, encoding="utf-8") + snapshot = fleet_data.load_fleet(None, env={fleet_data.DATA_PATH_ENV: str(path)}, now=NOW) + self.assertTrue(snapshot.is_file) + self.assertEqual(snapshot.total, 1) + + def test_identity_is_derived_from_the_dealer_number(self) -> None: + snapshot = self.snapshot_from(self.document(self.READY_RECORD)) + device = snapshot.devices[0] + self.assertEqual(device.device_id, "BF-12010193") + self.assertEqual(device.hostname, "bf-12010193") + self.assertEqual(device.dealer_id, "12010193") + self.assertEqual(snapshot.schema_version, 1) + self.assertEqual(snapshot.generated_at, NOW) + + def test_wave_aliases_resolve_to_the_rollout_codes(self) -> None: + for raw, expected in ( + ("pilot_1", "P1"), + ("LAB", "LAB"), + ("wave_2", "W2"), + ("production", "PROD"), + ): + with self.subTest(wave=raw): + record = dict(self.READY_RECORD, wave=raw) + snapshot = self.snapshot_from(self.document(record)) + self.assertEqual(snapshot.devices[0].wave_code, expected) + + def test_turkish_state_words_are_accepted_as_input(self) -> None: + for raw, expected in (("HAZIR", "READY"), ("Hazır", "READY"), ("hazir", "READY")): + with self.subTest(state=raw): + record = dict(self.READY_RECORD, state=raw) + snapshot = self.snapshot_from(self.document(record)) + self.assertEqual(snapshot.devices[0].state, expected) + + def test_broken_json_is_an_error_that_names_the_document(self) -> None: + snapshot = self.snapshot_from('{"schema_version": 1, "devices": [') + self.assertTrue(snapshot.is_error) + self.assertIn("JSON", snapshot.error or "") + + def test_unsupported_schema_version_is_refused(self) -> None: + for version in (2, 99): + with self.subTest(version=version): + document = {"schema_version": version, "devices": []} + snapshot = self.snapshot_from(document) + self.assertTrue(snapshot.is_error) + self.assertIn("şema sürümü", snapshot.error or "") + + def test_missing_schema_version_or_devices_list_is_refused(self) -> None: + for document in ({"devices": []}, {"schema_version": 1}): + with self.subTest(document=document): + snapshot = self.snapshot_from(document) + self.assertTrue(snapshot.is_error) + + def test_bad_dealer_number_is_shown_not_dropped(self) -> None: + record = dict(self.READY_RECORD, dealer_id="1201019") + snapshot = self.snapshot_from(self.document(record)) + self.assertEqual(snapshot.total, 0) + self.assertEqual(len(snapshot.invalid), 1) + reason = snapshot.invalid[0].reason + self.assertIn("8 hane", reason) + self.assertIn("1201019", reason) + + def test_duplicate_dealer_number_is_refused(self) -> None: + snapshot = self.snapshot_from(self.document(self.READY_RECORD, dict(self.READY_RECORD))) + self.assertEqual(snapshot.total, 1) + self.assertEqual(len(snapshot.invalid), 1) + self.assertIn("Yinelenen", snapshot.invalid[0].reason) + + def test_unknown_wave_is_refused_with_a_turkish_reason(self) -> None: + record = dict(self.READY_RECORD, wave="WAVE-9") + snapshot = self.snapshot_from(self.document(record)) + self.assertEqual(snapshot.total, 0) + self.assertIn("Bilinmeyen dalga", snapshot.invalid[0].reason) + + def test_mismatching_device_id_is_refused(self) -> None: + record = dict(self.READY_RECORD, device_id="BF-12010999") + snapshot = self.snapshot_from(self.document(record)) + self.assertEqual(snapshot.total, 0) + self.assertIn("kurala uymuyor", snapshot.invalid[0].reason) + + def test_unknown_state_becomes_hata_with_the_reason(self) -> None: + record = dict(self.READY_RECORD, state="BOZUK") + snapshot = self.snapshot_from(self.document(record)) + device = snapshot.devices[0] + self.assertEqual(device.state_label, "HATA") + self.assertEqual(device.claimed_state, "BOZUK") + self.assertIn("tanınmıyor", device.reason or "") + + def test_missing_state_becomes_hata_and_is_never_invented(self) -> None: + record = {key: value for key, value in self.READY_RECORD.items() if key != "state"} + snapshot = self.snapshot_from(self.document(record)) + device = snapshot.devices[0] + self.assertEqual(device.state_label, "HATA") + self.assertIn("Durum alanı yok", device.reason or "") + + def test_ready_claim_without_verified_channels_is_refused(self) -> None: + """docs/27: READY is refused unless every channel reports verified.""" + record = dict(self.READY_RECORD) + record["channels"] = dict(self.READY_RECORD["channels"], xrdp="pending") + snapshot = self.snapshot_from(self.document(record)) + device = snapshot.devices[0] + self.assertEqual(device.state_label, "HATA") + self.assertEqual(device.claimed_state, "READY") + self.assertIn("HAZIR iddiası reddedildi", device.reason or "") + self.assertIn("xRDP", device.reason or "") + self.assertIn("xRDP", device.unverified_channels) + + def test_ready_claim_with_every_channel_verified_is_ready(self) -> None: + snapshot = self.snapshot_from(self.document(self.READY_RECORD)) + device = snapshot.devices[0] + self.assertEqual(device.state_label, "HAZIR") + self.assertEqual(device.verified_channel_count, 4) + self.assertEqual(device.unverified_channels, ()) + self.assertIn("dört kanalın dördü de doğrulandı", device.ready_gate_note) + + def test_failed_channel_marks_the_device_hata(self) -> None: + record = dict(self.READY_RECORD, state="ENROLLED") + record["channels"] = dict(self.READY_RECORD["channels"], rustdesk="failed") + snapshot = self.snapshot_from(self.document(record)) + device = snapshot.devices[0] + self.assertEqual(device.state_label, "HATA") + self.assertIn("başarısız", device.reason or "") + + def test_panel_never_promotes_a_device(self) -> None: + record = dict(self.READY_RECORD, state="ENROLLED") + snapshot = self.snapshot_from(self.document(record)) + device = snapshot.devices[0] + self.assertEqual(device.state_label, "KAYITLI") + self.assertTrue(any("yükseltmez" in note for note in device.notes), device.notes) + + def test_missing_channels_are_all_reported_as_missing(self) -> None: + record = {"dealer_id": "12010193", "wave": "P1", "state": "PROVISIONED_OFFLINE"} + snapshot = self.snapshot_from(self.document(record)) + device = snapshot.devices[0] + self.assertEqual(len(device.channels), 4) + self.assertEqual({check.status for check in device.channels}, {"missing"}) + self.assertEqual({check.status_word for check in device.channels}, {"yok"}) + self.assertTrue(any("Kanallar bildirilmemiş" in note for note in device.notes)) + + def test_unknown_channel_keys_are_reported_not_silently_ignored(self) -> None: + record = dict(self.READY_RECORD) + record["channels"] = dict(self.READY_RECORD["channels"], telnet="verified") + snapshot = self.snapshot_from(self.document(record)) + device = snapshot.devices[0] + self.assertTrue(any("telnet" in note for note in device.notes), device.notes) + self.assertEqual(len(device.channels), 4) + + def test_loader_is_deterministic(self) -> None: + document = self.document(self.READY_RECORD) + first = self.snapshot_from(document) + second = self.snapshot_from(document) + self.assertEqual(first.devices, second.devices) + + +# -------------------------------------------------------------------------- +# ordering and filtering +# -------------------------------------------------------------------------- + + +class TestOrderingAndFiltering(PanelCase): + def sorted_demo(self): + return fleet_data.sort_devices(self.snapshot.devices) + + def test_non_ready_devices_come_first(self) -> None: + flags = [device.is_ready for device in self.sorted_demo()] + self.assertEqual(flags, sorted(flags), "hazır cihaz hazır olmayanın önünde") + self.assertIn(True, flags) + self.assertIn(False, flags) + + def test_failed_devices_are_at_the_top(self) -> None: + ordered = self.sorted_demo() + self.assertEqual(ordered[0].state_label, "HATA") + + def test_sort_keeps_every_device_exactly_once(self) -> None: + ordered = self.sorted_demo() + self.assertEqual(len(ordered), self.snapshot.total) + self.assertEqual( + {device.device_id for device in ordered}, + {device.device_id for device in self.snapshot.devices}, + ) + + def test_query_filter_matches_identity_hostname_and_dealer(self) -> None: + for needle in ("BF-12010193", "bf-12010193", "12010193"): + with self.subTest(needle=needle): + criteria, warnings = fleet_data.parse_filter({"q": [needle]}) + shown = fleet_data.filter_devices(self.snapshot.devices, criteria) + self.assertEqual(len(shown), 1, needle) + self.assertEqual(shown[0].device_id, "BF-12010193") + self.assertEqual(warnings, ()) + + def test_filter_query_is_case_insensitive(self) -> None: + criteria, _ = fleet_data.parse_filter({"q": ["bf-1201019"]}) + shown = fleet_data.filter_devices(self.snapshot.devices, criteria) + self.assertEqual( + {device.device_id for device in shown}, + {"BF-12010193", "BF-12010194", "BF-12010195"}, + ) + + def test_state_filter_accepts_word_machine_name_and_folded_input(self) -> None: + for raw in ("HAZIR", "ready", "hazir", "Hazır"): + with self.subTest(raw=raw): + criteria, warnings = fleet_data.parse_filter({"durum": [raw]}) + self.assertEqual(criteria.state, "READY") + self.assertEqual(warnings, ()) + shown = fleet_data.filter_devices(self.snapshot.devices, criteria) + self.assertTrue(shown) + self.assertEqual({device.state_label for device in shown}, {"HAZIR"}) + + def test_wave_filter_accepts_repo_group_names(self) -> None: + for raw, code in (("pilot_1", "P1"), ("W1", "W1"), ("production", "PROD")): + with self.subTest(raw=raw): + criteria, _ = fleet_data.parse_filter({"dalga": [raw]}) + self.assertEqual(criteria.wave, code) + shown = fleet_data.filter_devices(self.snapshot.devices, criteria) + self.assertTrue(shown) + self.assertEqual({device.wave_code for device in shown}, {code}) + + def test_unknown_filter_values_are_ignored_with_a_turkish_warning(self) -> None: + criteria, warnings = fleet_data.parse_filter({"durum": ["MOR"], "dalga": ["W9"]}) + self.assertEqual(criteria.state, "") + self.assertEqual(criteria.wave, "") + self.assertEqual(len(warnings), 2) + for warning in warnings: + self.assertIn("yok sayıldı", warning) + page = self.page("/?durum=MOR&dalga=W9") + self.assertIn("yok sayıldı", page) + self.assertIn("Uyarılar", page) + + def test_only_not_ready_filter(self) -> None: + criteria, _ = fleet_data.parse_filter({"hazir_degil": ["1"]}) + shown = fleet_data.filter_devices(self.snapshot.devices, criteria) + self.assertTrue(shown) + self.assertTrue(all(not device.is_ready for device in shown)) + + def test_counts_are_zero_filled_and_add_up(self) -> None: + counts = fleet_data.state_counts(self.snapshot.devices) + self.assertEqual(list(counts), list(STATE_WORDS)) + self.assertEqual(sum(counts.values()), self.snapshot.total) + self.assertEqual(counts, self.snapshot.counts) + self.assertEqual(self.snapshot.ready_count + self.snapshot.not_ready_count, self.snapshot.total) + + def test_filter_matches_the_rendered_count(self) -> None: + criteria, _ = fleet_data.parse_filter({"durum": ["YOK"]}) + shown = fleet_data.filter_devices(self.snapshot.devices, criteria) + page = self.page("/?durum=YOK") + self.assertEqual(len(self.row_words(page)), len(shown)) + self.assertTrue(all(word == "YOK" for word in self.row_words(page))) + + +# -------------------------------------------------------------------------- +# the first viewport +# -------------------------------------------------------------------------- + + +class TestFleetPage(PanelCase): + def test_served_as_utf8_turkish_html(self) -> None: + response = self.get("/") + self.assertEqual(response.status, 200) + self.assertEqual(response.content_type, "text/html; charset=utf-8") + markup = response.body.decode("utf-8") + self.assertIn('', markup) + # Turkish characters must survive the byte round trip, not just the string. + self.assertIn("doğrulandı".encode("utf-8"), response.body) + self.assertEqual(response.content_length, len(response.body)) + + def test_first_viewport_regions_are_ordered(self) -> None: + page = self.page("/") + positions = [ + page.index('data-region="header"'), + page.index('data-region="summary"'), + page.index('data-region="list"'), + page.index('data-region="detail"'), + ] + self.assertEqual(positions, sorted(positions), "bölge sırası DESIGN.md'ye uymuyor") + + def test_header_carries_product_target_and_privilege(self) -> None: + page = self.page("/") + header = page[: page.index('data-region="summary"')] + self.assertIn(web.PANEL_NAME, header) + self.assertIn(web.PANEL_SURFACE, header) + self.assertIn("Hedef", header) + self.assertIn(f"Tüm filo · {self.snapshot.total} cihaz", header) + self.assertIn("Yetki", header) + self.assertIn("Salt-okunur", header) + self.assertIn(self.read_only.reason, header) + + def test_privilege_level_is_the_right_most_header_readout(self) -> None: + """docs/DESIGN.md: 'privilege level on the right -- never a buried sentence'.""" + page = self.page("/") + header = page[: page.index('data-region="summary"')] + privilege = header.index("app-header__privilege") + self.assertGreater(privilege, header.index("app-header__identity")) + self.assertGreater(privilege, header.index("app-header__target")) + self.assertGreater(privilege, header.index("app-header__actions")) + + def test_primary_action_sits_in_the_header_not_below_the_fold(self) -> None: + page = self.page("/") + self.assertLess( + page.index("Filo durumunu yenile"), + page.index('data-region="summary"'), + "birincil eylem başlık bandında değil", + ) + self.assertIn('class="button button--primary"', page) + + def test_unprivileged_write_action_is_disabled_with_the_reason_on_the_control(self) -> None: + page = self.page("/") + self.assertIn('disabled aria-disabled="true" aria-describedby="gated-reason"', page) + self.assertIn('id="gated-reason"', page) + self.assertIn(self.read_only.reason, page) + + def test_every_state_word_appears_in_the_summary_strip(self) -> None: + page = self.page("/") + summary = page[page.index('data-region="summary"') : page.index('data-region="list"')] + for word in STATE_WORDS: + with self.subTest(word=word): + self.assertIn(word, summary) + + def test_summary_strip_has_exactly_one_large_figure(self) -> None: + page = self.page("/") + self.assertEqual(page.count("summary__lead-figure"), 1) + self.assertEqual( + page.count("summary__cell-value"), len(fleet_data.STATE_VOCABULARY) + ) + counts = self.snapshot.counts + summary = page[page.index('data-region="summary"') : page.index('data-region="list"')] + self.assertIn(f'>{counts["HAZIR"]}<', summary) + + def test_every_row_carries_a_state_word_in_monospace_columns(self) -> None: + page = self.page("/") + self.assertEqual(len(self.row_words(page)), self.snapshot.total) + for device in self.snapshot.devices: + with self.subTest(device=device.device_id): + self.assertIn( + f'' + f"{device.device_id}", + page, + ) + self.assertIn(f'class="badge badge--state-{device.state_token_suffix}"', page) + self.assertIn(f'{device.hostname}', page) + + def test_wave_column_names_the_wave_once_in_monospace(self) -> None: + """The Dalga column is a data cell, and it names each wave exactly once.""" + region = self.list_region(self.page("/")) + rows = re.findall(r'(BF-[0-9]{8})", row) + if match is None: + self.fail(f"satırda cihaz kimliği yok: {row[:120]}") + device = by_id[match.group(1)] + cells = re.findall(r"]*>(.*?)", row, flags=re.S) + self.assertEqual(len(cells), 5, row[:200]) + self.assertEqual( + cells[3], f'{device.wave.label}', row[:200] + ) + + def test_rows_are_sorted_non_ready_first(self) -> None: + words = self.row_words(self.page("/")) + ready_flags = [ + fleet_data.STATE_BY_WORD[word].state == "READY" for word in words + ] + self.assertEqual(ready_flags, sorted(ready_flags)) + self.assertEqual(ready_flags[0], False) + self.assertEqual(ready_flags[-1], True) + + def test_selected_row_uses_the_accent_soft_fill(self) -> None: + page = self.page("/") + self.assertEqual(page.count("fleet-row--selected"), 1) + selected_row = page[page.index("fleet-row--selected") :] + selected_row = selected_row[: selected_row.index("")] + detail = page[page.index('data-region="detail"') :] + device_id = re.search(r">(BF-[0-9]{8})", selected_row).group(1) + self.assertIn(device_id, detail) + css = (STATIC_DIR / "app.css").read_text(encoding="utf-8") + self.assertIn(".fleet-row--selected td { background: var(--accent-soft); }", css) + + def test_default_selection_is_the_first_row_of_the_list(self) -> None: + page = self.page("/") + first_row = self.list_region(page) + first_row = first_row[first_row.index("") :] + device_id = re.search(r">(BF-[0-9]{8})", first_row).group(1) + detail = page[page.index('data-region="detail"') :] + self.assertIn(device_id, detail) + self.assertIn("Hedef kimliği", detail) + + def test_detail_region_shows_state_identity_wave_and_channels(self) -> None: + page = self.page("/?cihaz=BF-12010193") + detail = page[page.index('data-region="detail"') :] + device = next(d for d in self.snapshot.devices if d.device_id == "BF-12010193") + self.assertIn(device.state_label, detail) + self.assertIn(device.device_id, detail) + self.assertIn(device.hostname, detail) + self.assertIn(device.wave_code, detail) + self.assertIn("Kanal doğrulaması", detail) + for channel in fleet_data.CHANNELS: + with self.subTest(channel=channel.label): + self.assertIn(channel.label, detail) + self.assertIn("Doğrulanan kanal: 4/4", detail) + self.assertIn("doğrulandı", detail) + + def test_channel_status_words_are_shown_in_turkish(self) -> None: + """Every channel status word reaches the screen, not only a colour.""" + cases = { + "BF-12010193": ("doğrulandı",), # all four verified + "BF-12010194": ("bekliyor",), # RustDesk pending + "BF-12010205": ("başarısız", "bilinmiyor"), # failed + unknown + "BF-12010195": ("yok",), # never checked + } + for device_id, words in cases.items(): + with self.subTest(device=device_id): + page = self.page(f"/?cihaz={device_id}") + detail = page[page.index('data-region="detail"') :] + for word in words: + self.assertIn(f">{word}", detail) + + def test_ready_device_detail_confirms_the_gate(self) -> None: + page = self.page("/?cihaz=BF-12010193") + self.assertIn("dört kanalın dördü de doğrulandı", page) + self.assertNotIn("HAZIR için eksik kanallar", page) + + def test_not_ready_device_detail_names_the_blocking_channels(self) -> None: + page = self.page("/?cihaz=BF-12010194") + self.assertIn("HAZIR için eksik kanallar: RustDesk", page) + self.assertIn("Doğrulanan kanal: 3/4", page) + + def test_hostname_selects_the_same_device_as_device_id(self) -> None: + by_id = self.page("/?cihaz=BF-12010193") + by_host = self.page("/?cihaz=bf-12010193") + self.assertEqual(self.row_words(by_id), self.row_words(by_host)) + self.assertIn("BF-12010193", by_host[by_host.index('data-region="detail"') :]) + + def test_unknown_device_id_is_reported_and_not_invented(self) -> None: + page = self.page("/?cihaz=BF-99999999") + detail = page[page.index('data-region="detail"') :] + self.assertIn("BF-99999999", detail) + self.assertIn("envanterde yok", detail.lower()) + self.assertIn("uydurmaz", detail) + + def test_filter_is_reflected_in_the_list_and_the_header(self) -> None: + page = self.page("/?durum=HAZIR") + self.assertEqual(set(self.row_words(page)), {"HAZIR"}) + self.assertIn("Durum HAZIR", page) + self.assertIn(f"Gösterilen: {self.snapshot.ready_count}", page) + + def test_selection_outside_the_filter_is_flagged_not_hidden(self) -> None: + page = self.page("/?durum=HAZIR&cihaz=BF-12010205") + detail = page[page.index('data-region="detail"') :] + self.assertIn("BF-12010205", detail) + self.assertIn("geçerli filtreye uymuyor", detail) + + def test_empty_result_gets_a_turkish_note_and_keeps_the_header(self) -> None: + page = self.page("/?durum=YOK&dalga=PROD") + self.assertEqual(self.row_words(page), []) + self.assertIn("Bu filtreye uyan cihaz yok", page) + self.assertIn("Filtreyi temizle", page) + self.assertIn('data-region="header"', page) + + def test_wave_strip_shows_every_wave_against_its_target(self) -> None: + page = self.page("/") + counts = fleet_data.wave_counts(self.snapshot.devices) + for wave in fleet_data.WAVES: + with self.subTest(wave=wave.code): + self.assertIn( + f'{wave.code} {counts[wave.code]}/{wave.target}', + page, + ) + + def test_demo_data_is_labelled_on_the_page(self) -> None: + page = self.page("/") + self.assertIn("DEMO VERİ", page) + self.assertIn("sentetik", page) + self.assertIn("hiçbir cihazı temsil etmez", page) + self.assertIn("Veri kaynağı: sentetik demo veri", page) + + def test_inventory_file_source_is_labelled_on_the_page(self) -> None: + document = { + "schema_version": 1, + "generated_at": NOW, + "devices": [ + { + "dealer_id": "12010193", + "wave": "P1", + "state": "ENROLLED", + "channels": {"ssh_over_wg": "verified"}, + } + ], + } + snapshot = self.snapshot_from(document) + context = panel.PanelContext( + snapshot=snapshot, privilege=self.read_only, server_info=self.info + ) + page = self.page("/", context=context) + self.assertIn("ENVANTER", page) + self.assertIn(snapshot.source_path, page) + self.assertNotIn("DEMO VERİ", page) + + def test_error_source_says_veri_yok_and_never_shows_demo_data(self) -> None: + snapshot = fleet_data.load_fleet("/yok/boyle/bir/dosya.json", env={}, now=NOW) + context = panel.PanelContext( + snapshot=snapshot, privilege=self.read_only, server_info=self.info + ) + page = self.page("/", context=context) + self.assertIn("VERİ YOK", page) + self.assertIn("Demo veriye düşülmedi", page) + self.assertNotIn("DEMO VERİ", page) + self.assertEqual(self.row_words(page), []) + self.assertIn("Envanterde gösterilecek cihaz yok", page) + + def test_invalid_records_are_surfaced_with_their_reason(self) -> None: + document = { + "schema_version": 1, + "devices": [{"dealer_id": "1201019", "wave": "P1", "state": "READY"}], + } + snapshot = self.snapshot_from(document) + context = panel.PanelContext( + snapshot=snapshot, privilege=self.read_only, server_info=self.info + ) + page = self.page("/", context=context) + self.assertIn("Geçersiz kayıtlar", page) + self.assertIn("8 hane", page) + + def test_every_visible_string_is_non_empty(self) -> None: + for target in ("/", "/?durum=HAZIR", "/?cihaz=BF-99999999", "/hakkinda"): + with self.subTest(target=target): + self.assertEqual(empty_elements(self.page(target)), []) + + def test_error_pages_keep_the_frame_and_stay_readable(self) -> None: + for target, status in (("/yok", 404),): + with self.subTest(target=target): + response = self.get(target) + self.assertEqual(response.status, status) + markup = response.body.decode("utf-8") + self.assertEqual(empty_elements(markup), []) + self.assertIn('data-region="header"', markup) + + def test_no_placeholder_or_python_none_leaks_into_the_page(self) -> None: + for target in ("/", "/hakkinda", "/?cihaz=BF-12010193", "/?durum=MOR"): + with self.subTest(target=target): + page = self.page(target) + self.assertNotIn("$", page) + self.assertNotIn("None", page) + self.assertNotIn("KeyError", page) + + def test_page_renders_without_a_live_server_context(self) -> None: + """render_fleet_page is usable on its own (tests, --check, future CLI).""" + page = render.render_fleet_page(self.snapshot) + self.assertIn(' None: + page = self.page("/hakkinda") + self.assertIn(web.PANEL_VERSION, page) + self.assertIn(render.PANEL_VERSION, page) + self.assertIn("Panel sürümü", page) + self.assertIn("127.0.0.1:8787", page) + self.assertIn("Salt-okunur", page) + self.assertIn(self.read_only.reason, page) + self.assertIn(self.info.python_version, page) + + def test_reports_the_data_source_and_its_limits(self) -> None: + page = self.page("/hakkinda") + self.assertIn("Veri kaynağı", page) + self.assertIn("sentetik demo veri", page) + self.assertIn("kullanılmadı (demo veri)", page) + self.assertIn(f"{self.snapshot.ready_count} hazır", page) + self.assertIn("Okuma hatası", page) + self.assertIn("yok", page) + + def test_lists_the_vocabulary_with_tokens_and_meanings(self) -> None: + page = self.page("/hakkinda") + for spec in fleet_data.STATE_VOCABULARY: + with self.subTest(state=spec.state): + self.assertIn(spec.word, page) + self.assertIn(spec.token, page) + self.assertIn(spec.meaning, page) + + def test_lists_every_wave_and_channel(self) -> None: + page = self.page("/hakkinda") + for wave in fleet_data.WAVES: + with self.subTest(wave=wave.code): + self.assertIn(wave.code, page) + for channel in fleet_data.CHANNELS: + with self.subTest(channel=channel.key): + self.assertIn(channel.key, page) + self.assertIn(channel.label, page) + + def test_states_the_panel_rules(self) -> None: + page = self.page("/hakkinda") + self.assertGreaterEqual(len(render.PANEL_RULES), 5) + for rule in render.PANEL_RULES: + with self.subTest(rule=rule[:30]): + self.assertTrue(rule.strip()) + self.assertIn(rule[:24], page) + self.assertIn("Panel kuralları", page) + + def test_full_privilege_level_is_distinct_and_honest(self) -> None: + page = self.page("/", context=self.full_context) + self.assertIn("badge--privilege-full", page) + self.assertIn("Tam yetki", page) + self.assertIn("yazma uç noktası uygulanmadı", page) + self.assertNotIn("badge--privilege-read-only", page) + + def test_about_page_has_no_external_url(self) -> None: + page = self.page("/hakkinda") + self.assertNotIn("http://", page) + self.assertNotIn("https://", page) + + +# -------------------------------------------------------------------------- +# routing and the privilege gate +# -------------------------------------------------------------------------- + + +class TestRoutingAndGate(PanelCase): + def test_only_read_methods_are_served(self) -> None: + for method in ("POST", "PUT", "PATCH", "DELETE", "OPTIONS", "TRACE"): + with self.subTest(method=method): + response = self.get("/", method) + self.assertEqual(response.status, 405) + markup = response.body.decode("utf-8") + self.assertIn("Yöntem reddedildi", markup) + self.assertIn(method, markup) + self.assertIn("yalnız okuma yapar", markup) + headers = dict(response.headers) + self.assertEqual(headers.get("Allow"), "GET, HEAD") + + def test_write_methods_are_refused_even_with_full_privilege(self) -> None: + response = self.get("/", "POST", self.full_context) + self.assertEqual(response.status, 405) + + def test_head_returns_the_same_length_without_a_body(self) -> None: + head = self.get("/", "HEAD") + get = self.get("/", "GET") + self.assertEqual(head.status, 200) + self.assertEqual(head.body, b"") + self.assertEqual(head.content_length, get.content_length) + self.assertEqual(head.content_type, get.content_type) + + def test_static_assets_are_served_from_disk_byte_for_byte(self) -> None: + for name in ("tokens.css", "app.css"): + with self.subTest(name=name): + response = self.get(f"/static/{name}") + self.assertEqual(response.status, 200) + self.assertEqual(response.content_type, "text/css; charset=utf-8") + self.assertEqual(response.body, (STATIC_DIR / name).read_bytes()) + + def test_unknown_static_asset_is_a_404(self) -> None: + response = self.get("/static/gizli.txt") + self.assertEqual(response.status, 404) + self.assertIn("Sayfa yok", response.body.decode("utf-8")) + + def test_path_traversal_is_refused(self) -> None: + for target in ( + "/static/../server.py", + "/static/..%2fserver.py", + "/static/%2e%2e/fleet_data.py", + "/../web/server.py", + "/static/.%2e/%2e%2e/etc/passwd", + ): + with self.subTest(target=target): + response = self.get(target) + self.assertEqual(response.status, 404) + body = response.body.decode("utf-8") + self.assertNotIn("BaseHTTPRequestHandler", body) + self.assertNotIn("route(context", body) + + def test_unknown_path_is_a_turkish_404(self) -> None: + response = self.get("/boyle-bir-sayfa-yok") + self.assertEqual(response.status, 404) + markup = response.body.decode("utf-8") + self.assertIn("Sayfa yok", markup) + self.assertIn("/boyle-bir-sayfa-yok", markup) + + def test_security_headers_keep_the_panel_local_and_script_free(self) -> None: + headers = dict(self.get("/").headers) + self.assertEqual(headers["X-Content-Type-Options"], "nosniff") + self.assertEqual(headers["Referrer-Policy"], "no-referrer") + csp = headers["Content-Security-Policy"] + self.assertIn("default-src 'self'", csp) + self.assertIn("script-src 'none'", csp) + self.assertIn("form-action 'self'", csp) + self.assertNotIn("http", csp) + self.assertEqual(headers["Cache-Control"], "no-store") + + def test_route_is_pure_and_repeatable(self) -> None: + targets = ("/", "/hakkinda", "/static/app.css", "/?durum=HAZIR&q=1201", "/yok", "/static/../x") + for target in targets: + with self.subTest(target=target): + first = self.get(target) + second = self.get(target) + self.assertEqual(first, second) + + def test_bogus_query_values_do_not_crash_the_page(self) -> None: + for target in ( + "/?q=&durum=&dalga=&cihaz=&hazir_degil=abc", + "/?durum=READY&durum=YOK", + "/?q=%C4%B0stanbul%20%2F%20test", + "/?cihaz=" + "x" * 500, + ): + with self.subTest(target=target[:40]): + response = self.get(target) + self.assertEqual(response.status, 200) + self.assertEqual(empty_elements(response.body.decode("utf-8")), []) + + def test_a_full_sweep_of_every_endpoint_writes_nothing(self) -> None: + """Read-only is a property of the surface, not a promise in a docstring.""" + before = tree_fingerprint(WEB_DIR) + requests = ( + ("GET", "/"), + ("GET", "/filo"), + ("GET", "/fleet"), + ("GET", "/hakkinda"), + ("GET", "/hakkinda/"), + ("GET", "/about"), + ("GET", "/static/tokens.css"), + ("GET", "/static/app.css"), + ("GET", "/?q=1201&durum=HAZIR&dalga=P1&hazir_degil=1&cihaz=BF-12010193"), + ("GET", "/yok"), + ("POST", "/"), + ("PUT", "/hakkinda"), + ("DELETE", "/static/app.css"), + ("HEAD", "/"), + ) + for method, target in requests: + with self.subTest(method=method, target=target): + response = self.get(target, method) + self.assertIn(response.status, (200, 404, 405)) + self.assertEqual(tree_fingerprint(WEB_DIR), before) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..82841ee --- /dev/null +++ b/web/README.md @@ -0,0 +1,107 @@ +# Blueforce Field OS — Filo Paneli (merkez web paneli) + +Operatörün ofis tarayıcısından filoyu gördüğü yüzey: **hangi cihaz kuruldu, +hangi yayılım dalgasında ve sıradaki tek iş ne**. Cihaz başına operatör arayüzü +`sudo bf` (tkinter konsolu, `bfos/`) ile aynı filoyu iki uçtan anlatır; iki yüzey +tek durum sözlüğünü paylaşır (`docs/DESIGN.md`). + +## Neden yalnız Python standart kütüphanesi + +Filo çevrimdışı kurulur ve paketler commit'lenmiş havuzdan gelir; `fastapi`, +`uvicorn`, `flask`, `jinja2` bu havuzda **yok**. Panel bunları import etse +yönettiği cihazlara kurulamaz (bkz. `PRODUCT.md`, "Stack"; karar kullanıcıyla +birlikte alındı). Bu yüzden: `http.server` + `string.Template`, harici CDN veya +webfont yok, tüm varlıklar yerel. + +## Çalıştırma + +```bash +# 1) Hiçbir şey vermeden: 127.0.0.1:8787, etiketli DEMO VERİ, salt-okunur +python3 -m web.server +# (python3 web/server.py de aynı şekilde çalışır) + +# 2) Gerçek envanter dosyasıyla +python3 -m web.server --data /var/lib/blueforce/inventory/fleet.json + +# 3) Sunucuyu başlatmadan veri kaynağını denetle (CI/operatör kontrolü) +python3 -m web.server --check --data ./web/data/fleet.json + +# 4) Tam yetki (bu sürümde yazma uç noktası yoktur; yalnız arayüz farkı) +python3 -m web.server --privilege full +``` + +Seçenekler: `--host` (varsayılan `127.0.0.1`), `--port` (varsayılan `8787`), +`--data`, `--privilege {read-only,full}`, `--check`, `--version`. +Panel varsayılan olarak **yalnız yerel arayüze** bağlanır. + +## Uç noktalar (hepsi okuma) + +| Yol | Ne döner | +|---|---| +| `/` (veya `/filo`) | İlk ekran: başlık bandı, durum özeti, cihaz listesi, seçili cihaz ayrıntısı | +| `/?q=&durum=&dalga=&hazir_degil=1&cihaz=` | Aynı ekran, sunucu tarafında filtrelenmiş (JavaScript yok) | +| `/hakkinda` | Panelin sürümü, veri kaynağı, durum sözlüğü, dalgalar, kanallar ve panel kuralları | +| `/static/tokens.css`, `/static/app.css` | Yerel stil dosyaları (allow-list; listeye eklenmeyen dosya 404) | + +`GET` ve `HEAD` dışındaki yöntemler **405** ile ve Türkçe gerekçeyle reddedilir. +Hiçbir uç nokta komut çalıştırmaz, dosya yazmaz veya cihazda değişiklik yapmaz. + +## Veri sözleşmesi (`schema_version: 1`) + +```json +{ + "schema_version": 1, + "generated_at": "2026-09-16T09:30:00+03:00", + "devices": [ + { + "dealer_id": "12010193", + "wave": "P1", + "state": "READY", + "last_seen": "2026-09-16T09:05:00+03:00", + "channels": { + "ssh_over_wg": {"status": "verified", "checked_at": "2026-09-14T10:00:00+03:00"}, + "xrdp": "pending", + "rustdesk": "verified", + "meshcentral": "verified" + } + } + ] +} +``` + +- `dealer_id`: 8 hane (`^[0-9]{8}$`). Cihaz kimliği ve hostname **türetilir**: + `BF-` / `bf-` (`docs/02`). Dosyada `device_id`/`hostname` de varsa + yalnız kurala uyduğunda kabul edilir. +- `wave`: `LAB`, `P1`, `P2`, `W1`, `W2`, `PROD` (Ansible grup adları da kabul: + `lab`, `pilot_1`, `wave_1`, `production`). +- `state`: `PROVISIONED_OFFLINE`, `ENROLLED`, `READY`, `FAILED`, `ABSENT` + (Türkçe kelimeler de kabul: `KURULDU`, `KAYITLI`, `HAZIR`, `HATA`, `YOK`). +- `channels`: dört kanal (`ssh_over_wg`, `xrdp`, `rustdesk`, `meshcentral`), + durum `verified` / `pending` / `failed` / `unknown` / `missing`. +- Örnek dosya: `web/data/fleet.example.json`. + +## Panelin uyguladığı kurallar (fail-closed) + +1. **HAZIR kapısı:** `READY` iddiası dört kanal da `verified` olmadan reddedilir; + cihaz `HATA` olarak ve gerekçesiyle gösterilir (`docs/27`). Panel bir iddiayı + uyarıya yumuşatmaz. +2. **Başarısız kanal:** denetimi başarısız (`failed`) bir kanal varsa cihaz + `HATA` sayılır — cihaz bir insan bekliyor demektir. +3. **Yükseltme yok:** panel hiçbir cihazın durumunu ilerletmez; `KAYITLI`yı + kendiliğinden `HAZIR` yapmaz. +4. **Geçersiz kayıt görünür:** kimliği kurulamayan kayıt (hatalı bayi no, + yinelenen bayi no, bilinmeyen dalga) sessizce düşürülmez; sayfada + "Geçersiz kayıtlar" altında nedeniyle listelenir. +5. **Bozuk veri demo veriye dönüşmez:** `--data`/`BF_FLEET_DATA` ile verilen + dosya yoksa, JSON değilse veya şema sürümü desteklenmiyorsa panel `VERİ YOK` + der; uydurmaz. + +## Test + +```bash +python3 tests/test_web_static.py -v # statik + sözleşme testleri (port açmaz) +bash tests/run-all.sh # depo genelindeki tüm testler +``` + +Testler HTTP yüzeyini `web/panel.py:route()` (saf fonksiyon) üzerinden kapsar; +bu yüzden hiçbir test gerçek port bağlamaz, tarayıcı açmaz. diff --git a/web/__init__.py b/web/__init__.py new file mode 100644 index 0000000..6cf3ea4 --- /dev/null +++ b/web/__init__.py @@ -0,0 +1,46 @@ +"""Blueforce Field OS — central fleet panel (Python standard library only). + +The operator surface of the fleet: one browser page that answers "which devices +are installed, which rollout wave are they in, and what is the one next action". +It is the companion of the on-device console (``bfos``, tkinter): both surfaces +describe the same fleet from opposite ends and share one status vocabulary +(docs/DESIGN.md). + +Why the standard library and nothing else: the fleet installs offline from a +committed package pool, so ``fastapi``/``uvicorn``/``flask`` are simply not +installable on the devices this panel manages (PRODUCT.md, "Stack"). No template +engine, no webfont and no CDN either -- the panel is one directory of Python and +local assets. + +Modules +------- +``fleet_data`` inventory model, the PROVISIONED_OFFLINE -> ENROLLED -> READY + rules, filtering and sorting (no I/O beyond reading one file). +``render`` server-rendered HTML pages from local templates. +``panel`` request routing and the privilege gate (read-only by default). +``server`` ``http.server`` glue and the command line. +""" + +from __future__ import annotations + +__all__ = [ + "PANEL_NAME", + "PANEL_SURFACE", + "PANEL_TITLE", + "PANEL_VERSION", + "DEFAULT_PORT", + "DEFAULT_HOST", +] + +# Product voice comes from PRODUCT.md; the surface name is what the operator +# reads in the header band (docs/DESIGN.md FIRST VIEWPORT). +PANEL_NAME = "Blueforce Field OS" +PANEL_SURFACE = "Filo Paneli" +PANEL_TITLE = f"{PANEL_NAME} — {PANEL_SURFACE}" + +PANEL_VERSION = "0.1.0" + +# The panel is an operator tool on an office workstation, reached over +# WireGuard; it binds to the loopback interface unless told otherwise. +DEFAULT_HOST = "127.0.0.1" +DEFAULT_PORT = 8787 diff --git a/web/data/fleet.example.json b/web/data/fleet.example.json new file mode 100644 index 0000000..e9a9637 --- /dev/null +++ b/web/data/fleet.example.json @@ -0,0 +1,66 @@ +{ + "_aciklama": "Filo envanteri örneği. Bu dosya bir şablondur; panel varsayılan olarak web/data/fleet.json okur. Alanlar docs/02 (kimlik) ve docs/27 (durum makinesi) sözleşmesine uyar. Örnek cihazlar sentetiktir.", + "schema_version": 1, + "generated_at": "2026-09-16T09:30:00+03:00", + "source": "merkezi envanter (örnek)", + "devices": [ + { + "dealer_id": "12010193", + "wave": "P1", + "state": "READY", + "last_seen": "2026-09-16T09:05:00+03:00", + "note": "Pilot cihaz; dört kanal da doğrulandı.", + "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"} + } + }, + { + "dealer_id": "12010194", + "wave": "pilot_1", + "state": "ENROLLED", + "last_seen": "2026-09-16T09:04:00+03:00", + "channels": { + "ssh_over_wg": "verified", + "xrdp": "verified", + "rustdesk": "pending", + "meshcentral": "verified" + } + }, + { + "dealer_id": "12010195", + "wave": "P1", + "state": "PROVISIONED_OFFLINE", + "last_seen": "2026-09-13T16:20:00+03:00", + "channels": {} + }, + { + "dealer_id": "12010205", + "wave": "P2", + "state": "ENROLLED", + "last_seen": "2026-09-16T06:20:00+03:00", + "channels": { + "ssh_over_wg": "verified", + "xrdp": "failed", + "rustdesk": "verified", + "meshcentral": "pending" + } + }, + { + "dealer_id": "12010208", + "wave": "wave_1", + "state": "ABSENT", + "last_seen": null, + "channels": {} + }, + { + "dealer_id": "12010210", + "wave": "PROD", + "state": "PROVISIONED_OFFLINE", + "last_seen": "2026-09-09T09:00:00+03:00", + "channels": {} + } + ] +} diff --git a/web/fleet_data.py b/web/fleet_data.py new file mode 100644 index 0000000..f239277 --- /dev/null +++ b/web/fleet_data.py @@ -0,0 +1,1083 @@ +"""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-`` for the + human surface and ``bf-`` 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 » 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 diff --git a/web/panel.py b/web/panel.py new file mode 100644 index 0000000..0d7ae76 --- /dev/null +++ b/web/panel.py @@ -0,0 +1,162 @@ +"""Request routing and the privilege gate for the central panel (stdlib only). + +``route`` is a pure function: it takes the panel context, an HTTP method and a +request target, and returns a ``Response``. Nothing in this module starts a +process, writes a file or opens an outbound connection -- the panel's read-only +claim is structural, not a promise in a comment: + +* only ``GET`` and ``HEAD`` are served; every other method is answered with 405 + and a Turkish explanation, never by an action; +* static assets are served from an explicit allow-list, so a request can never + walk the filesystem (``/static/../server.py`` is a 404); +* an unknown path is a Turkish 404, not a redirect anywhere. + +Because ``route`` is pure, tests exercise the whole HTTP surface without binding +a port. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from urllib.parse import parse_qs, unquote + +from . import render +from .fleet_data import FleetSnapshot +from .render import Privilege, ServerInfo + +#: Local assets only. Adding a name here is the only way to serve a new file. +STATIC_FILES: dict[str, str] = { + "tokens.css": "text/css; charset=utf-8", + "app.css": "text/css; charset=utf-8", +} + +READ_METHODS: tuple[str, ...] = ("GET", "HEAD") + +#: Response headers that keep an air-gapped panel inert: no script runs, no +#: remote origin is reachable, and the browser is told not to sniff types. +SECURITY_HEADERS: tuple[tuple[str, str], ...] = ( + ("X-Content-Type-Options", "nosniff"), + ("Referrer-Policy", "no-referrer"), + ( + "Content-Security-Policy", + "default-src 'self'; img-src 'self' data:; style-src 'self'; " + "script-src 'none'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'", + ), +) + + +@dataclass(frozen=True) +class Response: + """One HTTP answer, fully decided before the socket sees it.""" + + status: int + content_type: str + body: bytes + content_length: int + headers: tuple[tuple[str, str], ...] = () + + @property + def is_html(self) -> bool: + return self.content_type.startswith("text/html") + + +@dataclass(frozen=True) +class PanelContext: + """Everything a request needs: the snapshot, the privilege level, the facts.""" + + snapshot: FleetSnapshot + privilege: Privilege + server_info: ServerInfo + static_dir: Path = render.STATIC_DIR + + +def route(context: PanelContext, method: str, target: str) -> Response: + """Decide one request. Pure: same inputs, same bytes, no side effects.""" + verb = (method or "").upper() + raw_path, _, query_string = target.partition("?") + + if verb not in READ_METHODS: + return _html( + 405, + render.render_refused_page( + verb or "BİLİNMİYOR", + snapshot=context.snapshot, + privilege=context.privilege, + server_info=context.server_info, + ), + verb, + extra_headers=(("Allow", ", ".join(READ_METHODS)),), + ) + + decoded = unquote(raw_path) + if ".." in decoded or "\x00" in decoded: + return _not_found(context, decoded, verb) + + params = parse_qs(query_string, keep_blank_values=True) + + if decoded in ("/", "/filo", "/fleet"): + page = render.render_fleet_page( + context.snapshot, + params, + privilege=context.privilege, + server_info=context.server_info, + ) + return _html(200, page, verb) + + if decoded in ("/hakkinda", "/hakkinda/", "/about"): + page = render.render_about_page( + context.snapshot, + privilege=context.privilege, + server_info=context.server_info, + ) + return _html(200, page, verb) + + if decoded.startswith("/static/"): + return _static(context, decoded[len("/static/"):], verb) + + return _not_found(context, decoded, verb) + + +def _html( + status: int, + page: str, + verb: str, + *, + extra_headers: tuple[tuple[str, str], ...] = (), +) -> Response: + body = page.encode("utf-8") + headers = SECURITY_HEADERS + (("Cache-Control", "no-store"),) + extra_headers + if verb == "HEAD": + return Response(status, "text/html; charset=utf-8", b"", len(body), headers) + return Response(status, "text/html; charset=utf-8", body, len(body), headers) + + +def _not_found(context: PanelContext, request_path: str, verb: str) -> Response: + page = render.render_not_found_page( + request_path, + snapshot=context.snapshot, + privilege=context.privilege, + server_info=context.server_info, + ) + return _html(404, page, verb) + + +def _static(context: PanelContext, name: str, verb: str) -> Response: + # Allow-list first: anything not named here is a 404, so no path trick can + # reach a file this panel did not decide to publish. + content_type = STATIC_FILES.get(name) + if content_type is None: + return _not_found(context, f"/static/{name}", verb) + + path = context.static_dir / name + if not path.is_file(): + return _not_found(context, f"/static/{name}", verb) + + body = path.read_bytes() + headers = SECURITY_HEADERS + ( + ("Cache-Control", "no-cache"), + ) + if verb == "HEAD": + return Response(200, content_type, b"", len(body), headers) + return Response(200, content_type, body, len(body), headers) diff --git a/web/render.py b/web/render.py new file mode 100644 index 0000000..26c14be --- /dev/null +++ b/web/render.py @@ -0,0 +1,717 @@ +"""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(), + ) diff --git a/web/server.py b/web/server.py new file mode 100644 index 0000000..6c0a782 --- /dev/null +++ b/web/server.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +"""HTTP glue and command line for the central Blueforce panel (stdlib only). + + python3 -m web.server # loopback, demo data, read-only + python3 -m web.server --data ./fleet.json # a real inventory document + python3 -m web.server --port 9100 --privilege full + python3 -m web.server --check # verify the data source, no bind + +``http.server`` is the whole server: ``fastapi``/``uvicorn``/``flask`` are not in +the committed offline package pool, so the panel cannot ship with them +(PRODUCT.md, "Stack"). The routing itself lives in ``web/panel.py`` and is a pure +function, which is why the test suite can cover every endpoint without binding a +port. +""" + +from __future__ import annotations + +import argparse +import http.server +import sys +from pathlib import Path +from typing import Sequence + +try: # `python3 -m web.server` + from . import ( + DEFAULT_HOST, + DEFAULT_PORT, + PANEL_NAME, + PANEL_SURFACE, + PANEL_TITLE, + PANEL_VERSION, + ) + from . import fleet_data, render + from .panel import PanelContext, Response, route +except ImportError: # `python3 web/server.py` + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + from web import ( # type: ignore[no-redef] + DEFAULT_HOST, + DEFAULT_PORT, + PANEL_NAME, + PANEL_SURFACE, + PANEL_TITLE, + PANEL_VERSION, + ) + from web import fleet_data, render # type: ignore[no-redef] + from web.panel import PanelContext, Response, route # type: ignore[no-redef] + +__all__ = [ + "PanelRequestHandler", + "build_context", + "main", + "make_server", + "parse_args", +] + + +class PanelRequestHandler(http.server.BaseHTTPRequestHandler): + """One handler for every method: the panel answers, it never acts. + + Only the two read methods can produce a page; everything else is refused by + ``route`` with 405 and the connection is closed, so a write request cannot + leave half-read state behind. + """ + + server_version = f"BlueforcePanel/{PANEL_VERSION}" + protocol_version = "HTTP/1.1" + + def do_GET(self) -> None: # noqa: N802 - stdlib naming + self._respond("GET") + + def do_HEAD(self) -> None: # noqa: N802 - stdlib naming + self._respond("HEAD") + + def do_POST(self) -> None: # noqa: N802 - stdlib naming + self._respond("POST") + + def do_PUT(self) -> None: # noqa: N802 - stdlib naming + self._respond("PUT") + + def do_PATCH(self) -> None: # noqa: N802 - stdlib naming + self._respond("PATCH") + + def do_DELETE(self) -> None: # noqa: N802 - stdlib naming + self._respond("DELETE") + + def do_OPTIONS(self) -> None: # noqa: N802 - stdlib naming + self._respond("OPTIONS") + + def do_TRACE(self) -> None: # noqa: N802 - stdlib naming + self._respond("TRACE") + + # -- internals --------------------------------------------------------- + + def _respond(self, method: str) -> None: + context: PanelContext = self.server.context # type: ignore[attr-defined] + response: Response = route(context, method, self.path) + self.send_response(response.status) + self.send_header("Content-Type", response.content_type) + for name, value in response.headers: + self.send_header(name, value) + self.send_header("Content-Length", str(response.content_length)) + self.end_headers() + if method != "HEAD" and response.body: + self.wfile.write(response.body) + if method not in ("GET", "HEAD"): + self.close_connection = True + + def log_message(self, format: str, *args: object) -> None: # noqa: A002 + sys.stderr.write("[panel] %s %s\n" % (self.address_string(), format % args)) + + +def make_server(context: PanelContext, host: str, port: int) -> http.server.ThreadingHTTPServer: + """Bind the panel. Threaded so a slow page never blocks the operator.""" + server = http.server.ThreadingHTTPServer((host, port), PanelRequestHandler) + server.context = context # type: ignore[attr-defined] + return server + + +def build_context(args: argparse.Namespace) -> PanelContext: + """Load the inventory and describe the running panel.""" + snapshot = fleet_data.load_fleet(args.data) + privilege = render.privilege_from_level(args.privilege) + server_info = render.ServerInfo( + host=args.host, + port=args.port, + privilege=privilege, + python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}", + started_at=fleet_data.timestamp_now(), + data_path=args.data, + ) + return PanelContext(snapshot=snapshot, privilege=privilege, server_info=server_info) + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + prog="web/server.py", + description=f"{PANEL_TITLE} — filo yönetim paneli (yalnız okuma).", + ) + parser.add_argument( + "--host", + default=DEFAULT_HOST, + help=f"Dinlenecek adres (varsayılan {DEFAULT_HOST}: yalnız yerel arayüz).", + ) + parser.add_argument( + "--port", + type=int, + default=DEFAULT_PORT, + help=f"Dinlenecek port (varsayılan {DEFAULT_PORT}).", + ) + parser.add_argument( + "--data", + default=None, + help=( + "Filo envanteri JSON dosyası. Verilmezse ve varsayılan konum boşsa " + "etiketli demo veri gösterilir; verilir de okunamazsa panel demo veriye " + "düşmez, «VERİ YOK» der." + ), + ) + parser.add_argument( + "--privilege", + choices=(render.PRIVILEGE_READ_ONLY_LEVEL, render.PRIVILEGE_FULL_LEVEL), + default=render.PRIVILEGE_READ_ONLY_LEVEL, + help="Yetki düzeyi: salt-okunur (varsayılan) ya da tam yetki.", + ) + parser.add_argument( + "--check", + action="store_true", + help="Sunucuyu başlatmadan veri kaynağını denetle ve Türkçe durum yazıp çık.", + ) + parser.add_argument( + "--version", + action="version", + version=f"{PANEL_TITLE} {PANEL_VERSION}", + ) + return parser.parse_args(argv) + + +def _print_status(context: PanelContext) -> None: + snapshot = context.snapshot + print(f"{PANEL_NAME} — {PANEL_SURFACE} {PANEL_VERSION}") + print(f"Veri kaynağı : {snapshot.source_label}") + print(f"Veri durumu : {snapshot.banner_word} — {snapshot.banner_text}") + print( + f"Cihazlar : {snapshot.total} cihaz · hazır {snapshot.ready_count} · " + f"hazır değil {snapshot.not_ready_count} · geçersiz kayıt {len(snapshot.invalid)}" + ) + print(f"Yetki : {context.privilege.word} — {context.privilege.reason}") + print(f"Adres : {context.server_info.bind_address} (yalnız okuma, yerel arayüz)") + + +def main(argv: Sequence[str] | None = None) -> int: + args = parse_args(argv) + context = build_context(args) + _print_status(context) + + if args.check: + print("Denetim tamam; sunucu başlatılmadı (--check).") + return 0 + + server = make_server(context, args.host, args.port) + print("Durdurmak için Ctrl+C.") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nPanel durduruldu.") + finally: + server.server_close() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web/static/app.css b/web/static/app.css new file mode 100644 index 0000000..0fa0904 --- /dev/null +++ b/web/static/app.css @@ -0,0 +1,660 @@ +/* + * Blueforce Field OS — central panel stylesheet. + * --------------------------------------------------------------------------- + * Colour and metrics come from web/static/tokens.css and from nowhere else. + * This file declares no custom property and no literal colour: one palette + * change in tokens.css moves both operator surfaces. A colour here is a state + * or the action accent, never decoration (docs/DESIGN.md, Rules 1 and 2). + * + * Layout follows docs/DESIGN.md FIRST VIEWPORT: dark header band, one large + * figure in the summary strip, then the fleet list beside the selected device's + * detail region. + */ + +*, *::before, *::after { box-sizing: border-box; } + +html { background: var(--bg-app); } + +body { + margin: 0; + background: var(--bg-app); + color: var(--text); + font-family: var(--font-ui); + font-size: var(--size-md); + line-height: var(--leading-normal); +} + +a { color: var(--accent); } +a:hover { color: var(--accent-hover); } + +.mono { font-family: var(--font-data); font-variant-numeric: tabular-nums; } + +:focus-visible { + outline: 2px solid var(--focus-ring); + outline-offset: 2px; +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} + +.skip-link { + position: absolute; + left: var(--sp-2); + top: calc(-1 * var(--sp-7)); + z-index: 2; + padding: var(--sp-2) var(--sp-3); + background: var(--bg-panel); + color: var(--accent); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + transition: top .1s linear; +} + +.skip-link:focus { top: var(--sp-2); } + +/* -- Header band: the one dark region ---------------------------------- */ + +.app-header { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--sp-2) var(--sp-5); + min-height: var(--header-h); + padding: var(--sp-2) var(--sp-4); + background: var(--bg-header); + color: var(--text-on-dark); +} + +.app-header__identity { + display: flex; + flex-direction: column; +} + +.app-header__product { + margin: 0; + font-size: var(--size-lg); + font-weight: 600; + line-height: var(--leading-tight); + letter-spacing: .01em; +} + +.app-header__surface { + font-size: var(--size-xs); + color: var(--text-on-dark); + opacity: .75; +} + +.app-header__target, +.app-header__privilege { + display: flex; + flex-direction: column; + gap: var(--sp-1); + margin: 0; + font-size: var(--size-sm); +} + +.app-header__label { + font-size: var(--size-xs); + letter-spacing: .06em; + text-transform: uppercase; + color: var(--text-on-dark); + opacity: .7; +} + +.app-header__value { color: var(--text-on-dark); } + +.app-header__reason { + max-width: 34ch; + font-size: var(--size-xs); + color: var(--text-on-dark); + opacity: .8; +} + +.app-header__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--sp-2); +} + +.app-header__privilege { + max-width: 40ch; + text-align: right; +} + +.gated-reason { + max-width: 30ch; + font-size: var(--size-xs); + color: var(--text-on-dark); + opacity: .8; +} + +.app-header__nav { + display: flex; + gap: var(--sp-3); + margin-right: auto; +} + +.nav-link { + color: var(--text-on-dark); + font-size: var(--size-sm); + text-decoration: none; + padding-bottom: var(--sp-1); + border-bottom: 2px solid transparent; +} + +.nav-link[aria-current="page"] { border-bottom-color: var(--text-on-dark); } + +/* -- Buttons and inputs: one height ------------------------------------ */ + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + height: var(--control-h); + padding: 0 var(--sp-3); + font-family: inherit; + font-size: var(--size-sm); + font-weight: 600; + text-decoration: none; + color: var(--text); + background: var(--bg-panel); + border: 1px solid var(--border-strong); + border-radius: var(--radius); + cursor: pointer; +} + +.button--primary { + color: var(--text-on-accent); + background: var(--accent); + border-color: var(--accent); +} + +.button--primary:hover { + color: var(--text-on-accent); + background: var(--accent-hover); +} + +.button--gated:disabled { + color: var(--text-faint); + background: var(--bg-sunken); + border-color: var(--border); + cursor: not-allowed; +} + +input[type="search"], +select { + height: var(--control-h); + padding: 0 var(--sp-2); + font-family: inherit; + font-size: var(--size-sm); + color: var(--text); + background: var(--bg-panel); + border: 1px solid var(--border-strong); + border-radius: var(--radius); +} + +/* -- Page frame -------------------------------------------------------- */ + +.app-main { + display: flex; + flex-direction: column; + gap: var(--sp-4); + padding: var(--sp-4); + max-width: 1600px; + margin: 0 auto; +} + +.app-footer { + display: flex; + flex-wrap: wrap; + gap: var(--sp-4); + padding: var(--sp-3) var(--sp-4); + font-size: var(--size-xs); + color: var(--text-muted); + border-top: 1px solid var(--border); +} + +.section-title { + margin: 0 0 var(--sp-2); + font-size: var(--size-xl); + line-height: var(--leading-tight); +} + +.subsection-title { + margin: 0 0 var(--sp-2); + font-size: var(--size-lg); + line-height: var(--leading-tight); +} + +.section-note { + margin: 0 0 var(--sp-2); + font-size: var(--size-sm); + color: var(--text-muted); +} + +.cell-note { + font-size: var(--size-xs); + color: var(--text-muted); +} + +/* -- Data-source banner: what the operator is looking at --------------- */ + +.data-banner { + display: flex; + align-items: baseline; + gap: var(--sp-3); + padding: var(--sp-2) var(--sp-3); + font-size: var(--size-sm); + border: 1px solid var(--border-strong); + border-radius: var(--radius); +} + +.data-banner--info { background: var(--info-soft); border-color: var(--info); } +.data-banner--warn { background: var(--warn-soft); border-color: var(--warn); } +.data-banner--danger { background: var(--danger-soft); border-color: var(--danger); } + +.data-banner__word { + font-size: var(--size-xs); + font-weight: 700; + letter-spacing: .06em; +} + +/* -- Summary strip: the one loud figure -------------------------------- */ + +.summary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--sp-4) var(--sp-6); + padding: var(--sp-4); + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-sm); +} + +.summary__lead { + display: grid; + gap: var(--sp-1); + padding-right: var(--sp-5); + border-right: 1px solid var(--border); +} + +.summary__lead-figure { + font-size: var(--size-2xl); + font-weight: 700; + line-height: var(--leading-tight); + color: var(--state-ready); +} + +.summary__lead-label { font-size: var(--size-sm); font-weight: 600; } + +.summary__lead-note { font-size: var(--size-xs); color: var(--text-muted); } + +.summary__grid { + display: flex; + flex-wrap: wrap; + gap: var(--sp-5); + margin: 0; + padding: 0; + list-style: none; +} + +.summary__cell { display: grid; gap: var(--sp-1); } + +.summary__cell-value { + font-size: var(--size-lg); + font-weight: 600; + line-height: var(--leading-tight); +} + +.summary__cell-word { + font-size: var(--size-xs); + font-weight: 700; + letter-spacing: .04em; +} + +.summary__cell-note { font-size: var(--size-xs); color: var(--text-muted); } + +/* -- Two-column body: list beside detail ------------------------------- */ + +.split { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(320px, 400px); + gap: var(--sp-4); + align-items: start; +} + +@media (max-width: 1100px) { + .split { grid-template-columns: minmax(0, 1fr); } +} + +.split__list, +.split__detail { + padding: var(--sp-4); + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-lg); +} + +/* -- Filter bar -------------------------------------------------------- */ + +.filter-bar { + display: grid; + gap: var(--sp-2); + margin-bottom: var(--sp-3); + padding: var(--sp-3); + background: var(--bg-sunken); + border-radius: var(--radius); +} + +.filter-bar__form { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: var(--sp-3); + margin: 0; +} + +.filter-field { + display: grid; + gap: var(--sp-1); + margin: 0; + font-size: var(--size-xs); + color: var(--text-muted); +} + +.filter-field--check { + grid-auto-flow: column; + align-items: center; + gap: var(--sp-2); + height: var(--control-h); +} + +.filter-field--actions { + grid-auto-flow: column; + align-items: center; + gap: var(--sp-2); + font-size: var(--size-sm); +} + +.filter-bar__summary { + margin: 0; + font-size: var(--size-xs); + color: var(--text-muted); +} + +.wave-strip { + margin: 0 0 var(--sp-3); + font-size: var(--size-xs); + color: var(--text-muted); +} + +.wave-strip__item { white-space: nowrap; } + +/* -- Fleet table ------------------------------------------------------- */ + +.fleet-table { + width: 100%; + border-collapse: collapse; + font-size: var(--size-sm); +} + +.fleet-table th { + padding: var(--sp-2); + text-align: left; + font-size: var(--size-xs); + font-weight: 600; + letter-spacing: .04em; + text-transform: uppercase; + color: var(--text-muted); + border-bottom: 1px solid var(--border-strong); +} + +.fleet-table td { + height: var(--row-h); + padding: var(--sp-2); + border-bottom: 1px solid var(--border); + vertical-align: middle; +} + +.fleet-row:hover td { background: var(--bg-sunken); } + +.fleet-row--selected td { background: var(--accent-soft); } + +.row-select { text-decoration: none; } +.row-select:hover { text-decoration: underline; } + +/* -- Badges: a word plus a state colour -------------------------------- */ + +.badge { + display: inline-flex; + align-items: center; + padding: var(--sp-1) var(--sp-2); + font-family: var(--font-data); + font-size: var(--size-xs); + font-weight: 700; + letter-spacing: .04em; + border: 1px solid; + border-radius: var(--radius); +} + +.badge--state-provisioned { + color: var(--state-provisioned); + background: var(--info-soft); + border-color: var(--state-provisioned); +} + +.badge--state-enrolled { + color: var(--state-enrolled); + background: var(--warn-soft); + border-color: var(--state-enrolled); +} + +.badge--state-ready { + color: var(--state-ready); + background: var(--ok-soft); + border-color: var(--state-ready); +} + +.badge--state-failed { + color: var(--state-failed); + background: var(--danger-soft); + border-color: var(--state-failed); +} + +.badge--state-absent { + color: var(--state-absent); + background: var(--idle-soft); + border-color: var(--state-absent); +} + +.badge--privilege-read-only { + color: var(--text-on-dark); + background: transparent; + border-color: var(--text-on-dark); +} + +.badge--privilege-full { + color: var(--warn); + background: var(--warn-soft); + border-color: var(--warn); +} + +/* -- Detail region ----------------------------------------------------- */ + +.detail__state { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--sp-2); + margin: 0 0 var(--sp-3); +} + +.detail__meaning { font-size: var(--size-sm); color: var(--text-muted); } + +.selection-note, +.reason { + margin: 0 0 var(--sp-3); + padding: var(--sp-2) var(--sp-3); + font-size: var(--size-sm); + border: 1px solid; + border-radius: var(--radius); +} + +.selection-note { + background: var(--idle-soft); + border-color: var(--state-absent); +} + +.reason { + background: var(--danger-soft); + border-color: var(--state-failed); + color: var(--text); +} + +.facts { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: var(--sp-2) var(--sp-3); + margin: 0 0 var(--sp-4); + font-size: var(--size-sm); +} + +.facts dt { + font-size: var(--size-xs); + letter-spacing: .04em; + text-transform: uppercase; + color: var(--text-muted); + align-self: center; +} + +.facts dd { margin: 0; } + +.channels { + display: grid; + gap: var(--sp-2); + margin: 0 0 var(--sp-2); + padding: 0; + list-style: none; +} + +.channel { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: var(--sp-1) var(--sp-2); + padding: var(--sp-2); + background: var(--bg-sunken); + border-radius: var(--radius); +} + +.channel__name { font-weight: 600; font-size: var(--size-sm); } + +.channel__status { + justify-self: end; + font-size: var(--size-xs); + font-weight: 700; +} + +.channel__status--ok { color: var(--ok); } +.channel__status--warn { color: var(--warn); } +.channel__status--danger { color: var(--danger); } +.channel__status--idle { color: var(--idle); } + +.channel__meta { font-size: var(--size-xs); color: var(--text-faint); } + +.channel__hint { font-size: var(--size-xs); color: var(--text-muted); } + +.notes__list { + display: grid; + gap: var(--sp-1); + margin: 0 0 var(--sp-3); + padding-left: var(--sp-4); + font-size: var(--size-xs); + color: var(--text-muted); +} + +/* -- Warnings and invalid records -------------------------------------- */ + +.warnings, +.invalid { + padding: var(--sp-3); + margin-bottom: var(--sp-3); + border: 1px solid; + border-radius: var(--radius); +} + +.warnings { background: var(--info-soft); border-color: var(--info); } +.invalid { background: var(--danger-soft); border-color: var(--state-failed); } + +.warnings__list, +.invalid__list { + display: grid; + gap: var(--sp-1); + margin: 0; + padding-left: var(--sp-4); + font-size: var(--size-sm); +} + +.empty-note { + margin: var(--sp-3) 0 0; + padding: var(--sp-3); + font-size: var(--size-sm); + background: var(--bg-sunken); + border-radius: var(--radius); +} + +/* -- About/durum view -------------------------------------------------- */ + +.about { display: grid; gap: var(--sp-4); } + +.card { + padding: var(--sp-4); + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: var(--radius-lg); +} + +.card--danger { + background: var(--danger-soft); + border-color: var(--state-failed); +} + +.about-table { + width: 100%; + border-collapse: collapse; + font-size: var(--size-sm); +} + +.about-table th { + padding: var(--sp-2); + text-align: left; + font-size: var(--size-xs); + letter-spacing: .04em; + text-transform: uppercase; + color: var(--text-muted); + border-bottom: 1px solid var(--border-strong); +} + +.about-table td { + padding: var(--sp-2); + border-bottom: 1px solid var(--border); + vertical-align: top; +} + +.rules { + display: grid; + gap: var(--sp-2); + margin: 0; + padding-left: var(--sp-4); + font-size: var(--size-sm); +} diff --git a/web/static/tokens.css b/web/static/tokens.css index dc9cf5b..d197552 100644 --- a/web/static/tokens.css +++ b/web/static/tokens.css @@ -62,11 +62,17 @@ --idle: #5c6570; /* not yet acted on: not a failure */ --idle-soft: #eef0f2; - /* -- Device state machine --------------------------------------------- */ - /* PROVISIONED_OFFLINE -> ENROLLED -> READY, and the failure/absence that - sit outside the sequence. Kept as named tokens so no surface invents a - fifth stage or repaints a stage by accident. */ - --state-provisioned: var(--info); + /* Device state machine ---------------------------------------------- + PROVISIONED_OFFLINE -> ENROLLED -> READY, and the failure/absence that sit + outside the sequence. Kept as named tokens so no surface invents a fifth + stage or repaints a stage by accident. + + PROVISIONED is deliberately NOT the action accent. The accent means "the + action you came to take" and "the selected row"; a state that borrowed it + made a KURULDU badge and a selected row the same blue, so a list could not + say which of the two it was showing. This slate is distinct from both the + accent and the neutral --idle, and reads as one step along a path. */ + --state-provisioned: #475569; --state-enrolled: var(--warn); --state-ready: var(--ok); --state-failed: var(--danger); diff --git a/web/templates/about.html b/web/templates/about.html new file mode 100644 index 0000000..ebbcf79 --- /dev/null +++ b/web/templates/about.html @@ -0,0 +1,85 @@ +
+

Hakkında ve durum

+

Bu sayfa panelin kendi sürümünü ve hangi veriyi gösterdiğini yazar; operatör neye baktığını tahmin etmez.

+ +
+

Panel

+
+
Ürün
$panel_name
+
Yüzey
$panel_surface
+
Panel sürümü
$panel_version
+
Python
$python_version
+
Dinlenen adres
$bind_address
+
Yetki düzeyi
$privilege_word $privilege_reason
+
Sunucu başlangıcı
$started_at
+
+
+ +
+

Veri kaynağı

+

+ $data_banner_word + $data_banner_text +

+
+
Kaynak türü
$source_kind_word
+
Dosya
$source_path_text
+
Şema sürümü
$schema_version_text
+
Dosya üretimi
$generated_at_text
+
Yükleme zamanı
$loaded_at
+
Cihaz sayısı
$total_text
+
Hazır / hazır değil
$ready_text
+
Geçersiz kayıt
$invalid_count_text
+
Okuma hatası
$error_text
+
+
+ +
+

Durum sözlüğü

+

İki operatör yüzeyi aynı sözlüğü kullanır (docs/DESIGN.md); cihaz konsolu ve bu panel aynı kelimeyi yazar. Renk yalnız taşıyıcı değildir: rozet her zaman kelimesini taşır.

+ + + + + + +$vocabulary_rows + +
Durum sözlüğü: Türkçe kelime, renk belirteci ve anlamı
DurumRenk belirteciAnlam
+
+ +
+

Dalgalar

+

Yayılım sırası ve dalga hedefi (docs/18). Panelde $total_text cihaz var.

+ + + + + + +$wave_rows + +
Yayılım dalgaları, hedef cihaz sayısı ve panelde görünen cihaz sayısı
DalgaHedef cihazPanelde
+
+ +
+

Kanal doğrulaması

+

HAZIR yalnız dört kanalın hepsi doğrulandığında verilir; panel bunu denetler (docs/27).

+ + + + + + +$channel_rows + +
Dört uzak erişim kanalı ve envanter alan adları
KanalAlan adıNe doğrulanır
+
+ +
+

Panel kuralları

+
    +$rule_items +
+
+
diff --git a/web/templates/about_channel_row.html b/web/templates/about_channel_row.html new file mode 100644 index 0000000..8137e60 --- /dev/null +++ b/web/templates/about_channel_row.html @@ -0,0 +1 @@ + $label$key$hint diff --git a/web/templates/about_vocabulary_row.html b/web/templates/about_vocabulary_row.html new file mode 100644 index 0000000..6f81f36 --- /dev/null +++ b/web/templates/about_vocabulary_row.html @@ -0,0 +1 @@ + $word$token$meaning diff --git a/web/templates/about_wave_row.html b/web/templates/about_wave_row.html new file mode 100644 index 0000000..d1de9cc --- /dev/null +++ b/web/templates/about_wave_row.html @@ -0,0 +1 @@ + $code $label$target$in_panel diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..0daa6d9 --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,43 @@ + + + + + +$page_title + + + + + +
+
+

$panel_name

+ $panel_surface +
+ +

+ Hedef + $target_identity +

+
+ $primary_action + $gated_action +
+

+ Yetki + $privilege_word + $privilege_reason +

+
+
+$content +
+
+ $footer_source + $footer_meta +
+ + diff --git a/web/templates/channel_row.html b/web/templates/channel_row.html new file mode 100644 index 0000000..66d3bb4 --- /dev/null +++ b/web/templates/channel_row.html @@ -0,0 +1,6 @@ +
  • + $label + $status_word + $checked_at_text + $hint +
  • diff --git a/web/templates/detail.html b/web/templates/detail.html new file mode 100644 index 0000000..71d708a --- /dev/null +++ b/web/templates/detail.html @@ -0,0 +1,23 @@ +

    Seçili cihaz

    +

    + $state_label + $state_meaning +

    +$selection_note_block +$reason_block +
    +
    Hedef kimliği
    $device_id
    +
    Hostname
    $hostname
    +
    Bayi no
    $dealer_id
    +
    Dalga
    $wave_label hedef $wave_target cihaz
    +
    Son görülme
    $last_seen_text
    +
    Kayıt kaynağı
    $record_source
    +
    +$note_block +$notes_block +

    Kanal doğrulaması

    +

    $ready_gate_note

    +
      +$channel_rows +
    +

    $channel_count_note

    diff --git a/web/templates/detail_empty.html b/web/templates/detail_empty.html new file mode 100644 index 0000000..8a1a93e --- /dev/null +++ b/web/templates/detail_empty.html @@ -0,0 +1,2 @@ +

    Seçili cihaz

    +

    $empty_text

    diff --git a/web/templates/detail_unknown.html b/web/templates/detail_unknown.html new file mode 100644 index 0000000..ef7913d --- /dev/null +++ b/web/templates/detail_unknown.html @@ -0,0 +1,7 @@ +

    Seçili cihaz

    +

    + HATA + Bu kimlik envanterde yok. +

    +

    İstenen cihaz kimliği: $device_id. Envanterde bu kimliğe sahip bir kayıt yok; panel eksik kaydı uydurmaz.

    +

    Tüm filoyu göster

    diff --git a/web/templates/device_row.html b/web/templates/device_row.html new file mode 100644 index 0000000..122f8c1 --- /dev/null +++ b/web/templates/device_row.html @@ -0,0 +1,7 @@ + + $state_label + $device_id + $hostname + $wave_name + $last_seen_text + diff --git a/web/templates/empty_note.html b/web/templates/empty_note.html new file mode 100644 index 0000000..94f42cf --- /dev/null +++ b/web/templates/empty_note.html @@ -0,0 +1 @@ +

    $text Filtreyi temizle

    diff --git a/web/templates/filter_bar.html b/web/templates/filter_bar.html new file mode 100644 index 0000000..a9d7860 --- /dev/null +++ b/web/templates/filter_bar.html @@ -0,0 +1,29 @@ +
    +
    +

    + + +

    +

    + + +

    +

    + + +

    +

    + + +

    +

    + + Filtreyi temizle +

    +
    +

    Gösterilen: $shown_count / $total_count · $filter_description

    +
    diff --git a/web/templates/fleet.html b/web/templates/fleet.html new file mode 100644 index 0000000..9794cc5 --- /dev/null +++ b/web/templates/fleet.html @@ -0,0 +1,46 @@ +
    + $data_banner_word + $data_banner_text +
    + +
    +
    + $lead_figure + $lead_label + $lead_note +
    +
      +$summary_cells +
    +
    + +$warnings_block + +
    +
    +

    Filo cihazları

    +

    Hazır olmayan cihazlar listede önce gelir. $list_count_text

    +$filter_bar +$wave_strip +$invalid_block + + + + + + + + + + + + +$device_rows + +
    Filo cihazları: durum, cihaz kimliği, hostname, dalga ve son görülme
    DurumCihaz kimliğiHostnameDalgaSon görülme
    +$empty_note +
    + +
    diff --git a/web/templates/gated_action.html b/web/templates/gated_action.html new file mode 100644 index 0000000..d2408fb --- /dev/null +++ b/web/templates/gated_action.html @@ -0,0 +1,2 @@ + +Kapalı: $reason diff --git a/web/templates/invalid_block.html b/web/templates/invalid_block.html new file mode 100644 index 0000000..5df22ff --- /dev/null +++ b/web/templates/invalid_block.html @@ -0,0 +1,7 @@ +
    +

    Geçersiz kayıtlar: $invalid_count

    +

    Kimliği kurulamayan kayıt sessizce düşürülmez; aşağıda nedeniyle birlikte durur (docs/02).

    +
      +$invalid_items +
    +
    diff --git a/web/templates/invalid_item.html b/web/templates/invalid_item.html new file mode 100644 index 0000000..3202823 --- /dev/null +++ b/web/templates/invalid_item.html @@ -0,0 +1 @@ +
  • $record_ref — $reason
  • diff --git a/web/templates/not_found_body.html b/web/templates/not_found_body.html new file mode 100644 index 0000000..e357471 --- /dev/null +++ b/web/templates/not_found_body.html @@ -0,0 +1,5 @@ +
    +

    Sayfa yok

    +

    İstenen adres bu panelde tanımlı değil: $request_path

    +

    Panel sayfaları: Filo · Hakkında ve durum

    +
    diff --git a/web/templates/note_block.html b/web/templates/note_block.html new file mode 100644 index 0000000..487317b --- /dev/null +++ b/web/templates/note_block.html @@ -0,0 +1 @@ +

    Not: $text

    diff --git a/web/templates/notes_item.html b/web/templates/notes_item.html new file mode 100644 index 0000000..21ec89d --- /dev/null +++ b/web/templates/notes_item.html @@ -0,0 +1 @@ +
  • $text
  • diff --git a/web/templates/notes_list.html b/web/templates/notes_list.html new file mode 100644 index 0000000..8979855 --- /dev/null +++ b/web/templates/notes_list.html @@ -0,0 +1,3 @@ +
      +$items +
    diff --git a/web/templates/option.html b/web/templates/option.html new file mode 100644 index 0000000..6d92bf5 --- /dev/null +++ b/web/templates/option.html @@ -0,0 +1 @@ + diff --git a/web/templates/primary_action.html b/web/templates/primary_action.html new file mode 100644 index 0000000..7bc69a6 --- /dev/null +++ b/web/templates/primary_action.html @@ -0,0 +1 @@ +Filo durumunu yenile diff --git a/web/templates/reason_block.html b/web/templates/reason_block.html new file mode 100644 index 0000000..664716a --- /dev/null +++ b/web/templates/reason_block.html @@ -0,0 +1 @@ +

    $text

    diff --git a/web/templates/refused_body.html b/web/templates/refused_body.html new file mode 100644 index 0000000..8859133 --- /dev/null +++ b/web/templates/refused_body.html @@ -0,0 +1,6 @@ +
    +

    Yöntem reddedildi

    +

    Gelen yöntem: $method

    +

    Bu panel yalnız okuma yapar: hiçbir uç nokta cihazda değişiklik yapmaz, komut çalıştırmaz ve dosya yazmaz.

    +

    Kabul edilen yöntemler: GET, HEAD. Yetki düzeyi: $privilege_word.

    +
    diff --git a/web/templates/rule_item.html b/web/templates/rule_item.html new file mode 100644 index 0000000..3ac1ede --- /dev/null +++ b/web/templates/rule_item.html @@ -0,0 +1 @@ +
  • $text
  • diff --git a/web/templates/selection_note.html b/web/templates/selection_note.html new file mode 100644 index 0000000..dcf11a9 --- /dev/null +++ b/web/templates/selection_note.html @@ -0,0 +1 @@ +

    $text

    diff --git a/web/templates/summary_cell.html b/web/templates/summary_cell.html new file mode 100644 index 0000000..dd0027c --- /dev/null +++ b/web/templates/summary_cell.html @@ -0,0 +1,5 @@ +
  • + $count + $word + $note +
  • diff --git a/web/templates/warning_item.html b/web/templates/warning_item.html new file mode 100644 index 0000000..542f3d1 --- /dev/null +++ b/web/templates/warning_item.html @@ -0,0 +1 @@ +
  • $text
  • diff --git a/web/templates/warnings.html b/web/templates/warnings.html new file mode 100644 index 0000000..a1c8c4f --- /dev/null +++ b/web/templates/warnings.html @@ -0,0 +1,6 @@ +
    +

    Uyarılar

    +
      +$warning_items +
    +
    diff --git a/web/templates/wave_item.html b/web/templates/wave_item.html new file mode 100644 index 0000000..72cbca6 --- /dev/null +++ b/web/templates/wave_item.html @@ -0,0 +1 @@ +$code $count/$target diff --git a/web/templates/wave_strip.html b/web/templates/wave_strip.html new file mode 100644 index 0000000..42346f3 --- /dev/null +++ b/web/templates/wave_strip.html @@ -0,0 +1 @@ +

    Dalga doluluğu: $wave_items