feat(web): add the central fleet panel the operator asked for first
The fleet has ~700 devices and no way to see them: the device console manages one machine from its own keyboard, and the question "which devices are installed, and which wave is each in" had no surface at all. This adds it as the first screen. Python standard library only -- http.server, html.escape, string.Template. No framework and no third-party import, because the fleet installs offline from a committed package pool that contains no web framework, so fastapi/uvicorn/flask would have made the panel uninstallable on the devices it manages. That was decided with the user over the FastAPI they first picked, and a test asserts it: the module imports only stdlib plus its own siblings. What it shows: the dark header with the target and the privilege level; a summary of counts by state with one loud figure; the device list with a state badge per row, monospace ids and hostnames so columns line up, non-ready devices sorted first, and a filter bar; the selected device's detail with its four-channel verification, since READY is refused until every channel is verified (docs/27). Nothing is colour-only: every badge carries its Turkish state word. Data has no live source yet, so a missing inventory file falls back to synthetic records that the page labels as demo in two places -- the panel never presents invented devices as real ones. Two defects found by reviewing a real render, both fixed here with a test that fails when the bug returns (proven by mutation: each assertion goes red against the reintroduced bug, green after): - --state-provisioned resolved to var(--info), and --info is --accent, so a KURULDU badge and the selected row were the same blue. The state now resolves to its own colour, and the test resolves var() aliases before comparing -- comparing the raw declarations does not detect this, which is how the first version of the test passed against the bug. - the wave cell printed the bare code and then a label that already contains it, so one device read "P2 P2 — PILOT-2". Each wave is named once, now. Also fixes two entries in docs/27 that had become untrue: it claimed a clone carries the packages and the installer downloads nothing, which stopped being the case when the pool moved to Git LFS and bf-bootstrap began cloning without smudge. The real flow -- light clone, payload from a local mirror or a targeted `git lfs pull` -- is documented instead. Tests: tests/test_web_static.py, 112 tests, collected by tests/run-all.sh (12/12 PASS overall).
This commit is contained in:
parent
8299481627
commit
337db15d5b
42 changed files with 4714 additions and 5 deletions
61
docs/UI-REMAINING-WORK.md
Normal file
61
docs/UI-REMAINING-WORK.md
Normal file
|
|
@ -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.
|
||||
1292
tests/test_web_static.py
Normal file
1292
tests/test_web_static.py
Normal file
File diff suppressed because it is too large
Load diff
107
web/README.md
Normal file
107
web/README.md
Normal file
|
|
@ -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-<no>` / `bf-<no>` (`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.
|
||||
46
web/__init__.py
Normal file
46
web/__init__.py
Normal file
|
|
@ -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
|
||||
66
web/data/fleet.example.json
Normal file
66
web/data/fleet.example.json
Normal file
|
|
@ -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": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
1083
web/fleet_data.py
Normal file
1083
web/fleet_data.py
Normal file
File diff suppressed because it is too large
Load diff
162
web/panel.py
Normal file
162
web/panel.py
Normal file
|
|
@ -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)
|
||||
717
web/render.py
Normal file
717
web/render.py
Normal file
|
|
@ -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(),
|
||||
)
|
||||
212
web/server.py
Normal file
212
web/server.py
Normal file
|
|
@ -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())
|
||||
660
web/static/app.css
Normal file
660
web/static/app.css
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
85
web/templates/about.html
Normal file
85
web/templates/about.html
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
<section class="about" data-region="about">
|
||||
<h2 class="section-title">Hakkında ve durum</h2>
|
||||
<p class="section-note">Bu sayfa panelin kendi sürümünü ve hangi veriyi gösterdiğini yazar; operatör neye baktığını tahmin etmez.</p>
|
||||
|
||||
<section class="card" data-region="about-panel">
|
||||
<h3 class="subsection-title">Panel</h3>
|
||||
<dl class="facts">
|
||||
<dt>Ürün</dt><dd>$panel_name</dd>
|
||||
<dt>Yüzey</dt><dd>$panel_surface</dd>
|
||||
<dt>Panel sürümü</dt><dd class="mono">$panel_version</dd>
|
||||
<dt>Python</dt><dd class="mono">$python_version</dd>
|
||||
<dt>Dinlenen adres</dt><dd class="mono">$bind_address</dd>
|
||||
<dt>Yetki düzeyi</dt><dd><span class="badge badge--privilege-$privilege_level">$privilege_word</span> $privilege_reason</dd>
|
||||
<dt>Sunucu başlangıcı</dt><dd class="mono">$started_at</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="card" data-region="about-data">
|
||||
<h3 class="subsection-title">Veri kaynağı</h3>
|
||||
<p class="data-banner data-banner--$data_banner_tone" role="status">
|
||||
<span class="data-banner__word">$data_banner_word</span>
|
||||
<span class="data-banner__text">$data_banner_text</span>
|
||||
</p>
|
||||
<dl class="facts">
|
||||
<dt>Kaynak türü</dt><dd>$source_kind_word</dd>
|
||||
<dt>Dosya</dt><dd class="mono">$source_path_text</dd>
|
||||
<dt>Şema sürümü</dt><dd class="mono">$schema_version_text</dd>
|
||||
<dt>Dosya üretimi</dt><dd class="mono">$generated_at_text</dd>
|
||||
<dt>Yükleme zamanı</dt><dd class="mono">$loaded_at</dd>
|
||||
<dt>Cihaz sayısı</dt><dd class="mono">$total_text</dd>
|
||||
<dt>Hazır / hazır değil</dt><dd class="mono">$ready_text</dd>
|
||||
<dt>Geçersiz kayıt</dt><dd class="mono">$invalid_count_text</dd>
|
||||
<dt>Okuma hatası</dt><dd>$error_text</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="card" data-region="about-vocabulary">
|
||||
<h3 class="subsection-title">Durum sözlüğü</h3>
|
||||
<p class="section-note">İ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.</p>
|
||||
<table class="about-table">
|
||||
<caption class="visually-hidden">Durum sözlüğü: Türkçe kelime, renk belirteci ve anlamı</caption>
|
||||
<thead>
|
||||
<tr><th scope="col">Durum</th><th scope="col">Renk belirteci</th><th scope="col">Anlam</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
$vocabulary_rows
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="card" data-region="about-waves">
|
||||
<h3 class="subsection-title">Dalgalar</h3>
|
||||
<p class="section-note">Yayılım sırası ve dalga hedefi (docs/18). Panelde <span class="mono">$total_text</span> cihaz var.</p>
|
||||
<table class="about-table">
|
||||
<caption class="visually-hidden">Yayılım dalgaları, hedef cihaz sayısı ve panelde görünen cihaz sayısı</caption>
|
||||
<thead>
|
||||
<tr><th scope="col">Dalga</th><th scope="col">Hedef cihaz</th><th scope="col">Panelde</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
$wave_rows
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="card" data-region="about-channels">
|
||||
<h3 class="subsection-title">Kanal doğrulaması</h3>
|
||||
<p class="section-note">HAZIR yalnız dört kanalın hepsi doğrulandığında verilir; panel bunu denetler (docs/27).</p>
|
||||
<table class="about-table">
|
||||
<caption class="visually-hidden">Dört uzak erişim kanalı ve envanter alan adları</caption>
|
||||
<thead>
|
||||
<tr><th scope="col">Kanal</th><th scope="col">Alan adı</th><th scope="col">Ne doğrulanır</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
$channel_rows
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="card" data-region="about-rules">
|
||||
<h3 class="subsection-title">Panel kuralları</h3>
|
||||
<ul class="rules">
|
||||
$rule_items
|
||||
</ul>
|
||||
</section>
|
||||
</section>
|
||||
1
web/templates/about_channel_row.html
Normal file
1
web/templates/about_channel_row.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<tr><td>$label</td><td class="mono">$key</td><td>$hint</td></tr>
|
||||
1
web/templates/about_vocabulary_row.html
Normal file
1
web/templates/about_vocabulary_row.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<tr><td><span class="badge badge--state-$state_token_suffix">$word</span></td><td class="mono">$token</td><td>$meaning</td></tr>
|
||||
1
web/templates/about_wave_row.html
Normal file
1
web/templates/about_wave_row.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<tr><td><span class="mono">$code</span> $label</td><td class="mono">$target</td><td class="mono">$in_panel</td></tr>
|
||||
43
web/templates/base.html
Normal file
43
web/templates/base.html
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="tr" data-privilege="$privilege_level">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>$page_title</title>
|
||||
<link rel="stylesheet" href="/static/tokens.css">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main">İçeriğe atla</a>
|
||||
<header class="app-header" data-region="header">
|
||||
<div class="app-header__identity">
|
||||
<h1 class="app-header__product">$panel_name</h1>
|
||||
<span class="app-header__surface">$panel_surface</span>
|
||||
</div>
|
||||
<nav class="app-header__nav" aria-label="Panel bölümleri">
|
||||
<a class="nav-link" href="/"$nav_fleet_attr>Filo</a>
|
||||
<a class="nav-link" href="/hakkinda"$nav_about_attr>Hakkında ve durum</a>
|
||||
</nav>
|
||||
<p class="app-header__target">
|
||||
<span class="app-header__label">Hedef</span>
|
||||
<span class="app-header__value mono">$target_identity</span>
|
||||
</p>
|
||||
<div class="app-header__actions">
|
||||
$primary_action
|
||||
$gated_action
|
||||
</div>
|
||||
<p class="app-header__privilege">
|
||||
<span class="app-header__label">Yetki</span>
|
||||
<span class="badge badge--privilege-$privilege_level">$privilege_word</span>
|
||||
<span class="app-header__reason" id="yetki-nedeni">$privilege_reason</span>
|
||||
</p>
|
||||
</header>
|
||||
<main class="app-main" id="main">
|
||||
$content
|
||||
</main>
|
||||
<footer class="app-footer" data-region="footer">
|
||||
<span>$footer_source</span>
|
||||
<span class="mono">$footer_meta</span>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
6
web/templates/channel_row.html
Normal file
6
web/templates/channel_row.html
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<li class="channel">
|
||||
<span class="channel__name">$label</span>
|
||||
<span class="channel__status channel__status--$tone">$status_word</span>
|
||||
<span class="channel__meta mono">$checked_at_text</span>
|
||||
<span class="channel__hint">$hint</span>
|
||||
</li>
|
||||
23
web/templates/detail.html
Normal file
23
web/templates/detail.html
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<h2 class="section-title">Seçili cihaz</h2>
|
||||
<p class="detail__state">
|
||||
<span class="badge badge--state-$state_token_suffix" title="$state_meaning">$state_label</span>
|
||||
<span class="detail__meaning">$state_meaning</span>
|
||||
</p>
|
||||
$selection_note_block
|
||||
$reason_block
|
||||
<dl class="facts">
|
||||
<dt>Hedef kimliği</dt><dd class="mono">$device_id</dd>
|
||||
<dt>Hostname</dt><dd class="mono">$hostname</dd>
|
||||
<dt>Bayi no</dt><dd class="mono">$dealer_id</dd>
|
||||
<dt>Dalga</dt><dd><span class="mono">$wave_label</span> <span class="cell-note">hedef $wave_target cihaz</span></dd>
|
||||
<dt>Son görülme</dt><dd class="mono">$last_seen_text</dd>
|
||||
<dt>Kayıt kaynağı</dt><dd>$record_source</dd>
|
||||
</dl>
|
||||
$note_block
|
||||
$notes_block
|
||||
<h3 class="subsection-title">Kanal doğrulaması</h3>
|
||||
<p class="section-note">$ready_gate_note</p>
|
||||
<ul class="channels">
|
||||
$channel_rows
|
||||
</ul>
|
||||
<p class="section-note">$channel_count_note</p>
|
||||
2
web/templates/detail_empty.html
Normal file
2
web/templates/detail_empty.html
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<h2 class="section-title">Seçili cihaz</h2>
|
||||
<p class="section-note">$empty_text</p>
|
||||
7
web/templates/detail_unknown.html
Normal file
7
web/templates/detail_unknown.html
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<h2 class="section-title">Seçili cihaz</h2>
|
||||
<p class="detail__state">
|
||||
<span class="badge badge--state-failed">HATA</span>
|
||||
<span class="detail__meaning">Bu kimlik envanterde yok.</span>
|
||||
</p>
|
||||
<p class="section-note">İstenen cihaz kimliği: <span class="mono">$device_id</span>. Envanterde bu kimliğe sahip bir kayıt yok; panel eksik kaydı uydurmaz.</p>
|
||||
<p class="section-note"><a href="$reset_href">Tüm filoyu göster</a></p>
|
||||
7
web/templates/device_row.html
Normal file
7
web/templates/device_row.html
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<tr class="fleet-row$row_selected_class"$row_selected_attr>
|
||||
<td><span class="badge badge--state-$state_token_suffix" title="$state_meaning">$state_label</span></td>
|
||||
<td class="mono"><a class="row-select" href="$select_href">$device_id</a></td>
|
||||
<td class="mono">$hostname</td>
|
||||
<td><span class="mono">$wave_name</span></td>
|
||||
<td class="mono">$last_seen_text</td>
|
||||
</tr>
|
||||
1
web/templates/empty_note.html
Normal file
1
web/templates/empty_note.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<p class="empty-note">$text <a href="/">Filtreyi temizle</a></p>
|
||||
29
web/templates/filter_bar.html
Normal file
29
web/templates/filter_bar.html
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<div class="filter-bar" data-region="filter">
|
||||
<form class="filter-bar__form" method="get" action="$form_action">
|
||||
<p class="filter-field">
|
||||
<label for="filtre-q">Ara (kimlik, hostname, bayi no)</label>
|
||||
<input id="filtre-q" name="q" type="search" value="$query" autocomplete="off">
|
||||
</p>
|
||||
<p class="filter-field">
|
||||
<label for="filtre-durum">Durum</label>
|
||||
<select id="filtre-durum" name="durum">
|
||||
$state_options
|
||||
</select>
|
||||
</p>
|
||||
<p class="filter-field">
|
||||
<label for="filtre-dalga">Dalga</label>
|
||||
<select id="filtre-dalga" name="dalga">
|
||||
$wave_options
|
||||
</select>
|
||||
</p>
|
||||
<p class="filter-field filter-field--check">
|
||||
<input id="filtre-hazir-degil" name="hazir_degil" type="checkbox" value="1"$only_not_ready_checked>
|
||||
<label for="filtre-hazir-degil">Yalnız hazır olmayanlar</label>
|
||||
</p>
|
||||
<p class="filter-field filter-field--actions">
|
||||
<button class="button button--primary" type="submit">Filtrele</button>
|
||||
<a class="button" href="$reset_href">Filtreyi temizle</a>
|
||||
</p>
|
||||
</form>
|
||||
<p class="filter-bar__summary">Gösterilen: <span class="mono">$shown_count</span> / <span class="mono">$total_count</span> · $filter_description</p>
|
||||
</div>
|
||||
46
web/templates/fleet.html
Normal file
46
web/templates/fleet.html
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<section class="data-banner data-banner--$data_banner_tone" data-region="data-source" role="status">
|
||||
<span class="data-banner__word">$data_banner_word</span>
|
||||
<span class="data-banner__text">$data_banner_text</span>
|
||||
</section>
|
||||
|
||||
<section class="summary" data-region="summary" aria-label="Durum özeti">
|
||||
<div class="summary__lead">
|
||||
<span class="summary__lead-figure mono">$lead_figure</span>
|
||||
<span class="summary__lead-label">$lead_label</span>
|
||||
<span class="summary__lead-note">$lead_note</span>
|
||||
</div>
|
||||
<ul class="summary__grid">
|
||||
$summary_cells
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
$warnings_block
|
||||
|
||||
<section class="split" data-region="body">
|
||||
<div class="split__list" data-region="list">
|
||||
<h2 class="section-title">Filo cihazları</h2>
|
||||
<p class="section-note">Hazır olmayan cihazlar listede önce gelir. <span class="mono">$list_count_text</span></p>
|
||||
$filter_bar
|
||||
$wave_strip
|
||||
$invalid_block
|
||||
<table class="fleet-table">
|
||||
<caption class="visually-hidden">Filo cihazları: durum, cihaz kimliği, hostname, dalga ve son görülme</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Durum</th>
|
||||
<th scope="col">Cihaz kimliği</th>
|
||||
<th scope="col">Hostname</th>
|
||||
<th scope="col">Dalga</th>
|
||||
<th scope="col">Son görülme</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
$device_rows
|
||||
</tbody>
|
||||
</table>
|
||||
$empty_note
|
||||
</div>
|
||||
<aside class="split__detail" data-region="detail" aria-label="Cihaz ayrıntısı">
|
||||
$device_detail
|
||||
</aside>
|
||||
</section>
|
||||
2
web/templates/gated_action.html
Normal file
2
web/templates/gated_action.html
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<button class="button button--gated" type="button" disabled aria-disabled="true" aria-describedby="gated-reason">Onaylı güncelleme</button>
|
||||
<span class="gated-reason" id="gated-reason">Kapalı: $reason</span>
|
||||
7
web/templates/invalid_block.html
Normal file
7
web/templates/invalid_block.html
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<section class="invalid" data-region="invalid" role="status" aria-label="Geçersiz envanter kayıtları">
|
||||
<h2 class="section-title">Geçersiz kayıtlar: <span class="mono">$invalid_count</span></h2>
|
||||
<p class="section-note">Kimliği kurulamayan kayıt sessizce düşürülmez; aşağıda nedeniyle birlikte durur (docs/02).</p>
|
||||
<ul class="invalid__list">
|
||||
$invalid_items
|
||||
</ul>
|
||||
</section>
|
||||
1
web/templates/invalid_item.html
Normal file
1
web/templates/invalid_item.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<li class="invalid__item"><span class="mono">$record_ref</span> — $reason</li>
|
||||
5
web/templates/not_found_body.html
Normal file
5
web/templates/not_found_body.html
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<section class="card card--danger" data-region="not-found">
|
||||
<h2 class="section-title">Sayfa yok</h2>
|
||||
<p class="section-note">İstenen adres bu panelde tanımlı değil: <span class="mono">$request_path</span></p>
|
||||
<p class="section-note">Panel sayfaları: <a href="/">Filo</a> · <a href="/hakkinda">Hakkında ve durum</a></p>
|
||||
</section>
|
||||
1
web/templates/note_block.html
Normal file
1
web/templates/note_block.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<p class="section-note">Not: $text</p>
|
||||
1
web/templates/notes_item.html
Normal file
1
web/templates/notes_item.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<li class="notes__item">$text</li>
|
||||
3
web/templates/notes_list.html
Normal file
3
web/templates/notes_list.html
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<ul class="notes__list">
|
||||
$items
|
||||
</ul>
|
||||
1
web/templates/option.html
Normal file
1
web/templates/option.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<option value="$value"$selected>$label</option>
|
||||
1
web/templates/primary_action.html
Normal file
1
web/templates/primary_action.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<a class="button button--primary" href="$href">Filo durumunu yenile</a>
|
||||
1
web/templates/reason_block.html
Normal file
1
web/templates/reason_block.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<p class="reason">$text</p>
|
||||
6
web/templates/refused_body.html
Normal file
6
web/templates/refused_body.html
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<section class="card card--danger" data-region="refused">
|
||||
<h2 class="section-title">Yöntem reddedildi</h2>
|
||||
<p class="section-note">Gelen yöntem: <span class="mono">$method</span></p>
|
||||
<p class="section-note">Bu panel yalnız okuma yapar: hiçbir uç nokta cihazda değişiklik yapmaz, komut çalıştırmaz ve dosya yazmaz.</p>
|
||||
<p class="section-note">Kabul edilen yöntemler: <span class="mono">GET</span>, <span class="mono">HEAD</span>. Yetki düzeyi: $privilege_word.</p>
|
||||
</section>
|
||||
1
web/templates/rule_item.html
Normal file
1
web/templates/rule_item.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<li class="rules__item">$text</li>
|
||||
1
web/templates/selection_note.html
Normal file
1
web/templates/selection_note.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<p class="selection-note">$text</p>
|
||||
5
web/templates/summary_cell.html
Normal file
5
web/templates/summary_cell.html
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<li class="summary__cell">
|
||||
<span class="summary__cell-value mono">$count</span>
|
||||
<span class="summary__cell-word">$word</span>
|
||||
<span class="summary__cell-note">$note</span>
|
||||
</li>
|
||||
1
web/templates/warning_item.html
Normal file
1
web/templates/warning_item.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<li class="warnings__item">$text</li>
|
||||
6
web/templates/warnings.html
Normal file
6
web/templates/warnings.html
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<section class="warnings" data-region="warnings" role="status" aria-label="Filtre uyarıları">
|
||||
<h2 class="section-title">Uyarılar</h2>
|
||||
<ul class="warnings__list">
|
||||
$warning_items
|
||||
</ul>
|
||||
</section>
|
||||
1
web/templates/wave_item.html
Normal file
1
web/templates/wave_item.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<span class="wave-strip__item"><span class="mono">$code</span> $count/$target</span>
|
||||
1
web/templates/wave_strip.html
Normal file
1
web/templates/wave_strip.html
Normal file
|
|
@ -0,0 +1 @@
|
|||
<p class="wave-strip" data-region="waves">Dalga doluluğu: $wave_items</p>
|
||||
Loading…
Reference in a new issue