Compare commits

..

8 commits

Author SHA1 Message Date
emirhanmamak-blueforce
b6a14ca8e0 refine(operators): wave code back in the fleet row; drop a dead helper
The fleet row named the wave by its short name only ("PILOT-2") while the
detail view and the filter carry the full label ("P2 — PILOT-2"). An operator
matching a row against a work order speaks in codes, so the row carries the
full label now -- still named once, which the row test asserts.

Also takes the console agent's leftover: a dead state_style helper removed and
the unit convention on ALL_TOKENS documented (lengths are unitless; Tk padding
is already pixels, the parity test strips the CSS unit).
2026-09-16 22:47:12 +03:00
emirhanmamak-blueforce
8dde8dff3a refine(console): give both lower bands a floor so neither can swallow the other
Follow-up to the console redesign, from looking at the window at its minimum size
(1040x760) rather than at the size it happens to open in: the operation list and
the output well are both weighted rows, so at the floor one could collapse toward
nothing. Each now carries a minsize, and the comment records why.
2026-09-16 22:41:53 +03:00
emirhanmamak-blueforce
06c96690ca fix(offline): keep the package payload out of a clone, and say so when it is missing
The bundle pool is ~884 MB and now travels through Git LFS, so a plain clone
materialises all of it. Measured on this repository: 2.7 GB working tree, against
20 MB with smudge disabled -- the same 135x that decides whether one device or
seven hundred can be provisioned from a git remote.

bf-bootstrap exports GIT_LFS_SKIP_SMUDGE=1 for every git operation it performs
(clone and fetch), so provisioning a device no longer implies downloading the
package payload. The trade is explicit and documented rather than hidden: with
smudge off the pool files on disk are 131-byte pointer texts, and the installer
must not mistake one for a package.

So the installer gained a guard. A pointer text satisfies every existence check,
so without it the run died later inside `sha256sum -c` and reported a checksum
mismatch -- a misdiagnosis that sends an operator looking for corruption when the
payload was simply never fetched. It now recognises the pointer by content and
names the remedy instead.

Getting the payload is a deliberate second step, and both documented routes are
real: a local mirror (the offline-correct path, and the one the fleet uses) or
`git lfs pull --include='provisioning/offline-repo/built/pool/*'`, which fetches
only the delivery pool and leaves the intake copy under packages/ unfetched --
verified: 1.8 GB and a real .deb in built/pool, 131-byte stub still in packages/.

Tests: test-field-os-lifecycle.sh gains a scenario that lays down a pointer text
where a .deb belongs and asserts three things -- the run refuses, the message
names the cause, and it does not blame a checksum. Also asserts apt-get is never
invoked. 16 scenarios, all passing.

docs/27 is corrected where it had gone stale: it claimed a clone carries the
packages and the installer downloads nothing, which stopped being true the moment
the payload moved to LFS.
2026-09-16 22:41:53 +03:00
emirhanmamak-blueforce
337db15d5b feat(web): add the central fleet panel the operator asked for first
The fleet has ~700 devices and no way to see them: the device console manages one
machine from its own keyboard, and the question "which devices are installed, and
which wave is each in" had no surface at all. This adds it as the first screen.

Python standard library only -- http.server, html.escape, string.Template. No
framework and no third-party import, because the fleet installs offline from a
committed package pool that contains no web framework, so fastapi/uvicorn/flask
would have made the panel uninstallable on the devices it manages. That was
decided with the user over the FastAPI they first picked, and a test asserts it:
the module imports only stdlib plus its own siblings.

What it shows: the dark header with the target and the privilege level; a summary
of counts by state with one loud figure; the device list with a state badge per
row, monospace ids and hostnames so columns line up, non-ready devices sorted
first, and a filter bar; the selected device's detail with its four-channel
verification, since READY is refused until every channel is verified (docs/27).
Nothing is colour-only: every badge carries its Turkish state word.

Data has no live source yet, so a missing inventory file falls back to synthetic
records that the page labels as demo in two places -- the panel never presents
invented devices as real ones.

Two defects found by reviewing a real render, both fixed here with a test that
fails when the bug returns (proven by mutation: each assertion goes red against
the reintroduced bug, green after):
- --state-provisioned resolved to var(--info), and --info is --accent, so a
  KURULDU badge and the selected row were the same blue. The state now resolves
  to its own colour, and the test resolves var() aliases before comparing --
  comparing the raw declarations does not detect this, which is how the first
  version of the test passed against the bug.
- the wave cell printed the bare code and then a label that already contains it,
  so one device read "P2 P2 — PILOT-2". Each wave is named once, now.

Also fixes two entries in docs/27 that had become untrue: it claimed a clone
carries the packages and the installer downloads nothing, which stopped being the
case when the pool moved to Git LFS and bf-bootstrap began cloning without
smudge. The real flow -- light clone, payload from a local mirror or a targeted
`git lfs pull` -- is documented instead.

Tests: tests/test_web_static.py, 112 tests, collected by tests/run-all.sh
(12/12 PASS overall).
2026-09-16 22:39:50 +03:00
emirhanmamak-blueforce
8299481627 feat(console): group the operator console and give it a type and colour system
The console was a default-grey tkinter window: no grouping, no state colour, no
type scale, the privilege warning buried inside a monospace paragraph beside the
repository path, "Çık" offered twice at two different sizes, and an output area
that looked editable. The catalogue is 48 operations, 13 of them destructive, so
the screen an operator actually reads before touching a fleet was the one surface
with no hierarchy at all.

Adds bfos/ui_theme.py, which mirrors web/static/tokens.css value for value --
the two surfaces describe the same fleet and must not disagree about what a
colour means. It selects the `clam` theme because that renderer honours
background/bordercolor on the elements being restyled; note that theming an
element is not the same as redesigning a control, and the remaining gap is
recorded in docs/UI-REMAINING-WORK.md rather than claimed as finished.

What the redesign changes:
- sections get headed regions instead of floating on the root background;
- the privilege level becomes a header element and a banner, not a sentence;
- one exit action, not two;
- write operations are disabled with their reason visible when unprivileged,
  rather than hidden or silently listed;
- the output well is styled as the read-only evidence it is.

Tests: tests/test_bfos_gui_static.py moves to the new contract (68 -> assertions
updated, none deleted); tests/test_bfos.py still 155/155, which is the check that
the CLI and backend contracts survived a UI rewrite.
2026-09-16 22:38:01 +03:00
emirhanmamak-blueforce
4ad20452e7 feat(design): add the operator design system the two surfaces share
The device console is a default-grey tkinter window with no hierarchy: no
grouping, no state colour, no type scale, the privilege warning buried in a
monospace paragraph, "Çık" duplicated at two sizes, and an output area that
looks editable. Every measurement is in the delivered work; the point here is
that the fix is a system, not a repaint.

Adds PRODUCT.md (product truth: who uses this, from where, under what
constraints), docs/DESIGN.md (the direction contract and the shared status
vocabulary) and web/static/tokens.css (the values both surfaces read).

The vocabulary is the load-bearing part. Four severities -- ok / warn / danger /
info -- plus one action accent, and one Turkish word per device stage
(KURULDU / KAYITLI / HAZIR / HATA / YOK) used identically on the device and in
the central panel. A fifth hue is a bug, and colour is never the only carrier:
every badge ships its word, so the fleet list stays readable in greyscale.

The ground is light on purpose. The console runs in workshops and dealer service
areas in daylight; a near-black ops console loses its own body text there and its
state colours collapse into equal-brightness smudges.

No webfont: the fleet installs offline from a committed pool, so a font CDN is a
broken page on every device.
2026-09-16 22:20:15 +03:00
emirhanmamak-blueforce
d6d22c6e56 fix(offline-repo): commit the bundle pool policy and the guard for it
Follow-up to fc4c87c, which committed the pool files themselves. This records
the .gitignore rule that stops excluding built/pool/ and the two assertions that
keep it that way: the suite now fails if either the bundle's pool/ or its index
becomes gitignored, because a cloning device receives nothing but what the
repository tracks.

Verified: check-offline-repo-static.sh 140/140 with the pool committed.
2026-09-16 22:14:27 +03:00
emirhanmamak-blueforce
fc4c87c9ab fix(offline-repo): commit the bundle pool so a clone carries the packages
The installer resolves every pinned package as "<bundle>/pool/<source>" and the
lock rows name the files there, but built/pool was gitignored. A device that
cloned the repository therefore received the index and no packages and stopped
with "Kilitte yazılı paket depoda yok" -- the repository looked complete (2.7 MB
of index) while the payload was absent.

The bundle pool is committed now. The same blobs also live under
provisioning/offline-repo/packages/, which is the maintainer's intake directory;
Git LFS keys objects by content hash, so this is a second path and not a second
copy of the bytes.

Tests
- The suite asserts that neither the bundle's pool/ nor its index is gitignored,
  which is the condition a cloning device depends on. It passed 140/140 with the
  pool staged, and the check fails against the previous .gitignore.
2026-09-16 22:12:57 +03:00
1375 changed files with 10919 additions and 202 deletions

17
.gitignore vendored
View file

