From 337db15d5bb9ab5b81712a838a5e88c86774c1fb Mon Sep 17 00:00:00 2001 From: emirhanmamak-blueforce Date: Wed, 16 Sep 2026 22:39:50 +0300 Subject: [PATCH] feat(web): add the central fleet panel the operator asked for first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fleet has ~700 devices and no way to see them: the device console manages one machine from its own keyboard, and the question "which devices are installed, and which wave is each in" had no surface at all. This adds it as the first screen. Python standard library only -- http.server, html.escape, string.Template. No framework and no third-party import, because the fleet installs offline from a committed package pool that contains no web framework, so fastapi/uvicorn/flask would have made the panel uninstallable on the devices it manages. That was decided with the user over the FastAPI they first picked, and a test asserts it: the module imports only stdlib plus its own siblings. What it shows: the dark header with the target and the privilege level; a summary of counts by state with one loud figure; the device list with a state badge per row, monospace ids and hostnames so columns line up, non-ready devices sorted first, and a filter bar; the selected device's detail with its four-channel verification, since READY is refused until every channel is verified (docs/27). Nothing is colour-only: every badge carries its Turkish state word. Data has no live source yet, so a missing inventory file falls back to synthetic records that the page labels as demo in two places -- the panel never presents invented devices as real ones. Two defects found by reviewing a real render, both fixed here with a test that fails when the bug returns (proven by mutation: each assertion goes red against the reintroduced bug, green after): - --state-provisioned resolved to var(--info), and --info is --accent, so a KURULDU badge and the selected row were the same blue. The state now resolves to its own colour, and the test resolves var() aliases before comparing -- comparing the raw declarations does not detect this, which is how the first version of the test passed against the bug. - the wave cell printed the bare code and then a label that already contains it, so one device read "P2 P2 — PILOT-2". Each wave is named once, now. Also fixes two entries in docs/27 that had become untrue: it claimed a clone carries the packages and the installer downloads nothing, which stopped being the case when the pool moved to Git LFS and bf-bootstrap began cloning without smudge. The real flow -- light clone, payload from a local mirror or a targeted `git lfs pull` -- is documented instead. Tests: tests/test_web_static.py, 112 tests, collected by tests/run-all.sh (12/12 PASS overall). --- docs/UI-REMAINING-WORK.md | 61 ++ tests/test_web_static.py | 1292 +++++++++++++++++++++++ web/README.md | 107 ++ web/__init__.py | 46 + web/data/fleet.example.json | 66 ++ web/fleet_data.py | 1083 +++++++++++++++++++ web/panel.py | 162 +++ web/render.py | 717 +++++++++++++ web/server.py | 212 ++++ web/static/app.css | 660 ++++++++++++ web/static/tokens.css | 16 +- web/templates/about.html | 85 ++ web/templates/about_channel_row.html | 1 + web/templates/about_vocabulary_row.html | 1 + web/templates/about_wave_row.html | 1 + web/templates/base.html | 43 + web/templates/channel_row.html | 6 + web/templates/detail.html | 23 + web/templates/detail_empty.html | 2 + web/templates/detail_unknown.html | 7 + web/templates/device_row.html | 7 + web/templates/empty_note.html | 1 + web/templates/filter_bar.html | 29 + web/templates/fleet.html | 46 + web/templates/gated_action.html | 2 + web/templates/invalid_block.html | 7 + web/templates/invalid_item.html | 1 + web/templates/not_found_body.html | 5 + web/templates/note_block.html | 1 + web/templates/notes_item.html | 1 + web/templates/notes_list.html | 3 + web/templates/option.html | 1 + web/templates/primary_action.html | 1 + web/templates/reason_block.html | 1 + web/templates/refused_body.html | 6 + web/templates/rule_item.html | 1 + web/templates/selection_note.html | 1 + web/templates/summary_cell.html | 5 + web/templates/warning_item.html | 1 + web/templates/warnings.html | 6 + web/templates/wave_item.html | 1 + web/templates/wave_strip.html | 1 + 42 files changed, 4714 insertions(+), 5 deletions(-) create mode 100644 docs/UI-REMAINING-WORK.md create mode 100644 tests/test_web_static.py create mode 100644 web/README.md create mode 100644 web/__init__.py create mode 100644 web/data/fleet.example.json create mode 100644 web/fleet_data.py create mode 100644 web/panel.py create mode 100644 web/render.py create mode 100644 web/server.py create mode 100644 web/static/app.css create mode 100644 web/templates/about.html create mode 100644 web/templates/about_channel_row.html create mode 100644 web/templates/about_vocabulary_row.html create mode 100644 web/templates/about_wave_row.html create mode 100644 web/templates/base.html create mode 100644 web/templates/channel_row.html create mode 100644 web/templates/detail.html create mode 100644 web/templates/detail_empty.html create mode 100644 web/templates/detail_unknown.html create mode 100644 web/templates/device_row.html create mode 100644 web/templates/empty_note.html create mode 100644 web/templates/filter_bar.html create mode 100644 web/templates/fleet.html create mode 100644 web/templates/gated_action.html create mode 100644 web/templates/invalid_block.html create mode 100644 web/templates/invalid_item.html create mode 100644 web/templates/not_found_body.html create mode 100644 web/templates/note_block.html create mode 100644 web/templates/notes_item.html create mode 100644 web/templates/notes_list.html create mode 100644 web/templates/option.html create mode 100644 web/templates/primary_action.html create mode 100644 web/templates/reason_block.html create mode 100644 web/templates/refused_body.html create mode 100644 web/templates/rule_item.html create mode 100644 web/templates/selection_note.html create mode 100644 web/templates/summary_cell.html create mode 100644 web/templates/warning_item.html create mode 100644 web/templates/warnings.html create mode 100644 web/templates/wave_item.html create mode 100644 web/templates/wave_strip.html 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