@ -40,15 +40,20 @@ packages.lock.tsv
!provisioning/offline-repo/built/ !provisioning/offline-repo/built/
!provisioning/offline-repo/built/** !provisioning/offline-repo/built/**
# #
# Except the bundle's own pool/ copy of the .deb files: those packages are # The bundle's pool/ IS committed, deliberately. blueforce-install.sh resolves
# already tracked once under provisioning/offline-repo/packages/, and the same # every pinned package as "<bundle>/pool/<source>" (its lock rows carry
# ~890 MB does not need a second path in the repository. What the installer # "pool/<file>.deb"), so a device that clones the repository needs those files
# needs from this directory is the index (Packages, Packages.gz, # on disk. Excluding them left the installer with an index but no packages and
# packages.lock.tsv, SHA256SUMS) plus repo-manifest.yaml. # it stopped with "Kilitte yazılı paket depoda yok". The same blobs also live
provisioning/offline-repo/built/pool/ # under packages/, which is the intake directory the maintainer commits from;
# Git LFS stores one object per content hash, so the duplication is a path, not
# a second copy of the bytes.
# Documentation site (generated) # Documentation site (generated)
docs-site/node_modules/ docs-site/node_modules/
# Design tooling scratch (local to a workstation, not shippable)
.impeccable/
docs-site/build/ docs-site/build/
docs-site/.docusaurus/ docs-site/.docusaurus/
docs-site/docs/ docs-site/docs/

69
PRODUCT.md Normal file
View file

@ -0,0 +1,69 @@
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
Two surfaces share this record. The **central panel** is a web surface (browser, server-rendered). The **device console** is a native desktop surface: a Python/tkinter application that ships on each field PC and runs with no network. Visual work on the device console follows the native-platform guidance for its own platform; this value names the surface under design here.
## Stack
Python standard library only (`http.server` + server-rendered templates). No framework and no third-party dependency: the fleet installs offline from a committed package pool, and `fastapi`/`uvicorn`/`flask` are not in that pool. Adding one would mean the panel cannot be installed on the devices it manages. Confirmed with the user over FastAPI for this reason.
## Users
**Field technician** — a dealer/authorised service person who receives a Windows 10 PC and must turn it into a Blueforce Field OS device. Works at the device, often in a workshop or a customer site, with limited time and no developer background. Job: run the install, know whether it succeeded, and recover when it did not.
**Central operator** — the person responsible for the whole fleet (~700 devices) from an office browser. Job: see which devices are installed or not, which rollout wave each is in, and push an approved update. Handles many devices per session and needs status, not detail.
Both operate the same fleet from opposite ends: the technician from the device, the operator from the browser.
## Product Purpose
Convert a fleet of Windows 10 field PCs into machines running Blueforce Field OS, and keep them correct afterwards. Success means a technician finishes an install without internet access, without guessing, and without a silent partial state — and an operator can say at any moment which devices are ready and which are not.
## Positioning
The install is provably offline and fail-closed. The device carries its own signed package pool, the installer refuses to continue when the contract does not hold rather than half-installing, and every destructive step demands explicit approval with a root gate. Neither property can be copied by a neighbouring tool that assumes internet access or treats a failed check as a warning.
## Operating Context
- Fleet: ~700 Windows 10 field PCs, converted in waves LAB(2) → P1(5) → P2(20) → W1(50) → W2(100) → PROD, production behind a separate typed override.
- Install target: Ubuntu 26.04.1 LTS ("Resolute Raccoon"), GNOME desktop installed but not the boot default — the device boots to a terminal and the desktop is switched on when required.
- Network: the field estate is reached over WireGuard; four remote channels are supported (SSH-over-WG, xRDP, RustDesk OSS, MeshCentral). No commercial remote-desktop licence (AnyDesk was rejected).
- Fleet tooling: Ansible CLI + Semaphore Community for orchestration, Prometheus + Node Exporter + Grafana OSS + Uptime Kuma for monitoring, Docusaurus for documentation.
- Device state machine: `PROVISIONED_OFFLINE → ENROLLED → READY`, with no skipping and no going back; `READY` is refused unless every channel reports `verified`.
- Documentation set: 33 numbered Turkish documents plus README/SUMMARY, served through Docusaurus.
## Capabilities and Constraints
**Confirmed functionality** — the device console exposes 48 operations: 13 write (all requiring root) and 35 read-only. The installer is `scripts/install/blueforce-install.sh`, driven by an offline bundle at `provisioning/offline-repo/built/` (index + `pool/` of 1321 `.deb` files, ~887 MB). Evidence of fleet state is an inventory and a enrolment token per device.
**Hard constraints** — no internet during install or normal operation; every user-visible string is Turkish (code, comments and commit messages stay English); destructive operations need an explicit typed confirmation and a root gate; a check that fails stops the operation, it never degrades to a warning.
**Deliberately undecided** — where the central panel is hosted and how it authenticates to the fleet. Not invented here: the panel is being designed before its deployment model is fixed.
## Brand Commitments
Name: **Blueforce Field OS**. Existing operator documentation and console copy are Turkish and already carry the product voice — plain, factual, no marketing register. No logo or palette is binding; no visual identity exists yet.
## Evidence on Hand
- `docs/` — 33 numbered Turkish documents, including `01-ARCHITECTURE`, `24-DECISION-LOG` (K-01…K-14), `27-OFFLINE-PROVISIONING`, `33-OPERATOR-CONSOLE`.
- `bfos/` — the device console (`operations.py` catalogue of 48 operations, `runner.py`, `tui.py`, `ui_gui.py`).
- `provisioning/offline-repo/` — the committed package pool and its manifests.
- No analytics, no customer logos, no testimonials, no benchmark numbers, and no screenshots of a working web panel exist. Nothing of the sort may be fabricated.
## Product Principles
1. **Fail closed, never half-install.** A device that cannot be verified stays unfinished and says so; a degraded success is worse than a clear refusal.
2. **Offline is a design property, not a deployment accident.** Anything the fleet needs travels with the fleet.
3. **Destructive work is explicit.** Approval and root are gates, not warnings.
4. **State is legible at a glance.** Which wave, which stage, which channel — readable in seconds, from a device screen or across 700 devices.
5. **Turkish for operators, English for the codebase.** No mixed-language surface.
## Accessibility & Inclusion
Field devices are operated in workshops and customer sites, sometimes on small or poorly lit screens; the console is used with a keyboard more often than a mouse. Readable contrast and non-colour-only status signalling are requirements, not enhancements. No formal standard (WCAG level) was agreed; none is claimed.

View file

@ -77,7 +77,25 @@ EOF
say() { printf 'bf-bootstrap: %s\n' "$*"; } say() { printf 'bf-bootstrap: %s\n' "$*"; }
warn() { printf 'bf-bootstrap: WARN: %s\n' "$*" >&2; } warn() { printf 'bf-bootstrap: WARN: %s\n' "$*" >&2; }
die() { printf 'bf-bootstrap: ERROR: %s\n' "$*" >&2; exit 1; } die() { printf 'bf-bootstrap: ERROR: %s\n' "$*" >&2; exit 1; }
# run: executes a mutating command, or only reports it under --check.
# Git-LFS smudge is disabled for every git operation this script performs.
#
# Why: the offline package pool is ~884 MB of .deb payload tracked through Git
# LFS, and a default clone materialises all of it (measured: 2.7 GB working tree
# against 20 MB with smudge off, a 135x difference). A device that only needs to
# reach the console and the manifest pays that cost on every clone for bytes it
# may never install from.
#
# The trade is explicit: with smudge off the pool files on disk are 131-byte
# pointer texts, and the installer refuses such a bundle rather than installing
# from it ("Nokta dosyası" below). Fetching the payload is therefore a separate,
# deliberate step -- `git lfs pull --include='provisioning/offline-repo/built/pool/*'`
# -- so that "I cloned the repository" and "I have the packages" stay two facts.
#
# This is deliberately the smudge-off half only. Enabling a full `git lfs pull`
# here would reintroduce the 884 MB download this prevents.
export GIT_LFS_SKIP_SMUDGE=1
run() { run() {
if [[ "$CHECK_MODE" -eq 1 ]]; then if [[ "$CHECK_MODE" -eq 1 ]]; then
printf 'bf-bootstrap: check: would run: %s\n' "$*" printf 'bf-bootstrap: check: would run: %s\n' "$*"

File diff suppressed because it is too large Load diff

743
bfos/ui_theme.py Normal file
View file

@ -0,0 +1,743 @@
"""bfos/ui_theme.py -- design tokens for the device console.
The values in this file MIRROR ``web/static/tokens.css``, which is the single
source of truth for both operator surfaces (the device console and the central
panel). Nothing here invents a colour: a colour means a device state, a
severity, or the action accent, and a fifth hue would be a bug (docs/DESIGN.md,
"Rules"). The mirror is not a comment-level promise -- ``TOKENS`` carries the
CSS custom-property name of every value, and ``tests/test_bfos_gui_static.py``
parses ``tokens.css`` and compares the two files, so a token changed on one
surface and not the other fails the suite instead of drifting.
Why light, not dark
-------------------
The console runs in workshops, dealer service areas and customer sites: bright
overhead light, daylight from a roller door, a 14" laptop panel. A near-black
console loses its own body text there and its state colours collapse into
equal-brightness smudges. So the ground is light (``BG_APP``), the panels are
white (``BG_PANEL``), the one dark region is the header (``BG_HEADER``), and
saturation is spent only where it carries meaning.
Faces
-----
No webfont, and no font download: the fleet installs offline, so the UI face is
whatever the platform ships and the data face is monospace (ids, counts, exit
codes, timestamps line up in a column). ``apply_theme`` picks the first family
the platform actually has, from a candidate list -- never a missing font name,
which is a silently different layout.
Sizes and the 4px rhythm
------------------------
Type sizes and space steps are kept in the token's own unit (CSS px) so the
mirror is exact. Tk font sizes are points, so ``point_size`` converts with the
96 dpi relation the tokens assume; padding and row heights are already pixels
in Tk, so they are used as ``SP_*`` values directly.
Usage
-----
theme = ui_theme.apply_theme(root) # once, before any widget is built
ttk.Label(parent, style=theme.panel_label)
"""
from __future__ import annotations
from typing import Dict, Mapping, Sequence
try: # pragma: no cover - environment dependent
from tkinter import ttk
HAVE_TKINTER = True
except Exception: # pragma: no cover - environment dependent
ttk = None # type: ignore[assignment]
HAVE_TKINTER = False
# ---------------------------------------------------------------------------
# Ground and structure -- tokens.css: "Ground and structure"
# ---------------------------------------------------------------------------
BG_APP = "#f6f7f8" # window ground
BG_PANEL = "#ffffff" # raised surfaces, cards, table body
BG_SUNKEN = "#eef0f2" # wells: log output, code, inactive rows
BG_HEADER = "#1d2530" # app header; the one dark region
BORDER = "#d5d9de" # hairline between regions
BORDER_STRONG = "#b6bcc4" # input borders, table rules
# ---------------------------------------------------------------------------
# Text
# ---------------------------------------------------------------------------
TEXT = "#1a1f26" # body
TEXT_MUTED = "#5c6570" # secondary lines, labels
TEXT_FAINT = "#8b939c" # timestamps, hints, placeholders
TEXT_ON_DARK = "#e8ebee" # header text
TEXT_ON_ACCENT = "#ffffff"
# ---------------------------------------------------------------------------
# Action -- one accent: the action the operator came to take, and the
# selected row. Nothing else.
# ---------------------------------------------------------------------------
ACCENT = "#1d4ed8"
ACCENT_HOVER = "#1a43bd"
ACCENT_ACTIVE = "#16389e"
ACCENT_SOFT = "#e8eeff" # selected row, focus ring fill
FOCUS_RING = "#1d4ed8"
# ---------------------------------------------------------------------------
# State severities -- four, each with a strong form for text and a soft form
# for fills, so a badge is built from one palette without inventing a second.
# ---------------------------------------------------------------------------
OK = "#15803d"
OK_SOFT = "#e6f4ea"
WARN = "#b45309"
WARN_SOFT = "#fdf3e3"
DANGER = "#b91c1c"
DANGER_SOFT = "#fdecec"
INFO = "#1d4ed8"
INFO_SOFT = "#e8eeff"
IDLE = "#5c6570" # not yet acted on: not a failure
IDLE_SOFT = "#eef0f2"
# ---------------------------------------------------------------------------
# Device state machine -- PROVISIONED_OFFLINE -> ENROLLED -> READY, plus the
# failure and the absence that sit outside the sequence. Named so no surface
# repaints a stage by accident.
#
# PROVISIONED is deliberately NOT the action accent (tokens.css states the
# reason): the accent means "the action you came to take" and "the selected
# row", so a KURULDU badge built from it made a state and a selection the same
# blue. It is a slate of its own, one step along the path and distinct from the
# neutral --idle.
# ---------------------------------------------------------------------------
STATE_PROVISIONED = "#475569"
STATE_ENROLLED = WARN
STATE_READY = OK
STATE_FAILED = DANGER
STATE_ABSENT = IDLE
# ---------------------------------------------------------------------------
# Type (px, as the tokens declare them)
# ---------------------------------------------------------------------------
SIZE_XS = 12
SIZE_SM = 13
SIZE_MD = 15
SIZE_LG = 18
SIZE_XL = 22
SIZE_2XL = 30
LEADING_TIGHT = 1.25
LEADING_NORMAL = 1.5
# ---------------------------------------------------------------------------
# Space -- a 4px rhythm, named so a component never hand-picks 7px
# ---------------------------------------------------------------------------
SP_1 = 4
SP_2 = 8
SP_3 = 12
SP_4 = 16
SP_5 = 24
SP_6 = 32
SP_7 = 48
RADIUS = 6
RADIUS_LG = 10
# ---------------------------------------------------------------------------
# Metrics
# ---------------------------------------------------------------------------
ROW_H = 44 # fleet table row: comfortable at arm's length
CONTROL_H = 36 # buttons and inputs share one height
HEADER_H = 56
SIDEBAR_W = 220
# Elevation is a token with no Tk equivalent (Tk has no box-shadow); the names
# are kept so the two token sets stay comparable and the absence is explicit.
SHADOW_SM = "0 1px 2px rgba(16, 24, 40, .06)"
SHADOW_MD = "0 4px 12px rgba(16, 24, 40, .10)"
# ---------------------------------------------------------------------------
# Fonts -- candidates in preference order, resolved against the platform
# ---------------------------------------------------------------------------
FONT_UI_CANDIDATES: Sequence[str] = (
"Noto Sans",
"DejaVu Sans",
"Ubuntu",
"Liberation Sans",
"Segoe UI",
"Helvetica",
)
FONT_DATA_CANDIDATES: Sequence[str] = (
"DejaVu Sans Mono",
"Liberation Mono",
"Noto Sans Mono",
"Consolas",
"Menlo",
"Courier New",
)
FONT_UI_FALLBACK = "TkDefaultFont"
FONT_DATA_FALLBACK = "TkFixedFont"
#: CSS px -> Tk points, at the 96 dpi the tokens assume. Table, not a formula:
#: rounding is per size so the console keeps the token's steps instead of
#: collapsing two of them onto the same point size.
POINT_SIZES: Mapping[int, int] = {
SIZE_XS: 9,
SIZE_SM: 10,
SIZE_MD: 11,
SIZE_LG: 14,
SIZE_XL: 16,
SIZE_2XL: 22,
}
# ---------------------------------------------------------------------------
# The mirror table -- CSS custom property -> value. Kept as data so the parity
# test reads the same list the code uses.
# ---------------------------------------------------------------------------
TOKENS: Mapping[str, str] = {
"--bg-app": BG_APP,
"--bg-panel": BG_PANEL,
"--bg-sunken": BG_SUNKEN,
"--bg-header": BG_HEADER,
"--border": BORDER,
"--border-strong": BORDER_STRONG,
"--text": TEXT,
"--text-muted": TEXT_MUTED,
"--text-faint": TEXT_FAINT,
"--text-on-dark": TEXT_ON_DARK,
"--text-on-accent": TEXT_ON_ACCENT,
"--accent": ACCENT,
"--accent-hover": ACCENT_HOVER,
"--accent-active": ACCENT_ACTIVE,
"--accent-soft": ACCENT_SOFT,
"--focus-ring": FOCUS_RING,
"--ok": OK,
"--ok-soft": OK_SOFT,
"--warn": WARN,
"--warn-soft": WARN_SOFT,
"--danger": DANGER,
"--danger-soft": DANGER_SOFT,
"--info": INFO,
"--info-soft": INFO_SOFT,
"--idle": IDLE,
"--idle-soft": IDLE_SOFT,
"--state-provisioned": STATE_PROVISIONED,
"--state-enrolled": STATE_ENROLLED,
"--state-ready": STATE_READY,
"--state-failed": STATE_FAILED,
"--state-absent": STATE_ABSENT,
}
#: Space tokens, mirrored by name (the parity test compares these too).
SPACE_TOKENS: Mapping[str, int] = {
"--sp-1": SP_1,
"--sp-2": SP_2,
"--sp-3": SP_3,
"--sp-4": SP_4,
"--sp-5": SP_5,
"--sp-6": SP_6,
"--sp-7": SP_7,
}
#: Type-size tokens, mirrored by name.
SIZE_TOKENS: Mapping[str, int] = {
"--size-xs": SIZE_XS,
"--size-sm": SIZE_SM,
"--size-md": SIZE_MD,
"--size-lg": SIZE_LG,
"--size-xl": SIZE_XL,
"--size-2xl": SIZE_2XL,
}
#: Metric tokens, mirrored by name.
METRIC_TOKENS: Mapping[str, int] = {
"--row-h": ROW_H,
"--control-h": CONTROL_H,
"--header-h": HEADER_H,
"--sidebar-w": SIDEBAR_W,
}
#: Every mirrored value, by CSS token name (used by the parity test).
#: Lengths are stored WITHOUT their unit: the unit is the token's (px at
#: 96 dpi), and the console passes the number straight to Tk padding, which is
#: already in pixels. The parity test strips the CSS unit to compare.
ALL_TOKENS: Mapping[str, str] = {
**TOKENS,
**{name: str(value) for name, value in SPACE_TOKENS.items()},
**{name: str(value) for name, value in SIZE_TOKENS.items()},
**{name: str(value) for name, value in METRIC_TOKENS.items()},
}
# ---------------------------------------------------------------------------
# Severity vocabulary -- the fill/text pair a badge or a row is built from
# ---------------------------------------------------------------------------
SEVERITIES: Sequence[str] = ("ok", "warn", "danger", "info", "idle")
SEVERITY_FILL: Mapping[str, str] = {
"ok": OK_SOFT,
"warn": WARN_SOFT,
"danger": DANGER_SOFT,
"info": INFO_SOFT,
"idle": IDLE_SOFT,
}
SEVERITY_TEXT: Mapping[str, str] = {
"ok": OK,
"warn": WARN,
"danger": DANGER,
"info": INFO,
"idle": IDLE,
}
#: State vocabulary, exactly the words both surfaces use (docs/DESIGN.md).
STATE_WORDS: Mapping[str, str] = {
"provisioned": "KURULDU",
"enrolled": "KAYITLI",
"ready": "HAZIR",
"failed": "HATA",
"absent": "YOK",
}
STATE_FILL: Mapping[str, str] = {
# The tokens give no soft form for provisioned; the console borrows the
# sunken well fill rather than inventing a hue, so a KURULDU badge cannot be
# mistaken for the accent-soft selection (see tokens.css, device state).
"provisioned": BG_SUNKEN,
"enrolled": WARN_SOFT,
"ready": OK_SOFT,
"failed": DANGER_SOFT,
"absent": IDLE_SOFT,
}
STATE_TEXT_COLOUR: Mapping[str, str] = {
"provisioned": STATE_PROVISIONED,
"enrolled": STATE_ENROLLED,
"ready": STATE_READY,
"failed": STATE_FAILED,
"absent": STATE_ABSENT,
}
#: Privilege is a state, not a log line (docs/DESIGN.md, "Privilege").
PRIVILEGE_STYLE: Mapping[bool, str] = {True: "ok", False: "warn"}
#: ttk style names, resolved once by apply_theme and read by the view.
_FAMILIES: Dict[str, str] = {}
# ---------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------
def point_size(px: int) -> int:
"""Token px -> Tk point size (see POINT_SIZES)."""
return POINT_SIZES.get(px, max(7, round(px * 0.75)))
def ui_family() -> str:
"""The UI face resolved by :func:`apply_theme` (platform default before)."""
return _FAMILIES.get("ui", FONT_UI_FALLBACK)
def data_family() -> str:
"""The monospace face resolved by :func:`apply_theme`."""
return _FAMILIES.get("data", FONT_DATA_FALLBACK)
def ui_font(size: int = SIZE_MD, weight: str = "normal") -> tuple:
"""A Tk font tuple in the platform UI face."""
if weight == "normal":
return (ui_family(), point_size(size))
return (ui_family(), point_size(size), weight)
def data_font(size: int = SIZE_SM, weight: str = "normal") -> tuple:
"""A Tk font tuple in the data (monospace) face."""
if weight == "normal":
return (data_family(), point_size(size))
return (data_family(), point_size(size), weight)
#: State key -> badge style, built from STATE_WORDS so the word and the colour
#: can never be paired up wrongly on one surface.
STATE_BADGE_STYLE: Mapping[str, str] = {
key: f"State{key.capitalize()}.Badge.TLabel" for key in STATE_WORDS
}
#: Privilege level -> badge style ("Privilege" for full, "ReadOnly" else).
PRIVILEGE_BADGE_STYLE: Mapping[str, str] = {
"full": "Privilege.Badge.TLabel",
"read_only": "ReadOnly.Badge.TLabel",
}
def severity_style(severity: str) -> str:
"""ttk style name for a severity pill (ok / warn / danger / info / idle)."""
return f"{severity.capitalize()}.Status.TLabel"
def _resolve_family(root, candidates: Sequence[str], fallback: str) -> str:
"""First family the platform actually has; the Tk default otherwise."""
try:
from tkinter import font as tkfont
available = {name.lower() for name in tkfont.families(root)}
except Exception: # pragma: no cover - font database unavailable
return fallback
for name in candidates:
if name.lower() in available:
return name
return fallback
def _configure(style, name: str, **options) -> None:
"""Configure a ttk style, ignoring options the platform's theme rejects.
ttk style elements differ between Tk builds (clam vs default, Tk 8.6 vs
9.x): a missing element option raises TclError. Losing one border colour is
a cosmetic difference; crashing the console on startup is not.
"""
try:
style.configure(name, **options)
except Exception:
for key, value in options.items():
try:
style.configure(name, **{key: value})
except Exception:
continue
def _map(style, name: str, **options) -> None:
"""State map for a ttk style, same tolerance as :func:`_configure`."""
try:
style.map(name, **options)
except Exception:
for key, value in options.items():
try:
style.map(name, **{key: value})
except Exception:
continue
# ---------------------------------------------------------------------------
# The one setup function, called once at startup
# ---------------------------------------------------------------------------
def apply_theme(root) -> Mapping[str, str]:
"""Install the token set on ``root``'s ttk styles; returns the style names.
Idempotent and cheap: the GUI calls it once before building any widget, and
a second call simply re-applies the same values (tests may build several
roots in one process).
"""
if not HAVE_TKINTER: # pragma: no cover - environment dependent
raise RuntimeError("tkinter yok: tema uygulanamaz")
assert ttk is not None # narrowing for the type checker
_FAMILIES["ui"] = _resolve_family(root, FONT_UI_CANDIDATES, FONT_UI_FALLBACK)
_FAMILIES["data"] = _resolve_family(root, FONT_DATA_CANDIDATES, FONT_DATA_FALLBACK)
try:
root.configure(bg=BG_APP)
except Exception: # pragma: no cover - non-Tk root
pass
style = ttk.Style(root)
# clam honours background/bordercolor on the widget elements we restyle;
# the 'default' theme on Linux ignores most of them, which is how a
# console ends up grey whatever the tokens say.
try:
if "clam" in style.theme_names():
style.theme_use("clam")
except Exception: # pragma: no cover - theme list unavailable
pass
base_font = ui_font(SIZE_MD)
_configure(
style, ".",
background=BG_APP,
foreground=TEXT,
font=base_font,
bordercolor=BORDER,
lightcolor=BORDER,
darkcolor=BORDER,
troughcolor=BG_SUNKEN,
focuscolor=FOCUS_RING,
)
# -- frames: the ground, the raised panel, the well, the one dark band --
_configure(style, "TFrame", background=BG_APP, borderwidth=0)
_configure(style, "Panel.TFrame", background=BG_PANEL, borderwidth=1,
relief="solid", bordercolor=BORDER, lightcolor=BORDER, darkcolor=BORDER)
_configure(style, "Sunken.TFrame", background=BG_SUNKEN, borderwidth=1,
relief="solid", bordercolor=BORDER, lightcolor=BORDER, darkcolor=BORDER)
_configure(style, "Header.TFrame", background=BG_HEADER, borderwidth=0)
_configure(style, "Warn.TFrame", background=WARN_SOFT, borderwidth=1,
relief="solid", bordercolor=WARN, lightcolor=WARN_SOFT,
darkcolor=WARN_SOFT)
_configure(style, "Info.TFrame", background=INFO_SOFT, borderwidth=1,
relief="solid", bordercolor=INFO, lightcolor=INFO_SOFT,
darkcolor=INFO_SOFT)
_configure(style, "Accent.TFrame", background=ACCENT_SOFT, borderwidth=1,
relief="solid", bordercolor=ACCENT, lightcolor=ACCENT_SOFT,
darkcolor=ACCENT_SOFT)
# -- labels -------------------------------------------------------------
_configure(style, "TLabel", background=BG_APP, foreground=TEXT, font=base_font)
_configure(style, "Panel.TLabel", background=BG_PANEL, foreground=TEXT)
_configure(style, "Sunken.TLabel", background=BG_SUNKEN, foreground=TEXT)
_configure(style, "Muted.TLabel", background=BG_APP, foreground=TEXT_MUTED,
font=ui_font(SIZE_SM))
_configure(style, "PanelMuted.TLabel", background=BG_PANEL,
foreground=TEXT_MUTED, font=ui_font(SIZE_SM))
_configure(style, "Faint.TLabel", background=BG_APP, foreground=TEXT_FAINT,
font=ui_font(SIZE_XS))
_configure(style, "PanelFaint.TLabel", background=BG_PANEL,
foreground=TEXT_FAINT, font=ui_font(SIZE_XS))
_configure(style, "FieldLabel.TLabel", background=BG_PANEL, foreground=TEXT_MUTED,
font=ui_font(SIZE_SM), anchor="e")
_configure(style, "Section.TLabel", background=BG_APP, foreground=TEXT,
font=ui_font(SIZE_LG, "bold"))
_configure(style, "PanelTitle.TLabel", background=BG_PANEL, foreground=TEXT_MUTED,
font=ui_font(SIZE_XS, "bold"))
# The one loud figure per view.
_configure(style, "Figure.TLabel", background=BG_APP, foreground=TEXT,
font=ui_font(SIZE_2XL, "bold"))
_configure(style, "Ok.TLabel", background=BG_PANEL, foreground=OK,
font=ui_font(SIZE_SM))
_configure(style, "Warn.TLabel", background=WARN_SOFT, foreground=WARN,
font=ui_font(SIZE_SM))
_configure(style, "InfoText.TLabel", background=INFO_SOFT, foreground=INFO,
font=ui_font(SIZE_SM))
_configure(style, "Danger.TLabel", background=BG_PANEL, foreground=DANGER,
font=ui_font(SIZE_SM))
_configure(style, "AppWarn.TLabel", background=BG_APP, foreground=WARN,
font=ui_font(SIZE_SM, "bold"))
_configure(style, "PanelOk.TLabel", background=BG_PANEL, foreground=OK,
font=ui_font(SIZE_SM, "bold"))
_configure(style, "PanelDanger.TLabel", background=BG_PANEL, foreground=DANGER,
font=ui_font(SIZE_SM, "bold"))
# Dialogs sit on the app ground, not on a panel.
_configure(style, "Dialog.TLabel", background=BG_APP, foreground=TEXT,
font=ui_font(SIZE_MD))
_configure(style, "DialogTitle.TLabel", background=BG_APP, foreground=TEXT,
font=ui_font(SIZE_LG, "bold"))
_configure(style, "DialogMuted.TLabel", background=BG_APP,
foreground=TEXT_MUTED, font=ui_font(SIZE_SM))
# Header (dark) region.
_configure(style, "HeaderTitle.TLabel", background=BG_HEADER,
foreground=TEXT_ON_DARK, font=ui_font(SIZE_XL, "bold"))
_configure(style, "HeaderMeta.TLabel", background=BG_HEADER,
foreground=TEXT_FAINT, font=ui_font(SIZE_SM))
_configure(style, "HeaderTarget.TLabel", background=BG_HEADER,
foreground=TEXT_ON_DARK, font=data_font(SIZE_SM))
_configure(style, "HeaderFigure.TLabel", background=BG_HEADER,
foreground=TEXT_ON_DARK, font=data_font(SIZE_MD, "bold"))
# -- badges: state word + colour, never colour alone -------------------
_configure(style, "Badge.TLabel", background=IDLE_SOFT, foreground=IDLE,
font=ui_font(SIZE_SM, "bold"), padding=(SP_2, SP_1))
for key, word_style in (("provisioned", "Provisioned"), ("enrolled", "Enrolled"),
("ready", "Ready"), ("failed", "Failed"),
("absent", "Absent")):
_configure(
style, f"State{word_style}.Badge.TLabel",
background=STATE_FILL[key], foreground=STATE_TEXT_COLOUR[key],
font=ui_font(SIZE_SM, "bold"), padding=(SP_2, SP_1),
)
_configure(style, "Privilege.Badge.TLabel", background=OK_SOFT, foreground=OK,
font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_1))
_configure(style, "ReadOnly.Badge.TLabel", background=WARN_SOFT, foreground=WARN,
font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_1))
_configure(style, "Type.Destructive.Badge.TLabel", background=DANGER_SOFT,
foreground=DANGER, font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1))
_configure(style, "Type.ReadOnly.Badge.TLabel", background=IDLE_SOFT,
foreground=IDLE, font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1))
_configure(style, "Open.Badge.TLabel", background=OK_SOFT, foreground=OK,
font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1))
_configure(style, "Closed.Badge.TLabel", background=WARN_SOFT, foreground=WARN,
font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1))
_configure(style, "Info.Badge.TLabel", background=INFO_SOFT, foreground=INFO,
font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_1))
_configure(style, "Neutral.Badge.TLabel", background=BG_SUNKEN, foreground=IDLE,
font=ui_font(SIZE_XS, "bold"), padding=(SP_2, SP_1))
# -- status pill (footer): the console's own state ---------------------
_configure(style, "Status.TLabel", background=IDLE_SOFT, foreground=IDLE,
font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_2))
for severity in SEVERITIES:
_configure(
style, f"{severity.capitalize()}.Status.TLabel",
background=SEVERITY_FILL[severity], foreground=SEVERITY_TEXT[severity],
font=ui_font(SIZE_SM, "bold"), padding=(SP_3, SP_2),
)
# -- inputs: one height with the buttons ---------------------------------
entry_opts = dict(
fieldbackground=BG_PANEL,
background=BG_PANEL,
foreground=TEXT,
bordercolor=BORDER_STRONG,
lightcolor=BORDER_STRONG,
darkcolor=BORDER_STRONG,
insertcolor=TEXT,
padding=(SP_2, SP_1),
font=ui_font(SIZE_MD),
)
_configure(style, "TEntry", **entry_opts)
_map(style, "TEntry",
bordercolor=[("focus", FOCUS_RING), ("disabled", BORDER)],
foreground=[("disabled", TEXT_FAINT)],
fieldbackground=[("disabled", BG_SUNKEN)])
_configure(style, "Invalid.TEntry", **entry_opts)
_configure(style, "Invalid.TEntry", bordercolor=DANGER, lightcolor=DANGER,
darkcolor=DANGER)
_map(style, "Invalid.TEntry", bordercolor=[("focus", DANGER)])
_configure(style, "TCombobox", **entry_opts)
_configure(style, "TCombobox", arrowcolor=TEXT_MUTED, selectbackground=ACCENT_SOFT,
selectforeground=TEXT, padding=(SP_2, SP_1))
_map(style, "TCombobox",
fieldbackground=[("readonly", BG_PANEL), ("disabled", BG_SUNKEN)],
foreground=[("disabled", TEXT_FAINT)],
bordercolor=[("focus", FOCUS_RING)])
_configure(style, "TCheckbutton", background=BG_APP, foreground=TEXT,
font=ui_font(SIZE_SM), focuscolor=BG_APP, padding=(SP_1, SP_1))
_map(style, "TCheckbutton",
background=[("active", BG_APP)],
foreground=[("disabled", TEXT_FAINT)],
indicatorcolor=[("selected", ACCENT), ("pressed", ACCENT_ACTIVE)])
_configure(style, "Panel.TCheckbutton", background=BG_PANEL, foreground=TEXT,
font=ui_font(SIZE_SM), padding=(SP_1, SP_1))
_map(style, "Panel.TCheckbutton",
background=[("active", BG_PANEL), ("disabled", BG_PANEL)],
foreground=[("disabled", TEXT_FAINT)])
# -- buttons: hairline secondary, one accent primary, danger for
# destructive work ------------------------------------------------
_configure(style, "TButton", background=BG_PANEL, foreground=TEXT,
bordercolor=BORDER_STRONG, lightcolor=BG_PANEL, darkcolor=BG_PANEL,
font=ui_font(SIZE_MD), padding=(SP_3, SP_2), relief="solid",
focusthickness=1, focuscolor=FOCUS_RING, anchor="center")
_map(style, "TButton",
background=[("disabled", BG_SUNKEN), ("pressed", BG_SUNKEN),
("active", ACCENT_SOFT)],
foreground=[("disabled", TEXT_FAINT), ("pressed", ACCENT_ACTIVE)],
bordercolor=[("focus", FOCUS_RING)],
lightcolor=[("active", ACCENT_SOFT), ("pressed", BG_SUNKEN)],
darkcolor=[("active", ACCENT_SOFT), ("pressed", BG_SUNKEN)])
_configure(style, "Accent.TButton", background=ACCENT, foreground=TEXT_ON_ACCENT,
bordercolor=ACCENT, lightcolor=ACCENT, darkcolor=ACCENT)
_map(style, "Accent.TButton",
background=[("disabled", IDLE_SOFT), ("pressed", ACCENT_ACTIVE),
("active", ACCENT_HOVER)],
foreground=[("disabled", TEXT_FAINT)],
bordercolor=[("focus", ACCENT_ACTIVE), ("active", ACCENT_HOVER)],
lightcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)],
darkcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)])
_configure(style, "Danger.TButton", background=DANGER, foreground=TEXT_ON_ACCENT,
bordercolor=DANGER, lightcolor=DANGER, darkcolor=DANGER)
_map(style, "Danger.TButton",
background=[("disabled", IDLE_SOFT), ("pressed", DANGER_SOFT),
("active", DANGER_SOFT)],
foreground=[("disabled", TEXT_FAINT), ("active", DANGER),
("pressed", DANGER)],
bordercolor=[("focus", DANGER)],
lightcolor=[("active", DANGER_SOFT), ("pressed", DANGER_SOFT)],
darkcolor=[("active", DANGER_SOFT), ("pressed", DANGER_SOFT)])
# Simple mode: the three big buttons, one of them the action accent.
_configure(style, "Big.TButton", font=ui_font(SIZE_LG),
padding=(SP_5, SP_4), anchor="w")
_configure(style, "Accent.Big.TButton", background=ACCENT,
foreground=TEXT_ON_ACCENT, bordercolor=ACCENT, lightcolor=ACCENT,
darkcolor=ACCENT)
_map(style, "Accent.Big.TButton",
background=[("disabled", IDLE_SOFT), ("pressed", ACCENT_ACTIVE),
("active", ACCENT_HOVER)],
foreground=[("disabled", TEXT_FAINT)],
lightcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)],
darkcolor=[("active", ACCENT_HOVER), ("pressed", ACCENT_ACTIVE)])
_configure(style, "Quiet.TButton", background=BG_SUNKEN, bordercolor=BORDER)
# -- table: the fleet/operation list ------------------------------------
_configure(style, "Treeview", background=BG_PANEL, fieldbackground=BG_PANEL,
foreground=TEXT, bordercolor=BORDER, lightcolor=BORDER,
darkcolor=BORDER, font=ui_font(SIZE_SM), rowheight=ROW_H)
_configure(style, "Treeview.Heading", background=BG_SUNKEN,
foreground=TEXT_MUTED, font=ui_font(SIZE_XS, "bold"),
relief="flat", padding=(SP_2, SP_2))
_map(style, "Treeview",
background=[("selected", ACCENT_SOFT)],
foreground=[("selected", TEXT)])
_map(style, "Treeview.Heading", background=[("active", BG_SUNKEN)])
# -- tabs, separators, scrollbars --------------------------------------
_configure(style, "TNotebook", background=BG_APP, bordercolor=BORDER,
lightcolor=BORDER, darkcolor=BORDER, tabmargins=(0, 0, 0, 0))
_configure(style, "TNotebook.Tab", background=BG_SUNKEN, foreground=TEXT_MUTED,
font=ui_font(SIZE_SM), padding=(SP_3, SP_2), bordercolor=BORDER)
_map(style, "TNotebook.Tab",
background=[("selected", BG_PANEL), ("active", BG_PANEL)],
foreground=[("selected", TEXT), ("active", TEXT)],
expand=[("selected", (0, 0, 0, 0))])
_configure(style, "TSeparator", background=BORDER)
for orient in ("Vertical", "Horizontal"):
_configure(style, f"{orient}.TScrollbar", background=BG_SUNKEN,
troughcolor=BG_APP, bordercolor=BORDER, arrowcolor=TEXT_MUTED,
lightcolor=BG_SUNKEN, darkcolor=BG_SUNKEN)
_map(style, f"{orient}.TScrollbar",
background=[("active", BORDER_STRONG)])
return {
"frame": "TFrame",
"panel": "Panel.TFrame",
"sunken": "Sunken.TFrame",
"header": "Header.TFrame",
"warn_band": "Warn.TFrame",
"info_band": "Info.TFrame",
"label": "TLabel",
"panel_label": "Panel.TLabel",
"panel_title": "PanelTitle.TLabel",
"muted": "Muted.TLabel",
"panel_muted": "PanelMuted.TLabel",
"faint": "Faint.TLabel",
"panel_faint": "PanelFaint.TLabel",
"field_label": "FieldLabel.TLabel",
"section": "Section.TLabel",
"figure": "Figure.TLabel",
"ok_label": "Ok.TLabel",
"warn_label": "Warn.TLabel",
"info_label": "InfoText.TLabel",
"danger_label": "Danger.TLabel",
"app_warn": "AppWarn.TLabel",
"panel_ok": "PanelOk.TLabel",
"panel_danger": "PanelDanger.TLabel",
"header_title": "HeaderTitle.TLabel",
"header_meta": "HeaderMeta.TLabel",
"header_target": "HeaderTarget.TLabel",
"header_figure": "HeaderFigure.TLabel",
"badge": "Badge.TLabel",
"badge_info": "Info.Badge.TLabel",
"badge_neutral": "Neutral.Badge.TLabel",
"badge_destructive": "Type.Destructive.Badge.TLabel",
"badge_readonly": "Type.ReadOnly.Badge.TLabel",
"badge_open": "Open.Badge.TLabel",
"badge_closed": "Closed.Badge.TLabel",
"privilege": "Privilege.Badge.TLabel",
"readonly": "ReadOnly.Badge.TLabel",
"status": "Status.TLabel",
"button": "TButton",
"accent_button": "Accent.TButton",
"danger_button": "Danger.TButton",
"big_button": "Big.TButton",
"big_accent_button": "Accent.Big.TButton",
"quiet_button": "Quiet.TButton",
"entry": "TEntry",
"invalid_entry": "Invalid.TEntry",
"panel_check": "Panel.TCheckbutton",
"check": "TCheckbutton",
"dialog": "Dialog.TLabel",
"dialog_title": "DialogTitle.TLabel",
"dialog_muted": "DialogMuted.TLabel",
}
def DANGER_ACTIVE_SAFE() -> str: # pragma: no cover - trivial
"""The danger colour used for a focused destructive control.
A named function (rather than a bare literal) so the fifth-hue rule stays
visible: focus on a dangerous action reuses the danger token.
"""
return DANGER

View file

@ -1,6 +1,6 @@
# 27 — Offline Provisioning # 27 — Offline Provisioning
> Kısa özet: Paketler **git deposunun içindedir** (`provisioning/offline-repo/packages/`, 1321 `.deb`, ~888 MB, Git LFS ile takip edilir). Saha kurulumu internet **gerektirmez** ve kuran kişi **hiçbir şey indirmez**: `sudo bf` → Kurulum sekmesi → "Bu cihazı kur" → 8 haneli bayi no → onay. Bu yol yalnız `PROVISIONED_OFFLINE` durumuna getirir; cihaz, tek kullanımlık enrollment token ve merkezi kanallar doğrulanmadan `ENROLLED` veya `READY` olmaz. > Kısa özet: Paketler **git deposunun içindedir** (`provisioning/offline-repo/built/pool/`, 1321 `.deb`, ~884 MB, Git LFS ile takip edilir). Saha kurulumu internet **gerektirmez**: `sudo bf` → Kurulum sekmesi → "Bu cihazı kur" → 8 haneli bayi no → onay. Paket yükü klonla birlikte gelmez (bilerek; bkz. §4), yerel aynadan taşınır ve kurucu depoyu kendi bulur. Bu yol yalnız `PROVISIONED_OFFLINE` durumuna getirir; cihaz, tek kullanımlık enrollment token ve merkezi kanallar doğrulanmadan `ENROLLED` veya `READY` olmaz.
> >
> - Dosya: `docs/27-OFFLINE-PROVISIONING.md` > - Dosya: `docs/27-OFFLINE-PROVISIONING.md`
> >
@ -13,7 +13,7 @@
## 1. Amaç ## 1. Amaç
Saha interneti kesik veya güvenilmezken tekrarlanabilir, denetlenebilir ve secretsiz ilk kurulumu tanımlamak. Kuran kişi paket aramaz, indirmez, bayrak ezberlemez: paket seti depoyla birlikte gelir. Saha interneti kesik veya güvenilmezken tekrarlanabilir, denetlenebilir ve secretsiz ilk kurulumu tanımlamak. Kuran kişi paket aramaz, indirmez, bayrak ezberlemez: paket seti depoda sürümlenir ve yerel aynadan taşınır.
## 2. Kapsam ## 2. Kapsam
@ -33,7 +33,9 @@ LİSANS: APT — https://manpages.ubuntu.com/manpages/noble/en/man8/apt.8.html
## 4. Neden Bu Karar? ## 4. Neden Bu Karar?
**Paketler artık ayrı bir medyada değil, bu git deposunda taşınır:** `provisioning/offline-repo/packages/` altında 1321 `.deb` (~888 MB) durur ve `.gitattributes` ile **Git LFS** üzerinden takip edilir (`*.deb filter=lfs ...`). Bu yüzden sahada "hangi paket nereden geldi?" sorusu ne internete ne de dış medyaya bağlıdır; aynı soru `packages.lock.tsv` ve `repo-manifest.yaml` ile yanıtlanır. Kuran kişi paket aramaz ve **indirmez**; klonladığı depo seti zaten taşır. **Paketler ayrı bir medyada değil, bu git deposunda taşınır:** `provisioning/offline-repo/built/pool/` altında 1321 `.deb` (~884 MB) durur ve `.gitattributes` ile **Git LFS** üzerinden takip edilir (`*.deb filter=lfs ...`). Bu yüzden sahada "hangi paket nereden geldi?" sorusu ne internete ne de dış medyaya bağlıdır; aynı soru `packages.lock.tsv` ve `repo-manifest.yaml` ile yanıtlanır.
Kuran kişi paket **aramaz**, ama paket yükünü kendiliğinden de indirmez — bu bilinçli bir ayrımdır. LFS yükü ~884 MB olduğu ve 700 cihazın her biri onu çekerse aylık bant kotası ilk günde tükeneceği için klon hafif tutulur; yük **yerel aynadan** (USB ya da repo sorumlusunun bıraktığı kopya) gelir, sahada internet gerekmez. Yalnız internet varken ve tek seferlik bir çekme uygunsa `git lfs pull --include='provisioning/offline-repo/built/pool/*'` kullanılır.
Durum makinesi, "kuruldu" ile "merkezce güvenildi" arasındaki farkı görünür yapar: offline kurulum yalnız `PROVISIONED_OFFLINE` üretir. Durum makinesi, "kuruldu" ile "merkezce güvenildi" arasındaki farkı görünür yapar: offline kurulum yalnız `PROVISIONED_OFFLINE` üretir.
@ -56,7 +58,7 @@ Durum makinesi, "kuruldu" ile "merkezce güvenildi" arasındaki farkı görünü
## 7. Dezavantajlar ## 7. Dezavantajlar
- Git deposu paket setiyle birlikte büyür (LFS clone/pull maliyeti); derin clone sahada zaman alabilir. - Git deposu paket setiyle birlikte büyür (LFS; içerik-hash ile tekilleşir, ~884 MB). Klon hafiftir ama paket yükü ayrıca taşınmalıdır: 700 cihazın her biri LFS'ten çekerse bant kotası tükenir, bu yüzden yük yerel aynadan dağıtılır.
- Offline cihaz merkezi izleme ve erişim kanalları doğrulanana kadar operasyonel olarak hazır değildir. - Offline cihaz merkezi izleme ve erişim kanalları doğrulanana kadar operasyonel olarak hazır değildir.
- Paket snapshot'ı yenilenmediği sürece güvenlik güncellemeleri geride kalır (onaysız update zaten yasak, K-11). - Paket snapshot'ı yenilenmediği sürece güvenlik güncellemeleri geride kalır (onaysız update zaten yasak, K-11).
@ -81,7 +83,16 @@ Durum makinesi, "kuruldu" ile "merkezce güvenildi" arasındaki farkı görünü
### Kuran kişinin akışı (grafik arayüz — ana yol) ### Kuran kişinin akışı (grafik arayüz — ana yol)
1. Depoyu getir: `git clone <repo>` (paketler Git LFS ile gelir) — veya repo sorumlusunun bıraktığı kopyada `/opt/blueforce/offline-repo` hazırdır. 1. Depoyu getir: `git clone <repo>` — **paketler bilerek gelmez.** Bootstrap (ve her git işlemi) `GIT_LFS_SKIP_SMUDGE=1` ile çalışır, çünkü paket yükü ~884 MB'tır ve tam klon sahada 2.7 GB'lık bir çalışma kopyası üretir (ölçüldü; smudge kapalıyken 20 MB). Paket yükünü almanın iki yolu var:
- **Yerel ayna (önerilen, sahada internet yok):** repo sorumlusunun hazırladığı `/opt/blueforce/offline-repo` kopyasını taşıyın; ya da
- **Hedefli LFS çekme (yalnız internet varken):** depo kökünde
```
git lfs pull --include='provisioning/offline-repo/built/pool/*'
```
Bu yalnız teslim havuzunu indirir (`packages/` altındaki kaynak kopyayı çekmez, aynı baytları ikinci kez indirmez).
Paketler gelmediyse kurucu bunu **tek cümleyle** söyler (`Paketler henüz indirilmemiş: … Git LFS işaretçisi`), çünkü havuzdaki dosyalar `.deb` değil 131 baytlık işaretçi metnidir.
2. `sudo bf` — pencere açılır: **Blueforce Field OS — Yönetim**. 2. `sudo bf` — pencere açılır: **Blueforce Field OS — Yönetim**.
3. Üst çubuk **Hedef > Bayi no (8 hane)** alanına numarayı yaz (örn. `12010193`). 3. Üst çubuk **Hedef > Bayi no (8 hane)** alanına numarayı yaz (örn. `12010193`).
4. **Kurulum** sekmesi → **`Bu cihazı kur`** (hiçbir şey yazmayan ön kontrol için: `Kurulum ön kontrolü (--check)`). 4. **Kurulum** sekmesi → **`Bu cihazı kur`** (hiçbir şey yazmayan ön kontrol için: `Kurulum ön kontrolü (--check)`).
@ -177,8 +188,9 @@ Grafik operatör uygulaması Tkinter ile yazılmıştır ve tkinter Ubuntu'da **
## 12. Kontrol Listesi ## 12. Kontrol Listesi
- [ ] Paket seti repoda: `provisioning/offline-repo/packages/` (1321 `.deb`, Git LFS ile takip ediliyor). - [ ] Paket kaynağı repoda: `provisioning/offline-repo/packages/` (1321 `.deb`, Git LFS ile takip ediliyor; teslim kopyası `built/pool/` içindedir).
- [ ] Bundle indeksi repoda: `provisioning/offline-repo/built/` (`Packages`, `Packages.gz`, `packages.lock.tsv`, `SHA256SUMS`, `pool/`). - [ ] Bundle indeksi repoda: `provisioning/offline-repo/built/` (`Packages`, `Packages.gz`, `packages.lock.tsv`, `SHA256SUMS`, `pool/`).
- [ ] Paket yükü sahaya taşındı: yerel ayna (USB ya da `/opt/blueforce/offline-repo`) — klon LFS yükünü **bilerek** getirmez, bu adım atlanırsa kurucu `Paketler henüz indirilmemiş: … Git LFS işaretçisi` ile durur.
- [ ] `repo-manifest.yaml` güncel; pin listesi ile paket havuzu uyuşuyor. - [ ] `repo-manifest.yaml` güncel; pin listesi ile paket havuzu uyuşuyor.
- [ ] Depoda/repoda cihaz kimliği, private key veya kalıcı token yok. - [ ] Depoda/repoda cihaz kimliği, private key veya kalıcı token yok.
- [ ] `python3-tk` ve bağımlılıkları `manifests/base-packages.txt` içinde pinli; `18-final-check` tkinter'ı denetliyor. - [ ] `python3-tk` ve bağımlılıkları `manifests/base-packages.txt` içinde pinli; `18-final-check` tkinter'ı denetliyor.
@ -200,7 +212,7 @@ Grafik operatör uygulaması Tkinter ile yazılmıştır ve tkinter Ubuntu'da **
```mermaid ```mermaid
flowchart LR flowchart LR
M["git checkout (paketler LFS ile)<br/>veya /opt/blueforce/offline-repo"] --> G["sudo bf → Kurulum → 'Bu cihazı kur'"] M["git clone (hafif: LFS smudge kapalı)<br/>+ paket yükü yerel aynadan"] --> G["sudo bf → Kurulum → 'Bu cihazı kur'"]
G --> D{"Kurucu yerel depoyu arar<br/>BF_OFFLINE_REPO → repo/built → /opt/blueforce/offline-repo"} G --> D{"Kurucu yerel depoyu arar<br/>BF_OFFLINE_REPO → repo/built → /opt/blueforce/offline-repo"}
D -->|"hiçbiri yok / eksik"| X["HATA: Yerel paket deposu yok<br/>aranan yollar + ÇIKIŞ NEDENİ · exit 1<br/>sistem değişmez"] D -->|"hiçbiri yok / eksik"| X["HATA: Yerel paket deposu yok<br/>aranan yollar + ÇIKIŞ NEDENİ · exit 1<br/>sistem değişmez"]
D -->|"eksiksiz aday bulundu"| V{"Doğrulama katmanı:<br/>dosya listesi, SHA256SUMS, kilit, pin ↔ pool"} D -->|"eksiksiz aday bulundu"| V{"Doğrulama katmanı:<br/>dosya listesi, SHA256SUMS, kilit, pin ↔ pool"}

View file

@ -4,7 +4,7 @@
- Dosya: `docs/33-OPERATOR-CONSOLE.md` - Dosya: `docs/33-OPERATOR-CONSOLE.md`
- İlgili kararlar: `24-DECISION-LOG.md#K-09` (golden image + kurucu), `#K-12` (kimlik formatı `^[0-9]{8}$`), `#K-13` (SSH anahtar zorunlu), `#K-11` (onaysız update yasağı), `#K-21` (durum modeli), `#K-22` (RDP her zaman hazır / `bf-gui-*` yalnız yerel GUI) - İlgili kararlar: `24-DECISION-LOG.md#K-09` (golden image + kurucu), `#K-12` (kimlik formatı `^[0-9]{8}$`), `#K-13` (SSH anahtar zorunlu), `#K-11` (onaysız update yasağı), `#K-21` (durum modeli), `#K-22` (RDP her zaman hazır / `bf-gui-*` yalnız yerel GUI)
- Uygulama: Python paketi `bfos` (depo kökü) — `bfos/ui_gui.py` (grafik arayüz, tkinter), `bfos/tui.py` + `bfos/cli.py` (terminal konsolu), `bfos/operations.py` (iş mantığı + işlem kataloğu), `bfos/runner.py` (çalıştırma + denetim kaydı), `bfos/config.py` (Türkçe metinler) - Uygulama: Python paketi `bfos` (depo kökü) — `bfos/ui_gui.py` (grafik arayüz, tkinter), `bfos/ui_theme.py` (tasarım belirteçleri: `web/static/tokens.css`'in aynası), `bfos/tui.py` + `bfos/cli.py` (terminal konsolu), `bfos/operations.py` (iş mantığı + işlem kataloğu), `bfos/runner.py` (çalıştırma + denetim kaydı), `bfos/config.py` (Türkçe metinler)
- Giriş noktası: `admin/bf` (sarmalayıcı) → `sudo bf` ve `sudo bf --tui`. Kurucu: `admin/bf-bootstrap.sh` - Giriş noktası: `admin/bf` (sarmalayıcı) → `sudo bf` ve `sudo bf --tui`. Kurucu: `admin/bf-bootstrap.sh`
- Yedek yol: `admin/bf-menu` + `admin/lib/*` (eski bash konsolu) — ana yol değildir; yalnız Python'suz cihazlar ve olağanüstü durumlar için korunur - Yedek yol: `admin/bf-menu` + `admin/lib/*` (eski bash konsolu) — ana yol değildir; yalnız Python'suz cihazlar ve olağanüstü durumlar için korunur
- Durum: [ ] Taslak — pencere yerleşimi ve örnek satırlar LAB'da alınan gerçek koşuyla güncellenir - Durum: [ ] Taslak — pencere yerleşimi ve örnek satırlar LAB'da alınan gerçek koşuyla güncellenir
@ -252,7 +252,7 @@ Pencere açılıyorsa kurulum tamam. Bundan sonra her iş için aynı komut:
sudo bf sudo bf
``` ```
> **Bundan sonra ezberleyeceğin tek şey bu komuttur.** Pencereden çıkmak için `Çıkış` düğmesi (basit modda üçüncü düğme, alt çubukta da bir `Çıkış` düğmesi var). > **Bundan sonra ezberleyeceğin tek şey bu komuttur.** Pencereden çıkmak için `Çıkış` düğmesi: **basit modda** üçüncü büyük düğme, **detaylı modda** eylem çubuğunun alt satırında. Tek çıkış kontrolü olduğu için ikisi aynı anda görünmez.
**tkinter notu (grafik arayüz için gerekli).** Pencerenin açılabilmesi için `python3-tk` paketi gerekir. Bu paket **çevrimdışı depoda pinlidir** (`provisioning/offline-repo/manifests/base-packages.txt`), yani normal kurulumda senin bir şey yapmana gerek yoktur; kurulum sonu kapısı (`scripts/install/installer/modules/18-final-check.sh`) tkinter'ı denetler. Yine de eksikse uygulama şu Türkçe hatayı verir ve çıkış kodu `3` ile biter: **tkinter notu (grafik arayüz için gerekli).** Pencerenin açılabilmesi için `python3-tk` paketi gerekir. Bu paket **çevrimdışı depoda pinlidir** (`provisioning/offline-repo/manifests/base-packages.txt`), yani normal kurulumda senin bir şey yapmana gerek yoktur; kurulum sonu kapısı (`scripts/install/installer/modules/18-final-check.sh`) tkinter'ı denetler. Yine de eksikse uygulama şu Türkçe hatayı verir ve çıkış kodu `3` ile biter:
@ -286,7 +286,7 @@ Yerel ekranda çalıştırmak için oturum açık bir makinede deneyin:
### 9.2 Arayüzü kullanma — sekme sekme ### 9.2 Arayüzü kullanma — sekme sekme
`sudo bf` çalıştırınca **basit mod** açılır. Başlıkta `Blueforce Field OS — Yönetim`, altında `Blueforce saha cihazı yönetimi (Kur / Durum / Bakım)`, ardından depo yolu ve kullanılan katalog kaynağı (gerçek iş mantığı katmanı, örn. `bfos.operations + bfos.runner`) görünür. `sudo bf` çalıştırınca **basit mod** açılır. Pencere **koyu bir başlık bandıyla** açılır; bandda solda `Blueforce Field OS — Yönetim` ve `Blueforce saha cihazı yönetimi (Kur / Durum / Bakım)`, ortada **hedef kimliği** (`HEDEF`: `— seçilmedi` / `cihaz BF-12010193 (bf-12010193)` / `dalga 'pilot_1'` / `tüm filo (envanterdeki her cihaz)`), sağda **cihaz durumu** rozeti (`KURULDU` / `KAYITLI` / `HAZIR` / `HATA` / `YOK`) ve **yetki** rozeti (`TAM YETKİ` ya da `SALT-OKUNUR`) durur. Yetki bir durumdur, gömülü bir cümle değildir: yetkisiz açılışta bandın hemen altında turuncu bir şerit aynı bilgiyi tam cümleyle yineler. Bandın altında `HEDEF` paneli (bayi no, dalga, tüm filo), gövde, komut çıktısı ve eylem çubuğu gelir; depo yolu ile kullanılan katalog kaynağı (örn. `bfos.operations + bfos.runner`) bandın içinde sessiz bir satırdır.
**Basit mod (varsayılan):** ekranda `Ne yapmak istiyorsunuz?` sorusu ve üç büyük düğme — `Kur`, `Durum`, `Çıkış`. `Kur` bir seçim penceresi açar: `Ön kontrol (--check)`, `Kurulumu başlat`, `Vazgeç`. `Durum` salt-okunur özeti (`info-summary`) çalıştırır. Daha fazlası için üst çubuktaki **`Detaylı mod`** düğmesine bas. **Basit mod (varsayılan):** ekranda `Ne yapmak istiyorsunuz?` sorusu ve üç büyük düğme — `Kur`, `Durum`, `Çıkış`. `Kur` bir seçim penceresi açar: `Ön kontrol (--check)`, `Kurulumu başlat`, `Vazgeç`. `Durum` salt-okunur özeti (`info-summary`) çalıştırır. Daha fazlası için üst çubuktaki **`Detaylı mod`** düğmesine bas.
@ -298,7 +298,7 @@ Yerel ekranda çalıştırmak için oturum açık bir makinede deneyin:
[ Çıkış ] pencereyi kapatır [ Çıkış ] pencereyi kapatır
``` ```
**Detaylı mod:** 5 kategori **sekmesi** açılır. Her sekmede solda işlem listesi (sütunlar: `İşlem`, `Kapsam`, `Tür`), sağda `Seçenekler ve açıklama` paneli vardır; seçtiğin işlemin kapsamı, playbook yolu ve varsa zorunlu alanları orada görünür. Alt çubukta `Komutu göster ve çalıştır`, `Çıktıyı temizle`, `Çalışanı iptal et`, `Yalnız denetim (--check)`, `Çıkış` ve durum etiketi (`Hazır.`) bulunur. **Detaylı mod:** 5 kategori **sekmesi** açılır. Sekmelerin üstünde bir **özet şerit** vardır: büyük rakam işlem sayısıdır (`48 işlem`), yanında `13 yıkıcı` ve `35 salt-okunur` sessiz satırlar olarak durur; yetkisiz oturumda şeridin sağında `yazma işlemleri kapalı` yazar. Her sekmede solda işlem listesi (sütunlar: `İşlem`, `Kapsam`, `Tür`, `Durum`) ve üstünde bir `Süz:` alanı, sağda `SEÇENEKLER VE AÇIKLAMA` paneli vardır; seçtiğin işlemin kapsamı, playbook yolu ve varsa zorunlu alanları orada görünür. `Tür` sütunu yıkıcı işlemlerde kırmızı `YIKICI`, diğerlerinde `salt-okunur` yazar; `Durum` sütunu işlemin çalıştırılabilir olduğunu (`açık`) ya da kapalı olduğunu (`kapalı — sudo bf`) söyler. Eylem çubuğu iki satırdır: üstte `Komutu göster ve çalıştır` (yıkıcı işlem seçiliyse kırmızı, yetkisizse kapalı ve nedeni yanında yazılı) ve sağında durum göstergesi (`HAZIR` / `ÇALIŞIYOR` / `TAMAM` / `HATA` — sözcük + renk); altta `Çıktıyı temizle`, `Çalışanı iptal et`, `Yalnız denetim (--check)`, `Çıktı panelini gizle` ve `Çıkış`.
| Sekme | İşlem sayısı | Yazma (yıkıcı) | Salt-okunur | | Sekme | İşlem sayısı | Yazma (yıkıcı) | Salt-okunur |
|---|---|---|---| |---|---|---|---|
@ -381,7 +381,9 @@ Kurulum seçenekleri: `Yerel çevrimdışı depodan kur (--offline)` ve `Zaten O
Filo işlemlerinde hedef **üst çubuktan** seçilir; işlem seçildiğinde ekran hedefi ayrıca sormaz, çubuktaki değeri kullanır: Filo işlemlerinde hedef **üst çubuktan** seçilir; işlem seçildiğinde ekran hedefi ayrıca sormaz, çubuktaki değeri kullanır:
```text ```text
[Detaylı mod] Hedef: [ 12010193 ] Bayi no (8 hane) [ pilot_1 ▾ ] Dalga [ ] Tüm filo (dikkat) HEDEF [ Detaylı mod ]
Bayi no (8 hane) [ 12010193 ] Dalga [ pilot_1 ▾ ] [ ] Tüm filo (dikkat)
Bayi no geçerli: BF-12010193
``` ```
| Hedef türü | Çubukta ne yaparsın | Örnek | Boyut | Tipik kullanım | | Hedef türü | Çubukta ne yaparsın | Örnek | Boyut | Tipik kullanım |
@ -416,8 +418,9 @@ Pencere açılır: `Blueforce Field OS — Yönetim` / `Blueforce saha cihazı y
1) Üst çubuktaki `Bayi no (8 hane)` alanına `12010193` yaz: 1) Üst çubuktaki `Bayi no (8 hane)` alanına `12010193` yaz:
```text ```text
Hedef: [ 12010193 ] Bayi no (8 hane) [ lab ▾ ] Dalga [ ] Tüm filo (dikkat) HEDEF
durum: Bayi no geçerli: BF-12010193 Bayi no (8 hane) [ 12010193 ] Dalga [ lab ▾ ] [ ] Tüm filo (dikkat)
Bayi no geçerli: BF-12010193
``` ```
2) Basit modda `Kur` düğmesine bas. Seçim penceresi açılır (başlık: `Kurulum`): 2) Basit modda `Kur` düğmesine bas. Seçim penceresi açılır (başlık: `Kurulum`):
@ -525,7 +528,7 @@ Teşhis paketi için: `Araçlar` sekmesi → `Destek paketi oluştur`. Paket yol
| # | Belirti (ekranda ne görürsün) | Neden | Çözüm | | # | Belirti (ekranda ne görürsün) | Neden | Çözüm |
|---|---|---|---| |---|---|---|---|
| 1 | `bf: command not found` | Bootstrap çalışmamış veya araçlar bağlanmamış | Bootstrap'ı tekrar koş: `sudo admin/bf-bootstrap.sh`; sonra tam yoldan dene: `sudo /usr/local/bin/bf`. Python'suz cihazda yedek: `sudo bf-menu` | | 1 | `bf: command not found` | Bootstrap çalışmamış veya araçlar bağlanmamış | Bootstrap'ı tekrar koş: `sudo admin/bf-bootstrap.sh`; sonra tam yoldan dene: `sudo /usr/local/bin/bf`. Python'suz cihazda yedek: `sudo bf-menu` |
| 2 | Pencere açılıyor ama başlıkta turuncu şerit: `Yönetici yetkisi yok: salt-okunur raporlar çalışır, kurulum ve değişiklik gerektiren işlemler kapalıdır. Tam yetki için: sudo bf` | Arayüz `sudo` olmadan açıldı; yazma işlemleri kapalı | Pencereyi kapat ve `sudo bf` ile aç (§9.8) | | 2 | Başlık bandında turuncu `SALT-OKUNUR` rozeti ve bandın altında turuncu şerit: `Yönetici yetkisi yok: salt-okunur raporlar çalışır, kurulum ve değişiklik gerektiren işlemler kapalıdır. Tam yetki için: sudo bf` | Arayüz `sudo` olmadan açıldı; yazma işlemleri kapalı (listede kalır, `Durum` sütunu `kapalı — sudo bf` yazar) | Pencereyi kapat ve `sudo bf` ile aç (§9.8) |
| 3 | Hata penceresi: `Geçersiz bayi numarası '1201019': tam 8 hane olmalıdır (verilen uzunluk: 7). Örnek: 12010193` | Bayi no 8 haneli ve yalnız rakam olmalı | Tam 8 hane, sadece rakam gir; baştaki sıfırı silme. Örnek: `12010193` | | 3 | Hata penceresi: `Geçersiz bayi numarası '1201019': tam 8 hane olmalıdır (verilen uzunluk: 7). Örnek: 12010193` | Bayi no 8 haneli ve yalnız rakam olmalı | Tam 8 hane, sadece rakam gir; baştaki sıfırı silme. Örnek: `12010193` |
| 4 | Envanter notunda `UYARI: ansible-playbook bulunamadı; filo işlemleri çalışmaz (sudo apt install ansible).` | Makinede Ansible yok | `sudo apt install ansible` (reponun kurulumu `ansible-core`'u zaten kurar) ve araçları bağla: `sudo admin/bf-bootstrap.sh` | | 4 | Envanter notunda `UYARI: ansible-playbook bulunamadı; filo işlemleri çalışmaz (sudo apt install ansible).` | Makinede Ansible yok | `sudo apt install ansible` (reponun kurulumu `ansible-core`'u zaten kurar) ve araçları bağla: `sudo admin/bf-bootstrap.sh` |
| 5 | `Komut üretilemedi`: `Playbook bulunamadı: ansible/playbooks/...` veya `... konsol kapsamı 'X' beklerken playbook 'Y' beyan ediyor.` | Katalog ile repo checkout'u uyuşmuyor (eski checkout veya `--repo` yanlış) | `Araçlar` sekmesinden `Bu kopyayı güncelle (git pull --ff-only)` veya doğru repoyu ver: `sudo bf --repo /opt/blueforce-linux`; arayüzü yeniden başlat | | 5 | `Komut üretilemedi`: `Playbook bulunamadı: ansible/playbooks/...` veya `... konsol kapsamı 'X' beklerken playbook 'Y' beyan ediyor.` | Katalog ile repo checkout'u uyuşmuyor (eski checkout veya `--repo` yanlış) | `Araçlar` sekmesinden `Bu kopyayı güncelle (git pull --ff-only)` veya doğru repoyu ver: `sudo bf --repo /opt/blueforce-linux`; arayüzü yeniden başlat |
@ -553,8 +556,8 @@ Arayüz beklenen kullanımda **tam yetkili** çalışır: `sudo bf`. Kurulum bet
| Durum | Ne olur | | Durum | Ne olur |
|---|---| |---|---|
| `sudo bf` (tam yetki) | 48 işlemin tamamı çalışır: kurulum, filo bakımı, paket güncellemesi, kontrollü reboot ve `--check` koşuları | | `sudo bf` (tam yetki) | Başlık bandında yeşil `TAM YETKİ` rozeti. 48 işlemin tamamı çalışır: kurulum, filo bakımı, paket güncellemesi, kontrollü reboot ve `--check` koşuları |
| Root değil (yetkisiz açılış) | Başlıkta turuncu uyarı şeridi: `Yönetici yetkisi yok: salt-okunur raporlar çalışır, kurulum ve değişiklik gerektiren işlemler kapalıdır. Tam yetki için: sudo bf`. **35 salt-okunur işlem** çalışır (raporlar, envanter denetimi, bilgi ekranları, destek paketi); **13 yıkıcı işlem kapalıdır** | | Root değil (yetkisiz açılış) | Başlık bandında turuncu `SALT-OKUNUR` rozeti; bandın altında turuncu şerit: `Yönetici yetkisi yok: salt-okunur raporlar çalışır, kurulum ve değişiklik gerektiren işlemler kapalıdır. Tam yetki için: sudo bf`. **35 salt-okunur işlem** çalışır (raporlar, envanter denetimi, bilgi ekranları, destek paketi); **13 yıkıcı işlem listede kalır ama kapalıdır** (`Tür` = `YIKICI`, `Durum` = `kapalı — sudo bf`) ve `Komutu göster ve çalıştır` düğmesi kapalı çizilip nedenini kendi yanında yazar |
| Yetkisizken yazma denenirse | Yazma işlemleri düğme düzeyinde kapalıdır; bir yol bulunup denenirse **çalıştırma katmanı** reddeder ve çıktı alanına `[ENGELLENDİ] Bu işlem yönetici yetkisi gerektirir (sudo bf).` yazılır, işlem çalışmaz (çıkış kodu 2) | | Yetkisizken yazma denenirse | Yazma işlemleri düğme düzeyinde kapalıdır; bir yol bulunup denenirse **çalıştırma katmanı** reddeder ve çıktı alanına `[ENGELLENDİ] Bu işlem yönetici yetkisi gerektirir (sudo bf).` yazılır, işlem çalışmaz (çıkış kodu 2) |
| `--check` kipi | Salt-okunur olduğu için yetki istemez: `Yalnız denetim (--check)` kutusu işaretliyse deneme koşusu yetkisiz oturumda da yapılabilir | | `--check` kipi | Salt-okunur olduğu için yetki istemez: `Yalnız denetim (--check)` kutusu işaretliyse deneme koşusu yetkisiz oturumda da yapılabilir |
@ -569,6 +572,7 @@ Planlama yetki istemez: komut önizlemesi, onay pencereleri ve `--check` üretim
| `sudo admin/bf-bootstrap.sh` iki kez koşulur | İkinci koşu hata üretmez (idempotent), araçlar yerinde, `/usr/local/bin/bf` bağlı | LAB(2) | | `sudo admin/bf-bootstrap.sh` iki kez koşulur | İkinci koşu hata üretmez (idempotent), araçlar yerinde, `/usr/local/bin/bf` bağlı | LAB(2) |
| `sudo bf --list` / `python3 -m bfos.ui_gui --list` | 48 işlem, 5 kategori, 13 YIKICI / 35 salt-okunur, pencere açılmadan listelenir | LAB(2) | | `sudo bf --list` / `python3 -m bfos.ui_gui --list` | 48 işlem, 5 kategori, 13 YIKICI / 35 salt-okunur, pencere açılmadan listelenir | LAB(2) |
| `sudo bf` açılır, `Çıkış` ile kapanır | Pencere açılır, hiçbir değişiklik yapılmaz | LAB(2) | | `sudo bf` açılır, `Çıkış` ile kapanır | Pencere açılır, hiçbir değişiklik yapılmaz | LAB(2) |
| `python3 tests/test_bfos_gui_static.py` | Tema belirteçleri `web/static/tokens.css` ile birebir aynı; durum sözlüğü beş sözcük; yetkisiz oturumda yazma işlemleri listede ve nedeni yazılı; pencere açılmadan koşar | LAB(2) |
| `sudo bf --tui` ile aynı işlem listesi | Terminal konsolu iki giriş noktası ayrışmadan çalışır | LAB(2) | | `sudo bf --tui` ile aynı işlem listesi | Terminal konsolu iki giriş noktası ayrışmadan çalışır | LAB(2) |
| Grafik ekran olmadan (`env -u DISPLAY -u WAYLAND_DISPLAY sudo bf`) | Türkçe hata + `sudo bf --tui` önerisi, çıkış kodu `3` | LAB(2) | | Grafik ekran olmadan (`env -u DISPLAY -u WAYLAND_DISPLAY sudo bf`) | Türkçe hata + `sudo bf --tui` önerisi, çıkış kodu `3` | LAB(2) |
| tkinter'siz ortamda başlatma | Türkçe hata + `python3-tk` ipucu + `--tui` önerisi, çıkış kodu `3`; kurulum sonu kapısı bunu WARN olarak kaydeder | LAB(2) | | tkinter'siz ortamda başlatma | Türkçe hata + `python3-tk` ipucu + `--tui` önerisi, çıkış kodu `3`; kurulum sonu kapısı bunu WARN olarak kaydeder | LAB(2) |
@ -603,6 +607,7 @@ Planlama yetki istemez: komut önizlemesi, onay pencereleri ve `--check` üretim
- [x] Filo işlemleri açık hedefle; wave-scoped filo akışı dalga dalga ilerliyor; production için `production` yazma zorunlu. - [x] Filo işlemleri açık hedefle; wave-scoped filo akışı dalga dalga ilerliyor; production için `production` yazma zorunlu.
- [x] Katalog, playbook kapsamıyla uyuşmazsa çalıştırmayı reddediyor (kapsam playbook dosyasından okunur). - [x] Katalog, playbook kapsamıyla uyuşmazsa çalıştırmayı reddediyor (kapsam playbook dosyasından okunur).
- [x] Yetki modeli: `sudo bf` tam yetki; yetkisiz açılışta yazma işlemleri kapalı ve çalıştırma katmanı `[ENGELLENDİ]` ile reddediyor. - [x] Yetki modeli: `sudo bf` tam yetki; yetkisiz açılışta yazma işlemleri kapalı ve çalıştırma katmanı `[ENGELLENDİ]` ile reddediyor.
- [x] Pencere `docs/DESIGN.md` yönergesine göre çiziliyor: koyu başlık bandı (ürün adı + hedef kimliği + cihaz durumu + yetki), beyaz paneller, `KURULDU`/`KAYITLI`/`HAZIR`/`HATA`/`YOK` rozetleri (sözcük + renk), tek çıkış kontrolü; renk ve boşluk değerleri `bfos/ui_theme.py` üzerinden `web/static/tokens.css`'ten geliyor ve statik testle karşılaştırılıyor.
- [x] Grafik ekran/tkinter yokluğunda Türkçe hata + `sudo bf --tui` önerisi ve çıkış kodu 3. - [x] Grafik ekran/tkinter yokluğunda Türkçe hata + `sudo bf --tui` önerisi ve çıkış kodu 3.
- [x] Şifre üretimi varsayılan **kapalı**; yalnız `bf-creds --generate` ile üretiliyor; parola ekrana/loga gitmiyor. - [x] Şifre üretimi varsayılan **kapalı**; yalnız `bf-creds --generate` ile üretiliyor; parola ekrana/loga gitmiyor.
- [x] `Cihaz işlemleri` içinde onaysız güncelleme seçeneği **yok**; `Bekleyen güvenlik güncellemeleri` yalnız rapor. - [x] `Cihaz işlemleri` içinde onaysız güncelleme seçeneği **yok**; `Bekleyen güvenlik güncellemeleri` yalnız rapor.

80
docs/DESIGN.md Normal file
View file

@ -0,0 +1,80 @@
# Blueforce Field OS — Design System
Two operator surfaces, one visual language: the **device console** (Python/tkinter,
ships on each field PC) and the **central panel** (Python stdlib `http.server`,
browser). Both describe the same fleet from opposite ends, so they share a status
vocabulary and a token set. Palette values live in `web/static/tokens.css`; the
console reads the same values through `bfos/ui_theme.py`.
## Direction contract
**THESIS.** The device's state is the subject, and it must be legible from a step
back — on a workshop laptop, in daylight, with someone waiting. This surface
refuses the category default it would otherwise inherit: a near-black "ops
console" with grey body text, six hues fighting for meaning, and status buried in
a log. Instead: a light ground, dark text with real contrast, and saturation
spent only where it carries a state.
**OWN-WORLD.** Light neutral ground (`--bg-app #f6f7f8`) with white raised panels
and hairline borders; one dark region, the app header, which anchors the frame.
One action accent (`--accent #1d4ed8`) marking the action the operator came to
take and the selected row, nothing else. Four state severities — ok / warn /
danger / info — each with a strong and a soft form, so a badge or a row can be
built from one palette without inventing a second. Data (ids, counts, exit codes,
timestamps) set in monospace so columns align; UI text in the platform face.
Buttons and inputs share one height; rows share one height; a 4px space rhythm.
**STORY.** The operator sees at a glance whether the device (or the fleet) is
ready, what stage it reached, and what the one next action is. A technician
understands an install's outcome without reading the log; an operator scanning
700 devices finds the ones that need attention without opening them.
**FIRST VIEWPORT.** Header band (dark, 56px) carrying the product name, the
target identity, and privilege level on the right — privilege is never a buried
sentence. Below it, a two-column body: left, the fleet/device list with a state
badge per row, sorted so non-ready devices surface first, with a filter bar and a
summary strip of counts by state (the one large figure per view). Right, the
selected device's detail: state, target id, hostname, wave, and the channel
verification list. The primary action sits in the header, not below the fold.
**FORM.** Operate surface. The world is a *control room board*: fixed regions,
legible readouts, one accent, state as signal. No decorative motion; the only
transition is a state change and a row selection.
**FINISH.** unreviewed and undocumented is unfinished; this build ends with the
finish review, the verdict, DESIGN.md, and every shipping raster carrying its
provenance.
## Status vocabulary
One word per state, Turkish, used identically in both surfaces.
| State | Token | Meaning |
|---|---|---|
| `KURULDU` | `--state-provisioned` | Provisioned offline, not yet enrolled |
| `KAYITLI` | `--state-enrolled` | Enrolled, channels not all verified |
| `HAZIR` | `--state-ready` | Every channel verified; the only shippable state |
| `HATA` | `--state-failed` | A check failed; the device needs a person |
| `YOK` | `--state-absent` | Not reachable / never seen |
Colour is never the only carrier: every badge ships its word, and the fleet table
sorts non-ready first, so the list is readable in greyscale.
## Privilege
Read-only and full privilege are visually distinct states, not a log line: the
header states the current level, and the console disables rather than hides
write operations when unprivileged, with the reason on the control.
## Rules
1. Saturation carries meaning. A colour is a state or the action accent; it is
never decoration, and a fifth hue is a bug.
2. Badges carry words. Greyscale must not lose the state.
3. One loud figure per view. Everything else is quiet and aligned.
4. No webfont. The fleet installs offline; the UI face is whatever the platform
ships and the data face is monospace.
5. Destructive actions look different and ask twice. Approval and the root gate
are design, not an afterthought.
6. The log is evidence, not the interface. Outcomes surface as state and
structure first; raw output stays available, collapsed.

61
docs/UI-REMAINING-WORK.md Normal file
View 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.

Binary file not shown.

Binary file not shown.

BIN
provisioning/offline-repo/built/pool/acl_2.3.2-2_amd64.deb (Stored with Git LFS) Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
provisioning/offline-repo/built/pool/baobab_49.1-2_amd64.deb (Stored with Git LFS) Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
provisioning/offline-repo/built/pool/bolt_0.9.10-1_amd64.deb (Stored with Git LFS) Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show more