Unify Blueforce identity and Python operator surfaces

This commit is contained in:
emirhanmamak-blueforce 2026-09-17 13:22:26 +03:00
parent 708453677d
commit 5cc312f58a
89 changed files with 495 additions and 27523 deletions

View file

@ -80,7 +80,7 @@ flowchart TB
## Kurulum Akışı (özet)
> **Birincil yol git'tir.** Cihazı kurmanın standart yolu §[Hızlı Başlangıç](#hızlı-başlangıç-git-ile-kurulum) adımlarıdır (depo + `admin/bf-bootstrap.sh` + `sudo bf-menu`, bkz. [`docs/33`](docs/33-OPERATOR-CONSOLE.md)). Aşağıdaki ISO/firstboot akışı **ikincil/opsiyonel** yoldur; silinmemiştir ve offline/air-gapped saha için korunur.
> **Birincil yol git'tir.** Cihazı kurmanın standart yolu §[Hızlı Başlangıç](#hızlı-başlangıç-git-ile-kurulum) adımlarıdır (depo + `admin/bf-bootstrap.sh` + `sudo bf`, bkz. [`docs/33`](docs/33-OPERATOR-CONSOLE.md)). ISO/firstboot akışı offline saha için korunur.
```mermaid
flowchart LR
@ -168,7 +168,7 @@ Tüm numaralı dokümanlar `docs/` altındadır ve `docs/_TEMPLATE.md` şablonun
| 30 | `30-FIELD-OS-RELEASE-MANAGEMENT.md` | Field OS release, manifest ve rollback |
| 31 | `31-LEARNING-GUIDE.md` | Field OS öğrenme rehberi (LAB pratiği) |
| 32 | `32-REPOSITORY-AUDIT-AND-CONFLICTS.md` | Depo denetimi, çelişki raporu ve giderim durumu |
| 33 | `33-OPERATOR-CONSOLE.md` | Operatör konsolu (`bf-menu`) — sıfırdan başlayan için git kurulumu + menü rehberi |
| 33 | `33-OPERATOR-CONSOLE.md` | Python operatör konsolu (`bf`, `bf --tui`) — git kurulumu + çalışma rehberi |
Şablon: `docs/_TEMPLATE.md` (siteye alınmaz). Karar günlüğü: `docs/24-DECISION-LOG.md`. Özet ve soru-cevap: `SUMMARY.md`.
@ -180,7 +180,7 @@ Tüm numaralı dokümanlar `docs/` altındadır ve `docs/_TEMPLATE.md` şablonun
├── SUMMARY.md # Yönetici özeti, 39 soru-cevap, FREE bileşen listesi
├── IMPLEMENTATION-REPORT.md # Uygulama raporu (ne yapıldı / hangi testler geçti / LAB bekleyenler)
├── docs/ # 34 numaralı doküman (00–33) + _TEMPLATE.md
├── admin/ # git'ten tek komut kurulum (bf-bootstrap.sh, bf-creds, bf-menu)
├── admin/ # git'ten tek komut kurulum (bf-bootstrap.sh, bf-creds, bf)
├── provisioning/ # Field OS medya üretimi
│ ├── autoinstall/ # autoinstall.yaml + user-data + meta-data (interactive storage)
│ ├── iso/ # ISO doğrulama + build-blueforce-iso.sh (+ test-iso.sh)

View file

@ -54,7 +54,7 @@ Bu 12 soru + öğrenme rehberi sorusu, karar günlüğünde S-28…S-37 olarak i
[`05`](docs/05-ONE-CLICK-INSTALLER.md), [`27`](docs/27-OFFLINE-PROVISIONING.md), [`29`](docs/29-DEVICE-ENROLLMENT.md), [`20`](docs/20-FIELD-TECHNICIAN-RUNBOOK.md) (K-16, K-17, K-21)
8. **Installer nasıl çalışacak?** — Tek giriş grafik operatör uygulamasıdır (`sudo bf` → `Kurulum` sekmesi →
"Bu cihazı kur"); bu düğme `blueforce-install.sh --dealer-id <8hane>` motorunu çağırır ve paket deposunu
otomatik bulur (`--offline` zorunlu değil). Yedek yol `sudo bf --tui`, acil yedek bash menü `sudo admin/bf-menu`.
otomatik bulur (`--offline` zorunlu değil). Terminal yüzeyi `sudo bf --tui`.
Kurucu yalnız `PROVISIONED_OFFLINE` yazar. `READY`, installer çıkış kodu değil enrollment sonrası merkezi
WireGuard, erişim ve monitoring doğrulamasının sonucudur. →
[`05`](docs/05-ONE-CLICK-INSTALLER.md), [`27`](docs/27-OFFLINE-PROVISIONING.md), [`29`](docs/29-DEVICE-ENROLLMENT.md) (K-21)

View file

@ -1,6 +1,6 @@
# Blueforce operator console (`bf-menu`)
# Blueforce operator console (`bf`)
Numbered terminal console for the Blueforce field fleet. One entry point for the
Python terminal console for the Blueforce field fleet. One entry point for the
operator: no command has to be memorised, every command is printed before it
runs, and the dangerous ones ask twice.
@ -20,7 +20,7 @@ not invent playbooks, and does not talk to devices itself: it selects the right
## 1. Requirements
- Ubuntu LTS field device or admin workstation (the console is plain bash 4+).
- Ubuntu LTS field device or admin workstation (the console is Python 3.8+).
- `git` for the checkout; `ansible-core`/`ansible` **only** on the machine that
drives fleet operations (categories 2 and 3). A field device that only installs
itself needs neither Ansible nor an inventory.
@ -36,7 +36,8 @@ git clone <repository-url> Blueforce-Linux
cd Blueforce-Linux
sudo admin/bf-bootstrap.sh # optional: links the tools into /usr/local/bin
sudo admin/bf-menu # or: sudo bf-menu, once bootstrap ran
sudo bf # after bootstrap
sudo bf --tui # explicit terminal surface
```
An install is `git`-based on purpose: the running revision is traceable with

View file

@ -1,5 +1,5 @@
#!/bin/sh
''''command -v python3 >/dev/null 2>&1 || { printf '%s\n' "HATA: python3 bulunamadı. bfos operatör konsolu için Python 3.8 veya üzeri gerekir." "" "Yedek yol (bash): sudo admin/bf-menu ya da kuruluysa: sudo bf-menu" >&2; exit 3; }; exec python3 "$0" "$@" # '''
''''command -v python3 >/dev/null 2>&1 || { printf '%s\n' "HATA: python3 bulunamadı. bfos operatör konsolu için Python 3.8 veya üzeri gerekir." "" "Python kurulumu için Ubuntu paket yöneticisini kullanın." >&2; exit 3; }; exec python3 "$0" "$@" # '''
"""bfos operator console — thin entry point (sh/Python polyglot).
The file is valid POSIX shell *and* valid Python:
@ -7,8 +7,7 @@ The file is valid POSIX shell *and* valid Python:
* run as ``admin/bf`` (or as the ``bf`` symlink that
``admin/bf-bootstrap.sh`` creates), the shell line above checks that
``python3`` exists and, when it does not, prints a Turkish error and points at
the proven bash console (``admin/bf-menu``) — a device without Python is still
operational,
the Python console is the single operator surface,
* everything after that line is the Python entry point: it puts the repository
root on ``sys.path`` and calls :func:`bfos.cli.main`. Nothing else lives here;
all logic is in the ``bfos`` package next to it.
@ -27,7 +26,7 @@ if sys.version_info < MIN_PYTHON: # pragma: no cover - depends on the interpret
sys.version.split()[0], MIN_PYTHON[0], MIN_PYTHON[1]
)
)
sys.stderr.write("Yedek yol (bash): sudo admin/bf-menu\n")
sys.stderr.write("Python 3.8+ kurulumu gereklidir.\n")
raise SystemExit(3)
@ -51,7 +50,7 @@ if not ROOT:
sys.stderr.write(
"HATA: bfos paketi bulunamadı. Konsolu Blueforce deposunun içinden çalıştırın,\n"
"ya da --repo YOL ile depo yolunu belirtin.\n"
"Yedek yol (bash): sudo admin/bf-menu\n"
"Python konsolunu depo kökünden çalıştırın veya --repo YOL kullanın.\n"
)
raise SystemExit(3)

View file

@ -14,8 +14,7 @@
# fast-forward an existing checkout (never resets, never discards work),
# 5. link admin/bf-*, scripts/diagnostics/bf-* and scripts/maintenance/bf-*
# into /usr/local/bin (idempotent; a re-run refreshes the links),
# 6. make admin/bf-menu executable so `sudo bf-menu` starts the on-device
# installer menu,
# 6. make admin/bf executable so `sudo bf` starts the Python console,
# 7. print a clear summary.
#
# Safety
@ -50,7 +49,7 @@ Usage: sudo bf-bootstrap.sh [--check] [--dir DIR] [--branch BRANCH] [--yes] [--h
Install (or refresh) the Blueforce Linux admin toolbox from git: fetch the
repository, install the prerequisites, link the bf-* tools into
/usr/local/bin, and make `sudo bf-menu` available for the on-device setup.
/usr/local/bin, and make `sudo bf` available for the on-device setup.
Options:
--check Read-only: report what would happen. It never mutates
@ -317,13 +316,9 @@ fi
# --------------------------------------------------------------------------- #
# 6. Operator console entry point.
#
# The Python console (`admin/bf` -> bfos) is the primary path; the bash
# `bf-menu` stays linked as a fallback so a host without python3 can still be
# operated.
# The Python console (`admin/bf` -> bfos) is the only operator path.
# --------------------------------------------------------------------------- #
PY_CONSOLE="$DIR/admin/bf"
MENU_SOURCE="$DIR/admin/bf-menu"
MENU_READY=0
if [[ -f "$PY_CONSOLE" ]]; then
if [[ "$CHECK_MODE" -eq 1 ]]; then
@ -333,28 +328,10 @@ if [[ -f "$PY_CONSOLE" ]]; then
ln -sfn "$PY_CONSOLE" "$BIN_DIR/bf"
say "bf ready: $BIN_DIR/bf (Python console)"
fi
MENU_READY=1
else
warn "admin/bf is not present in $DIR; the Python console arrives with the next revision of the checkout"
fi
if [[ -f "$MENU_SOURCE" ]]; then
if [[ "$CHECK_MODE" -eq 1 ]]; then
say "check: would make $MENU_SOURCE executable and link $BIN_DIR/bf-menu"
else
chmod 0755 "$MENU_SOURCE"
ln -sfn "$MENU_SOURCE" "$BIN_DIR/bf-menu"
say "bf-menu ready: $BIN_DIR/bf-menu (bash fallback)"
fi
MENU_READY=1
else
warn "admin/bf-menu is not present in $DIR; the bash fallback will work once it is in the checkout"
fi
if [[ "$MENU_READY" -eq 0 ]]; then
warn "no operator console was found in $DIR; link one of admin/bf or admin/bf-menu manually"
fi
# --------------------------------------------------------------------------- #
# 7. Summary.
# --------------------------------------------------------------------------- #
@ -383,17 +360,10 @@ if [[ -x "$BIN_DIR/bf" ]]; then
else
printf ' console : pending (admin/bf is not in the checkout yet)\n'
fi
if [[ -x "$BIN_DIR/bf-menu" ]]; then
printf ' fallback : %s (bash)\n' "$BIN_DIR/bf-menu"
else
printf ' fallback : pending (admin/bf-menu is not in the checkout yet)\n'
fi
# The Python console is the recommended entry point; the bash menu stays as the
# fallback for a host without python3.
if [[ -x "$BIN_DIR/bf" ]]; then
printf ' next : sudo bf\n'
else
printf ' next : sudo bf-menu\n'
printf ' next : sudo admin/bf\n'
fi
printf '\nInstallation complete. To get started: sudo bf\n'
printf 'Kurulum tamam. Başlamak için: sudo bf\n'

File diff suppressed because it is too large Load diff

View file

@ -1,476 +0,0 @@
#!/usr/bin/env bash
# admin/lib/targets.sh -- device identity, inventory parsing and target selection.
#
# Sourced by admin/bf-menu. Contains no UI rendering and no execution: it answers
# "which hosts does this operation apply to, and which Ansible --limit does that
# become".
#
# Contracts it enforces (sources of truth in parentheses):
# * dealer number matches ^[0-9]{8}$ exactly (docs/02)
# * machine hostname is bf-<dealer>, human id is BF-<dealer> (docs/02)
# * waves are lab(2) -> pilot_1(5) -> pilot_2(20) -> wave_1(50) -> wave_2(100)
# -> production, in that order (docs/10)
# * the inventory is the Ansible inventory; no device is ever addressed that is
# not in it (docs/09)
#
# Inputs (set by bf-menu before calling):
# BF_REPO repository root
# BF_INVENTORY resolved inventory path, or empty to trigger auto-discovery
#
# shellcheck shell=bash
# Canonical wave order. Kept in one place so the menu, the gate checks and the
# fleet-wide loop can never disagree.
BF_WAVES=(lab pilot_1 pilot_2 wave_1 wave_2 production)
# ---------------------------------------------------------------------------
# Identity helpers
# ---------------------------------------------------------------------------
# BF_DEALER_RE -- the single validation rule for dealer numbers.
BF_DEALER_RE='^[0-9]{8}$'
bf_dealer_validate() {
[[ "$1" =~ $BF_DEALER_RE ]]
}
bf_dealer_to_host() {
printf 'bf-%s\n' "$1"
}
bf_dealer_to_device_id() {
printf 'BF-%s\n' "$1"
}
# bf_host_to_dealer HOST -> dealer number, empty when the name is not bf-<8 digits>.
bf_host_to_dealer() {
local host="$1"
if [[ "$host" =~ ^bf-([0-9]{8})$ ]]; then
printf '%s\n' "${BASH_REMATCH[1]}"
return 0
fi
return 1
}
# bf_is_wave NAME -- true for the six approved wave groups (docs/10).
bf_is_wave() {
local name="$1" wave
for wave in "${BF_WAVES[@]}"; do
[[ "$name" == "$wave" ]] && return 0
done
return 1
}
bf_waves_csv() {
local IFS=,
printf '%s\n' "${BF_WAVES[*]}"
}
# ---------------------------------------------------------------------------
# Inventory discovery and parsing
# ---------------------------------------------------------------------------
# bf_inventory_candidates -- every path we are willing to read, best first.
# hosts.example.yml is listed last on purpose: it is a template with example
# device IDs, so it is only usable after an explicit acknowledgement.
bf_inventory_candidates() {
local dir="${BF_REPO:-.}/ansible/inventory"
if [[ -n "${BF_INVENTORY:-}" ]]; then
printf '%s\n' "$BF_INVENTORY"
return 0
fi
printf '%s\n' \
"$dir/hosts.yml" \
"$dir/hosts.yaml" \
"$dir/hosts" \
"$dir/hosts.ini"
}
# bf_inventory_resolve -- pick the inventory file. Prints the path on success.
# Prints a remediation message to stderr and returns 1 when nothing is usable.
bf_inventory_resolve() {
local candidate example="${BF_REPO:-.}/ansible/inventory/hosts.example.yml"
if [[ -n "${BF_INVENTORY:-}" ]]; then
if [[ -r "$BF_INVENTORY" ]]; then
printf '%s\n' "$BF_INVENTORY"
return 0
fi
tui_error "Inventory not readable: $BF_INVENTORY"
tui_note "Pass a real path with --inventory PATH or unset it to auto-discover." >&2
return 1
fi
while IFS= read -r candidate; do
[[ -n "$candidate" ]] || continue
if [[ -r "$candidate" ]]; then
printf '%s\n' "$candidate"
return 0
fi
done < <(bf_inventory_candidates)
tui_error "No Ansible inventory found under ${BF_REPO:-.}/ansible/inventory/."
# These remediation lines go to stderr on purpose: the resolver's stdout is
# captured as the resolved path by the caller.
if [[ -r "$example" ]]; then
tui_note "Only the example inventory exists: $example" >&2
tui_note "It carries example device IDs (bf-1201xxxx) and must not be used against real devices." >&2
tui_note "Create the real inventory first:" >&2
tui_note " cp $example ${BF_REPO:-.}/ansible/inventory/hosts.yml" >&2
tui_note " \$EDITOR ${BF_REPO:-.}/ansible/inventory/hosts.yml" >&2
else
tui_note "Create ansible/inventory/hosts.yml with your bf-<8-digit dealer> hosts (docs/02, docs/09)." >&2
fi
return 1
}
# bf_inventory_pairs FILE -- "group<TAB>host" for every host in the inventory.
# A deliberately small YAML reader: the inventory is a nested map of
# all.children.<group>.children.<wave>.hosts.<hostname>. It tracks indentation
# instead of pulling in a YAML parser (none is guaranteed on a field console).
bf_inventory_pairs() {
local inv="$1"
[[ -r "$inv" ]] || return 1
awk '
function trim(s) { gsub(/^[[:space:]]+|[[:space:]]+$/, "", s); return s }
function unquote(s) { gsub(/^["\x27]|["\x27]$/, "", s); return s }
{
line = $0
if (line ~ /^[[:space:]]*#/) next
if (line ~ /^[[:space:]]*$/) next
if (line ~ /^[[:space:]]*-/) next
if (line !~ /:/) next
match(line, /^ */); ind = RLENGTH
key = line; sub(/^[[:space:]]*/, "", key); sub(/:.*$/, "", key)
key = unquote(trim(key))
val = line; sub(/^[^:]*:/, "", val); val = trim(val)
if (key == "") next
while (depth > 0 && sind[depth] >= ind) depth--
# A "hosts:" block owns the nearest enclosing group key.
if (key == "hosts" && val == "") {
h_active = 1; h_ind = ind; h_step = 0
h_grp = (depth > 0) ? skey[depth] : ""
skey[++depth] = key; sind[depth] = ind
next
}
# Anything at or above the "hosts:" indentation ends that block.
if (h_active && ind <= h_ind) h_active = 0
if (h_active && ind > h_ind) {
if (h_step == 0) h_step = ind
if (ind == h_step) {
print h_grp "\t" key
skey[++depth] = key; sind[depth] = ind
next
}
}
if (val == "") { skey[++depth] = key; sind[depth] = ind }
}
' "$inv"
}
# Reads the inventory and caches the pairs; repeated calls are cheap and the
# cache is invalidated by BF_INVENTORY_CACHE_FILE going away.
bf_inventory_cache() {
local inv="${BF_INVENTORY:-}"
[[ -n "$inv" ]] || return 1
if [[ -z "${BF_INVENTORY_CACHE_FILE:-}" ]]; then
BF_INVENTORY_CACHE_FILE="$(mktemp "${TMPDIR:-/tmp}/bf-inventory.XXXXXX")"
export BF_INVENTORY_CACHE_FILE
fi
if [[ ! -s "$BF_INVENTORY_CACHE_FILE" ]]; then
bf_inventory_pairs "$inv" >"$BF_INVENTORY_CACHE_FILE" || return 1
fi
return 0
}
bf_inventory_groups() {
bf_inventory_cache || return 1
awk -F '\t' 'NF == 2 && !seen[$1]++ { print $1 }' "$BF_INVENTORY_CACHE_FILE" | grep -v '^$' || true
}
bf_inventory_hosts() {
bf_inventory_cache || return 1
awk -F '\t' 'NF == 2 && !seen[$2]++ { print $2 }' "$BF_INVENTORY_CACHE_FILE" | grep -v '^$' || true
}
bf_inventory_hosts_of_group() {
local group="$1"
bf_inventory_cache || return 1
awk -F '\t' -v g="$group" '$1 == g { print $2 }' "$BF_INVENTORY_CACHE_FILE"
}
# bf_count_lines -- wc -l without the set -e trap of grep -c returning 1 on zero.
bf_count_lines() {
local n
n="$(printf '%s\n' "$1" | grep -c . || true)"
printf '%s\n' "${n:-0}"
}
bf_inventory_group_exists() {
local group="$1"
bf_inventory_cache || return 1
awk -F '\t' -v g="$group" '$1 == g { found = 1; exit } END { exit !found }' "$BF_INVENTORY_CACHE_FILE"
}
bf_inventory_host_exists() {
local host="$1"
bf_inventory_cache || return 1
awk -F '\t' -v h="$host" '$2 == h { found = 1; exit } END { exit !found }' "$BF_INVENTORY_CACHE_FILE"
}
bf_inventory_count_all() {
bf_count_lines "$(bf_inventory_hosts || true)"
}
# bf_inventory_host_wave HOST -- the approved wave that contains HOST.
# Prints nothing and returns 1 when the device sits outside every wave group.
bf_inventory_host_wave() {
local host="$1" wave
bf_inventory_cache || return 1
for wave in "${BF_WAVES[@]}"; do
bf_inventory_group_exists "$wave" || continue
if bf_inventory_hosts_of_group "$wave" | grep -qx -- "$host"; then
printf '%s\n' "$wave"
return 0
fi
done
return 1
}
# bf_wave_groups_present -- canonical waves that actually exist in the inventory,
# always in rollout order.
bf_wave_groups_present() {
local wave
for wave in "${BF_WAVES[@]}"; do
if bf_inventory_group_exists "$wave"; then
printf '%s\n' "$wave"
fi
done
}
# ---------------------------------------------------------------------------
# Target selection
# ---------------------------------------------------------------------------
bf_target_reset() {
BF_TARGET_KIND=""
BF_TARGET_LIMIT=""
BF_TARGET_WAVE=""
BF_TARGET_LABEL=""
BF_TARGET_COUNT=0
}
bf_target_is_set() {
[[ -n "${BF_TARGET_KIND:-}" && -n "${BF_TARGET_LIMIT:-}" ]]
}
# bf_target_set_host DEALER [MODE]
# MODE: any|wave|release. Returns 1 (after explaining) when the mode forbids a
# single-device selection or the device is unknown to the inventory.
bf_target_set_host() {
local dealer="$1" mode="${2:-any}" host wave
if ! bf_dealer_validate "$dealer"; then
tui_error "Invalid dealer number '$dealer': it must match ${BF_DEALER_RE} (8 digits, no spaces)."
return 1
fi
if [[ "$mode" == "release" ]]; then
tui_error "Release-gated operations are wave-scoped (docs/10): select a wave group, not a single device."
return 1
fi
host="$(bf_dealer_to_host "$dealer")"
if ! bf_inventory_host_exists "$host"; then
tui_error "$host is not in the inventory (${BF_INVENTORY:-unresolved})."
tui_note "Add the device to its wave group first, then retry (see admin/README.md, docs/02)."
return 1
fi
wave="$(bf_inventory_host_wave "$host" || true)"
if [[ -z "$wave" && "$mode" != "any" ]]; then
tui_error "$host is not inside an approved wave group ($(bf_waves_csv))."
tui_note "Wave-scoped playbooks refuse a device that no wave covers (fail-closed)."
return 1
fi
BF_TARGET_KIND="host"
BF_TARGET_LIMIT="$host"
BF_TARGET_WAVE="$wave"
BF_TARGET_COUNT=1
if [[ -n "$wave" ]]; then
BF_TARGET_LABEL="device $(bf_dealer_to_device_id "$dealer") ($host) in wave '$wave'"
else
BF_TARGET_LABEL="device $(bf_dealer_to_device_id "$dealer") ($host)"
fi
return 0
}
# bf_target_set_group GROUP [MODE]
bf_target_set_group() {
local group="$1" mode="${2:-any}" count
if [[ "$group" == "all" ]]; then
bf_target_set_all "$mode"
return $?
fi
if ! bf_inventory_group_exists "$group"; then
tui_error "Group '$group' is not in the inventory (${BF_INVENTORY:-unresolved})."
tui_note "Known groups: $(bf_inventory_groups | tr '\n' ' ')"
return 1
fi
if [[ "$mode" == "wave" || "$mode" == "release" ]] && ! bf_is_wave "$group"; then
tui_error "Group '$group' is not an approved wave; wave-scoped operations need one of: $(bf_waves_csv)."
return 1
fi
count="$(bf_count_lines "$(bf_inventory_hosts_of_group "$group" || true)")"
if [[ "$count" -eq 0 ]]; then
tui_error "Group '$group' contains no hosts; refusing an empty target."
return 1
fi
BF_TARGET_KIND="group"
BF_TARGET_LIMIT="$group"
BF_TARGET_WAVE="$group"
# shellcheck disable=SC2034 # read by bf-menu (library contract)
BF_TARGET_COUNT="$count"
if bf_is_wave "$group"; then
BF_TARGET_LABEL="wave '$group' ($count device(s))"
else
BF_TARGET_LABEL="group '$group' ($count device(s))"
fi
return 0
}
# bf_target_set_all MODE -- the whole fleet.
# For release-gated operations this is refused: the update/reboot playbooks are
# gated wave by wave and a fleet-wide limit would defeat the gate (docs/10).
bf_target_set_all() {
local mode="${1:-any}" count
if [[ "$mode" == "release" ]]; then
tui_error "Fleet-wide scope is refused for release-gated operations: roll out one wave at a time (docs/10)."
return 1
fi
count="$(bf_inventory_count_all)"
if [[ "$count" -eq 0 ]]; then
tui_error "The inventory lists no hosts; refusing an empty target."
return 1
fi
BF_TARGET_KIND="all"
BF_TARGET_LIMIT="all"
BF_TARGET_WAVE=""
# shellcheck disable=SC2034 # read by bf-menu (library contract)
BF_TARGET_COUNT="$count"
BF_TARGET_LABEL="all devices in the fleet ($count device(s))"
return 0
}
# bf_target_parse WORD MODE -- accept what the operator typed in one prompt:
# an 8-digit dealer number, a group/wave name, or "all".
bf_target_parse() {
local word="$1" mode="${2:-any}"
bf_target_reset
if [[ -z "$word" ]]; then
tui_error "No target given."
return 1
fi
if [[ "$word" == "all" ]]; then
bf_target_set_all "$mode"
return $?
fi
if bf_dealer_validate "$word"; then
bf_target_set_host "$word" "$mode"
return $?
fi
if bf_inventory_group_exists "$word"; then
bf_target_set_group "$word" "$mode"
return $?
fi
# A bf-<dealer> hostname is accepted as a convenience.
if [[ "$word" =~ ^bf-[0-9]{8}$ ]]; then
if bf_dealer_validate "${word#bf-}"; then
bf_target_set_host "${word#bf-}" "$mode"
return $?
fi
fi
tui_error "'$word' is not a dealer number (${BF_DEALER_RE}), a group, or 'all'."
tui_note "Known groups: $(bf_inventory_groups | tr '\n' ' ')"
return 1
}
# bf_target_describe -- one line for the confirmation screen.
bf_target_describe() {
printf '%s --limit %s%s\n' "${BF_TARGET_LABEL:-unset}" "${BF_TARGET_LIMIT:-unset}" \
"$([[ -n "${BF_TARGET_WAVE:-}" ]] && printf ' (wave %s)' "$BF_TARGET_WAVE")"
}
# bf_target_select MODE
# Interactive target picker. MODE:
# any single device, any group, or the whole fleet
# wave single device, approved wave group, or the whole fleet wave by wave
# release approved wave group only
# On success the BF_TARGET_* globals describe the selection.
bf_target_select() {
local mode="${1:-any}" choice word count index group_list=()
bf_target_reset
while true; do
tui_section "Target selection (mode: $mode)"
tui_item 1 "Single device by dealer number" "8 digits, e.g. 12010101"
tui_item 2 "Group / wave" "lab, pilot_1, pilot_2, wave_1, wave_2, production"
if [[ "$mode" == "release" ]]; then
tui_note " all) not offered: release-gated operations run one wave at a time"
else
tui_item 3 "All devices in the fleet" "wave-scoped playbooks ask per wave"
fi
tui_item 9 "Type the target directly" "dealer number, group or 'all'"
tui_footer "Back to the operation"
printf '\n'
if ! tui_read "Target > "; then return 1; fi
choice="$TUI_REPLY"
case "$choice" in
""|0|q|Q) return 1 ;;
1)
tui_read "Dealer number (8 digits) > " || return 1
word="$TUI_REPLY"
bf_target_set_host "$word" "$mode" && return 0
;;
2)
tui_section "Groups in the inventory"
group_list=()
while IFS= read -r group; do
[[ -n "$group" ]] || continue
group_list+=("$group")
done < <(if [[ "$mode" == "any" ]]; then bf_inventory_groups || true; else bf_wave_groups_present || true; fi)
if [[ ${#group_list[@]} -eq 0 ]]; then
tui_error "No usable group in the inventory (${BF_INVENTORY:-unresolved})."
return 1
fi
index=0
for group in "${group_list[@]}"; do
index=$((index + 1))
count="$(bf_count_lines "$(bf_inventory_hosts_of_group "$group" || true)")"
if bf_is_wave "$group"; then
tui_item "$index" "$group" "$count device(s), approved wave"
else
tui_item "$index" "$group" "$count device(s)"
fi
done
printf '\n'
tui_note "Approved waves: $(bf_waves_csv) -- source: $(basename "${BF_INVENTORY:-unresolved}")"
if ! tui_read "Group number > "; then return 1; fi
choice="$TUI_REPLY"
if ! tui_menu_index "$choice" "$index"; then
tui_error "Enter a number between 1 and $index."
continue
fi
bf_target_set_group "${group_list[$((choice - 1))]}" "$mode" && return 0
;;
3)
if [[ "$mode" == "release" ]]; then
tui_error "Fleet-wide scope is refused here."
continue
fi
bf_target_set_all "$mode" && return 0
;;
9)
tui_read "Target (dealer number, group or all) > " || return 1
bf_target_parse "$TUI_REPLY" "$mode" && return 0
;;
*) tui_error "Unknown choice '$choice'." ;;
esac
printf '\n'
tui_pause
done
}

View file

@ -1,216 +0,0 @@
#!/usr/bin/env bash
# admin/lib/tui.sh -- terminal UI helpers for the Blueforce operator console.
#
# Sourced by admin/bf-menu. This file contains presentation and input helpers
# only: no fleet logic, no inventory parsing, and no side effects when sourced.
#
# Contract:
# * user-facing output goes to stdout, diagnostics/warnings to stderr,
# * no helper calls exit except tui_die (and it only does so when called),
# * no helper ever prints a secret, and nothing here writes to the log,
# * every helper tolerates `set -u`: optional arguments use ${x:-} defaults.
#
# shellcheck shell=bash
# ---------------------------------------------------------------------------
# Colors. Disabled automatically when stdout is not a terminal, when NO_COLOR
# is set, or when the caller asks for "never".
# ---------------------------------------------------------------------------
TUI_COLOR_MODE="${TUI_COLOR_MODE:-auto}"
# shellcheck disable=SC2034 # color variables are consumed by bf-menu
tui_init_colors() {
local want="${1:-$TUI_COLOR_MODE}"
local enable=0
case "$want" in
always) enable=1 ;;
never) enable=0 ;;
*)
if [[ -t 1 && -z "${NO_COLOR:-}" ]]; then
enable=1
fi
;;
esac
if [[ "$enable" -eq 1 ]]; then
C_RESET=$'\033[0m'
C_BOLD=$'\033[1m'
C_DIM=$'\033[2m'
C_RED=$'\033[31m'
C_GREEN=$'\033[32m'
C_YELLOW=$'\033[33m'
C_BLUE=$'\033[34m'
C_MAGENTA=$'\033[35m'
C_CYAN=$'\033[36m'
else
C_RESET="" C_BOLD="" C_DIM="" C_RED="" C_GREEN="" C_YELLOW=""
C_BLUE="" C_MAGENTA="" C_CYAN=""
fi
export C_RESET C_BOLD C_DIM C_RED C_GREEN C_YELLOW C_BLUE C_MAGENTA C_CYAN
}
# ---------------------------------------------------------------------------
# Layout
# ---------------------------------------------------------------------------
TUI_WIDTH="${TUI_WIDTH:-78}"
tui_hr() {
local char="${1:--}"
local line
line="$(printf "%*s" "$TUI_WIDTH" "" | tr ' ' "$char")"
printf '%s%s%s\n' "$C_DIM" "$line" "$C_RESET"
}
# tui_banner TITLE [SUBTITLE]
tui_banner() {
local title="$1" subtitle="${2:-}"
printf '\n'
tui_hr '='
printf ' %s%s%s\n' "$C_BOLD$C_CYAN" "$title" "$C_RESET"
if [[ -n "$subtitle" ]]; then
printf ' %s%s%s\n' "$C_DIM" "$subtitle" "$C_RESET"
fi
tui_hr '='
printf '\n'
}
# tui_screen TITLE [SUBTITLE]
# Clears the terminal first when interactive so one screen fits one page; the
# clear is skipped for non-interactive runs (pipes, tests, logs).
tui_screen() {
if [[ -t 1 && "${TUI_CLEAR:-yes}" == "yes" ]]; then
printf '\033[H\033[2J'
fi
tui_banner "${1:-}" "${2:-}"
}
# tui_item NUMBER LABEL [DESCRIPTION]
tui_item() {
local number="$1" label="$2" desc="${3:-}"
if [[ -n "$desc" ]]; then
printf ' %s%2s%s) %-46s %s%s%s\n' "$C_BOLD" "$number" "$C_RESET" "$label" "$C_DIM" "$desc" "$C_RESET"
else
printf ' %s%2s%s) %s\n' "$C_BOLD" "$number" "$C_RESET" "$label"
fi
}
# tui_section LABEL -- visual separator inside a long menu.
tui_section() {
printf '\n %s%s%s\n' "$C_MAGENTA" "${1:-}" "$C_RESET"
}
# tui_footer [BACK_LABEL] -- every screen ends with back and quit.
tui_footer() {
local back="${1:-Back}"
printf '\n'
tui_hr '-'
printf ' %s%2s%s) %-46s %s%2s%s) %s\n' \
"$C_BOLD" "0" "$C_RESET" "$back" "$C_BOLD" "q" "$C_RESET" "Quit the console"
tui_hr '-'
}
# ---------------------------------------------------------------------------
# Input
# ---------------------------------------------------------------------------
# tui_read PROMPT -> TUI_REPLY
# Returns 0 on a line, 1 on EOF (stdin closed / non-interactive); on EOF the
# reply is "q" so the caller's menu loop quits instead of spinning forever.
tui_read() {
local prompt="${1:-} "
TUI_REPLY=""
printf '%s' "$prompt"
if ! IFS= read -r TUI_REPLY; then
TUI_REPLY="q"
printf '\n'
return 1
fi
# Trim surrounding whitespace so " 2 " is accepted like "2".
TUI_REPLY="${TUI_REPLY#"${TUI_REPLY%%[![:space:]]*}"}"
TUI_REPLY="${TUI_REPLY%"${TUI_REPLY##*[![:space:]]}"}"
return 0
}
# tui_prompt LABEL [DEFAULT] -> TUI_REPLY
tui_prompt() {
local label="$1" default="${2:-}" suffix=""
if [[ -n "$default" ]]; then
suffix=" [$default]"
fi
tui_read "${label}${suffix}: " || true
if [[ -z "$TUI_REPLY" ]]; then
TUI_REPLY="$default"
fi
}
# tui_confirm QUESTION [yes|no] -- default is NO: only an explicit y/yes proceeds.
tui_confirm() {
local question="$1" default="${2:-no}" suffix="[y/N]"
[[ "$default" == "yes" ]] && suffix="[Y/n]"
tui_read "${question} ${suffix} " || true
case "${TUI_REPLY,,}" in
y|yes) return 0 ;;
n|no) return 1 ;;
"")
[[ "$default" == "yes" ]] && return 0
return 1
;;
*) return 1 ;;
esac
}
# tui_confirm_token QUESTION TOKEN -- the operator must type the exact token.
# Used for the highest-risk operations (production waves, fleet-wide runs).
tui_confirm_token() {
local question="$1" token="$2"
tui_warn "$question"
tui_read "$(printf 'Type %s%s%s to confirm (anything else cancels): ' "$C_BOLD" "$token" "$C_RESET")" || true
[[ "$TUI_REPLY" == "$token" ]]
}
# tui_ask_run QUESTION -> 0 = run, 2 = run with --check, 1 = cancel
tui_ask_run() {
local question="$1"
tui_read "${question} [y=run / c=run with --check / N=cancel] " || true
case "${TUI_REPLY,,}" in
y|yes) return 0 ;;
c|check) return 2 ;;
*) return 1 ;;
esac
}
# tui_menu_index NUMBER MAX -- true when NUMBER selects an item of the menu.
tui_menu_index() {
local number="$1" max="$2"
[[ "$number" =~ ^[0-9]+$ ]] || return 1
((number >= 1 && number <= max)) || return 1
return 0
}
tui_pause() {
local message="${1:-Press Enter to continue}"
if [[ -t 0 ]]; then
tui_read "${C_DIM}${message}${C_RESET}" || true
fi
}
# ---------------------------------------------------------------------------
# Messages
# ---------------------------------------------------------------------------
tui_msg() { printf '%s\n' "$*"; }
tui_info() { printf '%s[i]%s %s\n' "$C_CYAN" "$C_RESET" "$*"; }
tui_ok() { printf '%s[OK]%s %s\n' "$C_GREEN" "$C_RESET" "$*"; }
tui_warn() { printf '%s[WARN]%s %s\n' "$C_YELLOW" "$C_RESET" "$*" >&2; }
tui_error(){ printf '%s[ERROR]%s %s\n' "$C_RED" "$C_RESET" "$*" >&2; }
tui_note() { printf '%s%s%s\n' "$C_DIM" "$*" "$C_RESET"; }
# tui_kv KEY VALUE -- aligned key/value line for information screens.
tui_kv() {
printf ' %-24s %s\n' "$1" "$2"
}
# tui_die MESSAGE [EXIT_CODE] -- last resort: report and exit with a non-zero
# status. Everything user-recoverable should return a status instead.
tui_die() {
local message="$1" code="${2:-1}"
tui_error "$message"
exit "$code"
}

View file

@ -54,7 +54,10 @@
remote_wg_interface: "{{ wg_interface | default('wg0') }}"
remote_wg_handshake_max_age: "{{ wg_handshake_max_age_seconds | default(180) }}"
remote_rustdesk_system_config: /etc/rustdesk/RustDesk2.toml
remote_rustdesk_user_config: /home/blueforce/.config/RustDesk/RustDesk2.toml
remote_dealer_id_file: /etc/blueforce/dealer-id
remote_dealer_id: "{{ lookup('file', remote_dealer_id_file) | trim }}"
remote_bf_user: "bf-{{ remote_dealer_id }}"
remote_rustdesk_user_config: "/home/{{ remote_bf_user }}/.config/RustDesk/RustDesk2.toml"
remote_mesh_config: /etc/meshagent/meshagent.json
remote_status_bin: /usr/local/bin/bf-remote-status
pre_tasks:

View file

@ -64,6 +64,10 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("-V", "--version", action="store_true", help="sürümü göster ve çık")
parser.add_argument("--gui", action="store_true",
help="grafik arayüzü aç (varsayılan davranış)")
parser.add_argument("--install", action="store_true",
help="yalnızca bağımsız yerel kurulum yüzeyini aç")
parser.add_argument("--portal", action="store_true",
help="yalnızca bağımsız uzaktan yönetim yüzeyini aç")
parser.add_argument("--tui", action="store_true",
help="terminal konsolunu aç (grafik arayüz açılamıyorsa yedek)")
parser.add_argument("--full", action="store_true",
@ -1037,8 +1041,11 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
if args.tui and args.gui:
sys.stderr.write("HATA: --tui ve --gui birlikte kullanılamaz.\n")
if args.tui and (args.gui or args.install or args.portal):
sys.stderr.write("HATA: --tui, --gui, --install ve --portal birlikte kullanılamaz.\n")
return config.EXIT_USAGE
if args.install and args.portal:
sys.stderr.write("HATA: --install ve --portal birlikte kullanılamaz.\n")
return config.EXIT_USAGE
if args.version:
@ -1057,6 +1064,10 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
gui_argv: List[str] = []
if args.full:
gui_argv.append("--full")
if args.install:
gui_argv.append("--install")
if args.portal:
gui_argv.append("--portal")
if args.dry_run:
gui_argv.append("--dry-run")
if args.repo:

View file

@ -72,7 +72,7 @@ DISK_WARN_MAX = 100
DEFAULT_REBOOT_GRACE = 300
DEFAULT_LOG_LINES = 500
# Non-secret identity/lifecycle keys of state.json (whitelist, mirrors bf-menu).
# Non-secret identity/lifecycle keys of state.json.
STATE_ALLOWED_KEYS = ("phase", "enrollment_status", "fleet_status", "device_id", "provisioning_id")
# Central endpoint keys read from group_vars/all.yml for the preparation screens.
@ -184,11 +184,11 @@ MSG_NOT_A_TTY = "Bu ekran etkileşimli bir uçbirim gerektiriyor; çıkılıyor.
# Interpreter / environment
MSG_PYTHON_MISSING = (
"HATA: python3 bulunamadı. bfos operatör konsolu için Python 3.8 veya üzeri gerekir.\n"
"Yedek yol (bash): sudo admin/bf-menu ya da kuruluysa: sudo bf-menu"
"Python konsolu için sudo bf kullanın."
)
MSG_PYTHON_OLD = (
"HATA: Python sürümü çok eski: {version}. bfos için Python 3.8+ gerekir.\n"
"Yedek yol (bash): sudo admin/bf-menu"
"Python terminal konsolu için sudo bf --tui kullanın."
)
MSG_LANG_UNSUPPORTED = "Bu konsol yalnızca Türkçe çalışır; '{value}' desteklenmiyor. Kullanılabilir: {supported}."
MSG_REPO_INVALID = (

44
bfos/identity.py Normal file
View file

@ -0,0 +1,44 @@
"""Canonical Blueforce device identity."""
from __future__ import annotations
import os
import re
from pathlib import Path
DEALER_ID_PATTERN = re.compile(r"^[0-9]{8}$")
DEALER_ID_FILE = Path("/etc/blueforce/dealer-id")
def validate_dealer_id(value: str) -> bool:
return bool(DEALER_ID_PATTERN.fullmatch(str(value).strip()))
def dealer_id_file(path: str | os.PathLike[str] | None = None) -> Path:
return Path(path) if path is not None else Path(
os.environ.get("BF_DEALER_ID_FILE", str(DEALER_ID_FILE))
)
def read_dealer_id(path: str | os.PathLike[str] | None = None) -> str:
value = dealer_id_file(path).read_text(encoding="utf-8").strip()
if not validate_dealer_id(value):
raise ValueError("dealer-id must contain exactly 8 digits")
return value
def identity_for(dealer_id: str) -> dict[str, str]:
dealer_id = str(dealer_id).strip()
if not validate_dealer_id(dealer_id):
raise ValueError("dealer-id must contain exactly 8 digits")
return {
"dealer_id": dealer_id,
"username": f"bf-{dealer_id}",
"hostname": f"bf-{dealer_id}",
"device_id": f"BF-{dealer_id}",
"active_user": f"bf-{dealer_id}",
}
def current_identity(path: str | os.PathLike[str] | None = None) -> dict[str, str]:
return identity_for(read_dealer_id(path))

View file

@ -1,7 +1,7 @@
"""Operation model for the bfos operator console.
This module is the behaviour contract of the console, ported from the proven
bash console (``admin/bf-menu`` + ``admin/lib/targets.sh``) so the two entry
Python GUI and TUI surfaces so all entry
points can never disagree:
* the 21 Ansible playbooks of ``ansible/playbooks/`` with their declared
@ -189,7 +189,7 @@ ALL_OPERATIONS: Tuple[Operation, ...] = REPORT_OPERATIONS + MAINTENANCE_OPERATIO
#: Variables the playbooks use to express their own gates. The console derives
#: the scope from the playbook file and adds a variable only when the playbook
#: actually declares it (a permanent parity rule with admin/bf-menu).
#: actually declares it (a permanent parity rule with the Python catalogue).
VAR_OPERATION_WAVE = "operation_wave"
VAR_RELEASE_WAVE = "release_wave"
VAR_OPERATION_CONFIRMED = "operation_confirmed"
@ -230,7 +230,7 @@ def catalogue_rows() -> Tuple[Operation, ...]:
def non_comment_text(text: str) -> str:
"""The playbook text without full-line comments.
Deliberately identical to ``grep -vE '^[[:space:]]*#'`` in bf-menu: a
Deliberately small and deterministic parser: a
trailing comment is kept, so the console and the bash console can never
disagree about what a playbook declares.
"""

View file

@ -1,26 +1,30 @@
"""Small, local GUI preferences for the active Blueforce identity."""
"""Compatibility adapter for the canonical system identity file."""
from __future__ import annotations
import json
import os
import tempfile
from pathlib import Path
from typing import Any
from bfos.operations import dealer_is_valid
from bfos.identity import DEALER_ID_FILE, identity_for, read_dealer_id
PREFERENCES_FILENAME = "blueforce-gui.json"
def preferences_path() -> Path:
base = os.environ.get("XDG_CONFIG_HOME")
if base:
return Path(base) / "blueforce" / PREFERENCES_FILENAME
return Path.home() / ".config" / "blueforce" / PREFERENCES_FILENAME
return Path(os.environ.get("BF_DEALER_ID_FILE", str(DEALER_ID_FILE)))
def load_preferences(path: Path | None = None) -> dict[str, str]:
target = path or preferences_path()
if path is None:
try:
return identity_for(read_dealer_id(target))
except (OSError, ValueError):
return {}
try:
data: Any = json.loads(target.read_text(encoding="utf-8"))
except (OSError, ValueError):
@ -30,19 +34,38 @@ def load_preferences(path: Path | None = None) -> dict[str, str]:
dealer = str(data.get("dealer_id", "")).strip()
if not dealer_is_valid(dealer):
return {}
return {"dealer_id": dealer, "active_user": f"bf-{dealer}"}
return identity_for(dealer)
def save_dealer(dealer_id: str, path: Path | None = None) -> dict[str, str]:
if not dealer_is_valid(dealer_id):
raise ValueError("Bayi numarası tam 8 rakam olmalıdır.")
target = path or preferences_path()
identity = identity_for(dealer_id)
if path is None and target.exists():
existing = read_dealer_id(target)
if existing != dealer_id:
raise PermissionError(
f"Bayi kimliği {existing} olarak kilitli; değiştirmek için açık geçiş gerekir."
)
if path is None:
target.parent.mkdir(parents=True, exist_ok=True)
fd, temporary_name = tempfile.mkstemp(prefix="dealer-id.", dir=str(target.parent))
try:
os.fchmod(fd, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(dealer_id + "\n")
os.replace(temporary_name, target)
finally:
if os.path.exists(temporary_name):
os.unlink(temporary_name)
return identity
target.parent.mkdir(parents=True, exist_ok=True)
data = {"dealer_id": dealer_id, "active_user": f"bf-{dealer_id}"}
temporary = target.with_suffix(target.suffix + ".tmp")
temporary.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
os.replace(temporary, target)
return data
return identity
def clear_dealer(path: Path | None = None) -> None:

View file

@ -7,7 +7,7 @@ Separation of duties:
executes it with :mod:`subprocess`, reports the exit code, and writes the
audit record.
Guarantees kept from the bash console (``admin/bf-menu``):
Guarantees shared by the Python GUI and TUI:
* the command is always printed **before** it is confirmed and run,
* ``--dry-run`` executes nothing at all,

View file

@ -2,7 +2,7 @@
Ne yapar
--------
`admin/bf-menu` terminal konsolunun yaptigi isi ayni is mantigi uzerinden bir
Python TUI konsolunun yaptigi isi ayni is mantigi uzerinden bir
gercek pencereye tasir:
* basit mod : uc buyuk buton -- Kur / Durum / Cikis (+ "Detayli mod"),
@ -89,7 +89,7 @@ dogrudan onlari kullanir; hicbir kural kopyalanmaz:
salt-okunur ekranlar: targets_lines / central_*_lines / identity_lines /
state_lines / environment_lines
Gercek moduller yoksa dosya kendi YERLESIK KATALOG'unu kullanir: bu katalog
admin/bf-menu'nun tablosuyla (id, playbook, kapsam, yikici, ek degisken
kanonik işlem kataloğuyla (id, playbook, kapsam, yıkıcı, ek değişken
kurallari) birebir ayni kurallara ve ayni komut uretimine dayanir.
======================================================================
@ -191,7 +191,7 @@ SCOPE_LABELS: Mapping[str, str] = {
"release": "filo — sürüm kapılı (onaylı dalga)",
}
# admin/lib/targets.sh + admin/bf-menu ile ayni kurallar.
# ortak kimlik ve hedef kurallarıyla ayni kurallar.
SERVICE_ALLOWLIST: tuple[str, ...] = (
"blueforce-agent",
"docker",
@ -228,12 +228,10 @@ CHECK_CAPABLE_LOCAL_OPS: tuple[str, ...] = (
DEFAULT_CONSOLE_LOG = "/var/log/blueforce-console.log"
INSTALLER_REL = "scripts/install/blueforce-install.sh"
PLAYBOOK_DIR_REL = "ansible/playbooks"
MENU_REL = "admin/bf-menu"
TK_PACKAGE_HINT = "sudo apt install -y python3-tk"
# Grafik arayüz açılamadığında kullanılacak yol: aynı bfos uygulamasının
# terminal konsolu. Bash menü (bf-menu) hâlâ yedek olarak durur ama Python
# konsolu ana yoldur, o yüzden önce o önerilir.
# terminal konsolu. GUI ve TUI aynı Python backend'ini kullanır.
TERMINAL_MENU_HINT = "sudo bf --tui"
# ---------------------------------------------------------------------------
@ -406,7 +404,7 @@ def operation_is_write(operation) -> bool:
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class OptionSpec:
"""Bir islemin ek parametresi (bf-menu'deki -e degiskenleri)."""
"""Bir işlemin ek parametresi (Ansible -e değişkenleri)."""
name: str
label: str
@ -518,7 +516,7 @@ class CommandPlan:
@property
def lines(self) -> tuple[str, ...]:
"""Kopyala/yapistir edilebilir komut satirlari (bf-menu %q ile ayni fikir)."""
"""Kopyala/yapıştır edilebilir komut satırları."""
return tuple(shlex.join(argv) for argv in self.commands if argv)
@property
@ -798,8 +796,7 @@ def operation_is_destructive(operation_id: str) -> bool:
# ---------------------------------------------------------------------------
# Yerlesik katalog -- admin/bf-menu tablosuyla ayni id/kapsam/yikici degerleri
# (kaynak: admin/bf-menu BF_OPS_REPORT / BF_OPS_MAINTENANCE)
# Yerleşik katalog -- ortak Python katalog kuralları.
# ---------------------------------------------------------------------------
_FLEET_OPTIONS: Mapping[str, tuple[OptionSpec, ...]] = {
"disk": (
@ -1061,7 +1058,7 @@ def operations_by_category(category: str) -> tuple[Operation, ...]:
# ---------------------------------------------------------------------------
# Yardimci: yol cozumleme (admin/bf-menu ile ayni kurallar)
# Yardımcı: yol çözümleme.
# ---------------------------------------------------------------------------
def repo_root_from_file() -> Path:
"""bfos/ui_gui.py -> depo koku (bir ust dizin)."""
@ -1081,7 +1078,7 @@ def resolve_inventory(repo_root: Path, explicit: str | None = None) -> Path | No
def resolve_tool(name: str, repo_root: Path | None = None) -> str | None:
"""admin/bf-menu bf_tool_path ile ayni sira: /usr/local/bin, sonra depo."""
"""Araç yolu sırası: /usr/local/bin, sonra depo."""
system = Path("/usr/local/bin") / name
if system.is_file() and os.access(system, os.X_OK):
return str(system)
@ -1661,15 +1658,6 @@ class CatalogueBackend:
)
return str(installer)
def _menu_arg(self) -> str:
menu = self.ctx.repo_root / MENU_REL
if not menu.is_file():
raise CommandError(
f"Konsol betiği bulunamadı: {MENU_REL}\n"
"Depo kopyasını doğru verin (--repo PATH)."
)
return str(menu)
def _tool_argv(self, tool: str, *args: str, optional: bool = False) -> tuple[str, ...] | None:
path = resolve_tool(tool, self.ctx.repo_root)
if path is None:
@ -2532,7 +2520,7 @@ def load_backend(ctx: GuiContext):
return (
CatalogueBackend(ctx),
f"bfos.operations bulunamadı ({type(exc).__name__}); yerleşik katalog "
"kullanılıyor (admin/bf-menu kuralları).",
"kullanılıyor (ortak Python kuralları).",
)
runner_module = None
try:
@ -2686,7 +2674,7 @@ class OutputPane:
# ---------------------------------------------------------------------------
# Arayuz
# ---------------------------------------------------------------------------
class BfGui: # pragma: no cover - pencere gerektirir (statik testler dokunmaz)
class _LegacyBfGui: # pragma: no cover - compatibility-only, not an entry point
"""Blueforce Field OS Turkce masaustu arayuzu.
Yerlesim docs/DESIGN.md "Direction contract" yonergesine gore kurulur:

View file

@ -7,7 +7,9 @@ state crosses the boundary.
from __future__ import annotations
import os
import queue
import subprocess
import threading
import tkinter as tk
from tkinter import messagebox, ttk
@ -124,9 +126,15 @@ class InstallerSurface(SurfaceFrame):
self.preferences = load_preferences()
self.dealer_var.set(self.preferences.get("dealer_id", ""))
self.dealer_locked = bool(self.preferences.get("dealer_id"))
self.has_root = getattr(os, "geteuid", lambda: 0)() == 0
self.identity_var = tk.StringVar(value=self._identity_text())
self.dealer_var.trace_add("write", lambda *_: self._on_dealer_changed())
self.status_var = tk.StringVar(value="Hazır — bayi numarası bekleniyor")
initial_status = (
"Hazır — bayi numarası bekleniyor"
if self.has_root
else "Yönetici yetkisi yok — kurulum için uygulamayı 'sudo bf' ile açın"
)
self.status_var = tk.StringVar(value=initial_status)
self.output_queue: queue.Queue[str] = queue.Queue()
self._render()
self._drain_output()
@ -164,8 +172,12 @@ class InstallerSurface(SurfaceFrame):
state="normal" if self.dealer_locked else "disabled")
self.change_dealer_button.grid(row=2, column=1, sticky="w", padx=theme.SP_3,
pady=(theme.SP_4, theme.SP_2))
ttk.Button(main, text="Ön kontrolü çalıştır", style=self.styles["accent_button"],
command=self._run_install).grid(row=3, column=0, sticky="w", padx=theme.SP_5, pady=theme.SP_4)
self.install_button = ttk.Button(
main, text="Kurulumu başlat", style=self.styles["accent_button"],
command=self._run_install,
state="normal" if self.has_root else "disabled",
)
self.install_button.grid(row=3, column=0, sticky="w", padx=theme.SP_5, pady=theme.SP_4)
ttk.Label(main, textvariable=self.status_var, style=self.styles["panel_muted"]).grid(
row=4, column=0, sticky="w", padx=theme.SP_5, pady=(0, theme.SP_4))
@ -180,14 +192,41 @@ class InstallerSurface(SurfaceFrame):
command=self._back).pack(anchor="w", padx=theme.SP_6, pady=(0, theme.SP_5))
def _run_install(self) -> None:
if not self.has_root:
self.status_var.set("Kurulum engellendi — yönetici yetkisi için: sudo bf")
self._append(
"[ENGELLENDİ] Kurulum yönetici yetkisi ister.\n"
"Bu uygulamayı terminalden 'sudo bf' ile yeniden başlatın.\n"
)
return
dealer = self.dealer_var.get().strip()
problem = dealer_error(dealer)
if problem:
self.status_var.set(problem)
return
if self.preferences.get("dealer_id") and self.preferences["dealer_id"] != dealer:
if not messagebox.askyesno(
"Kimlik geçişini onaylayın",
"Mevcut Linux hesabı, SSH anahtarı, RustDesk ve sudo sahiplikleri yeni kimliğe taşınacak. Devam edilsin mi?",
default="no",
):
self.status_var.set("Kimlik geçişi iptal edildi; mevcut kimlik korundu.")
return
script = self.ctx.repo_root / "scripts" / "identity" / "bf-reidentity"
if not script.is_file():
self.status_var.set("Kimlik geçiş komutu bulunamadı.")
return
self.status_var.set("Kimlik geçişi çalışıyor…")
result = subprocess.run([str(script), dealer], capture_output=True, text=True, check=False)
self._append(result.stdout + result.stderr)
if result.returncode != 0:
self.status_var.set("Kimlik geçişi başarısız; eski kimlik korundu.")
return
saved = load_preferences()
else:
try:
saved = save_dealer(dealer)
except OSError as exc:
except (OSError, PermissionError, ValueError) as exc:
self.status_var.set(f"Ayar kaydedilemedi: {exc}")
return
self.ctx.active_dealer = saved["dealer_id"]

View file

@ -13,7 +13,8 @@ KbdInteractiveAuthentication no
PubkeyAuthentication yes
# Only the maintenance user may open SSH sessions.
AllowUsers blueforce
# Identity-specific SSH policy is generated by installer module 07-ssh as
# /etc/ssh/sshd_config.d/99-bf-<dealer-id>.conf.
# Reduce idle-session and brute-force exposure.
X11Forwarding no

View file

@ -1,6 +0,0 @@
node_modules/
build/
.docusaurus/
# Generated content (recreated by sync-docs.sh on prestart/prebuild).
docs/*.md
!docs/.gitkeep

View file

View file

@ -1,19 +0,0 @@
# Blueforce docs site (Docusaurus)
Static documentation is synced one-way from `../docs/*.md` into `./docs/`; never edit generated `./docs/` directly.
## Local development
```bash
cd docs-site
npm install
npm start # http://127.0.0.1:3000 (loopback only)
npm run build
npm run serve # local static preview on 127.0.0.1:3000
```
For an explicitly approved intranet preview, use `npm run start:intranet` or `npm run serve:intranet`; those commands bind `0.0.0.0:3000`.
## Deploy
Publish `build/` through the intranet web server. The site has no favicon reference until a maintained favicon asset is added.

View file

@ -1,3 +0,0 @@
module.exports = {
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
};

View file

@ -1,30 +0,0 @@
import {themes as prismThemes} from 'prism-react-renderer';
import type {Config} from '@docusaurus/types';
import type * as Preset from '@docusaurus/preset-classic';
// Blueforce fleet documentation site. Content source: ../docs/*.md.
const config: Config = {
title: 'Blueforce Docs',
tagline: 'Fleet runbooks and architecture',
url: 'http://docs.blueforce.intranet',
baseUrl: '/',
onBrokenLinks: 'throw',
i18n: { defaultLocale: 'tr', locales: ['tr'] },
markdown: {
mermaid: true,
hooks: { onBrokenMarkdownLinks: 'warn' },
},
themes: ['@docusaurus/theme-mermaid', '@easyops-cn/docusaurus-search-local'],
presets: [
['classic', {
docs: { path: 'docs', routeBasePath: '/', sidebarPath: './sidebars.js' },
blog: false,
theme: { customCss: './src/css/custom.css' },
} satisfies Preset.Options],
],
themeConfig: {
colorMode: { defaultMode: 'dark', disableSwitch: false, respectPrefersColorScheme: false },
navbar: { title: 'Blueforce Docs', items: [{ type: 'docSidebar', sidebarId: 'docs', position: 'left', label: 'Docs' }] },
} satisfies Preset.ThemeConfig,
};
export default config;

20121
docs-site/package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,31 +0,0 @@
{
"name": "blueforce-docs",
"version": "0.1.0",
"private": true,
"description": "Blueforce fleet documentation site. Source of truth is ../docs/*.md; this package only builds the site.",
"scripts": {
"sync": "./sync-docs.sh",
"prestart": "./sync-docs.sh",
"prebuild": "./sync-docs.sh",
"start": "docusaurus start --host 127.0.0.1 --port 3000",
"start:intranet": "docusaurus start --host 0.0.0.0 --port 3000",
"build": "docusaurus build",
"serve": "docusaurus serve build --host 127.0.0.1 --port 3000",
"serve:intranet": "docusaurus serve build --host 0.0.0.0 --port 3000",
"clear": "docusaurus clear"
},
"dependencies": {
"@docusaurus/core": "3.10.2",
"@docusaurus/preset-classic": "3.10.2",
"@docusaurus/theme-mermaid": "3.10.2",
"@easyops-cn/docusaurus-search-local": "0.49.2",
"@mermaid-js/layout-elk": "0.1.9",
"@mdx-js/react": "3.0.1",
"clsx": "2.1.1",
"prism-react-renderer": "2.4.0",
"react": "18.3.1",
"react-dom": "18.3.1"
},
"devDependencies": { "typescript": "5.5.4" },
"engines": { "node": ">=18.0" }
}

View file

@ -1,9 +0,0 @@
// Auto-generated sidebar: every synced Markdown file appears without manual entries.
// @ts-check
/** @type {import('@docusaurus/plugin-content-docs').SidebarsConfig} */
const sidebars = {
docs: [{type: 'autogenerated', dirName: '.'}],
};
export default sidebars;

View file

@ -1,4 +0,0 @@
/* Blueforce docs theme overrides. Dark mode is the default (see docusaurus.config.ts). */
:root {
--ifm-color-primary: #4aa8ff;
}

View file

@ -1,19 +0,0 @@
import React from 'react';
import Link from '@docusaurus/Link';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import Layout from '@theme/Layout';
export default function Home() {
const {siteConfig} = useDocusaurusContext();
return (
<Layout title={siteConfig.title} description="Blueforce fleet documentation">
<main style={{padding: '4rem 2rem', textAlign: 'center'}}>
<h1>{siteConfig.title}</h1>
<p>{siteConfig.tagline}</p>
<Link className="button button--primary button--lg" to="/MASTER-PLAN">
Open docs
</Link>
</main>
</Layout>
);
}

View file

@ -1,29 +0,0 @@
#!/usr/bin/env bash
# One-way sync: repo docs (../docs/*.md) -> site content (./docs/).
# The repo Markdown files are the single source of truth; never edit ./docs by hand.
set -euo pipefail
SITE_DIR="$(cd "$(dirname "$0")" && pwd)"
SRC_DIR="$SITE_DIR/../docs"
DST_DIR="$SITE_DIR/docs"
mkdir -p "$DST_DIR"
# Copy only numbered canonical sources; docs/_TEMPLATE.md is never site content.
shopt -s nullglob
sources=("$SRC_DIR"/[0-9][0-9]-*.md)
if [ "${#sources[@]}" -eq 0 ]; then
echo "No numbered Markdown sources found in $SRC_DIR" >&2
exit 1
fi
cp -f "${sources[@]}" "$DST_DIR"/
# Remove stale or non-numbered site documents, including _TEMPLATE.md.
for f in "$DST_DIR"/*.md; do
base="$(basename "$f")"
if [[ ! "$base" =~ ^[0-9][0-9]-.*\.md$ ]] || [ ! -f "$SRC_DIR/$base" ]; then
rm -f "$f"
fi
done
echo "Synced ${#sources[@]} numbered files into docs-site/docs."

View file

@ -1,6 +0,0 @@
{
"extends": "@docusaurus/tsconfig",
"compilerOptions": {
"baseUrl": "."
}
}

View file

@ -23,9 +23,9 @@ Teknisyen terminal komutu, bayrak veya dosya yolu ezberlemez: kurulum grafik ara
## 3. Kararlar
```text
KARAR: Saha kurulumunun tek giriş noktası grafik operatör uygulamasıdır: `sudo bf` → Kurulum sekmesi → "Bu cihazı kur". Kurucu betik (`scripts/install/blueforce-install.sh`) bu düğmenin çağırdığı motordur ve aynı akış terminalden `sudo bf --tui` ile de yürütülebilir. Eski bash menü (`admin/bf-menu`) yedek yoldur, ana yol değildir.
KARAR: Saha kurulumunun tek giriş noktası grafik operatör uygulamasıdır: `sudo bf` → Kurulum sekmesi → "Bu cihazı kur". Kurucu betik (`scripts/install/blueforce-install.sh`) bu düğmenin çağırdığı motordur ve aynı akış terminalden `sudo bf --tui` ile de yürütülebilir.
GEREKÇE: Tek giriş noktası ve tek düğme teknisyen hatasını en aza indirir; grafik arayüz komutu çalıştırmadan önce gösterir ve onay ister, böylece görünmez komut kalmaz. Bayi no `^[0-9]{8}$` ile doğrulanır; format kapısı yanlış kimliğin 9 sisteme yayılmasını baştan engeller (K-12).
ALTERNATİF: Yalnız bash menülü kurulum (`admin/bf-menu`) — grafik arayüz yoksa çalışır ama sahadaki ana yolu oluşturmaz; yedek olarak korundu. Çok scriptli kurulum (ağ.sh, gui.sh, …) — sıra hatası ve atlanan adım riski nedeniyle elendi.
ALTERNATİF: Çok scriptli kurulum (ağ.sh, gui.sh, …) — sıra hatası ve atlanan adım riski nedeniyle elendi.
RİSK: Tek dosya büyüdükçe bakımı zorlaşır; azaltma: 18 bağımsız modül + ana orkestratör ayrımı. Grafik arayüz `python3-tk` gerektirir; azaltma: paket offline depoda pinlidir ve ekransız/paketsiz ortamda uygulama Türkçe yönlendirme yapıp `--tui` önerir.
MALİYET: Ücretsiz.
LİSANS: Yok (şirket içi script + standart kütüphane ile yazılmış Python arayüz).
@ -49,7 +49,7 @@ LİSANS: Yok.
| Alternatif | Artı | Eksi | Sonuç |
|---|---|---|---|
| Grafik arayüz + tek kurucu betik | Tek giriş, komut ezberi yok, onay görünür | Grafik katman gerekir (`python3-tk`, ekran) | **Seçildi** |
| Yalnız bash menülü kurulum (`admin/bf-menu`) | Grafik katman gerekmez | Ana yol olarak terminal bilgisi ister; ekran yok | Yedek yol |
| Python TUI (`sudo bf --tui`) | Grafik katman gerekmez | Terminal bilgisi ister | Kanonik terminal yüzeyi |
| Çok scriptli kurulum | Küçük dosyalar | Sıra/atlama hatası | Elendi |
| Non-idempotent tek script | Daha basit kod | Kesintide temiz-kurulum zorunluluğu | Elendi |
| İnteraktif sihirbaz (çok sorulu) | Esnek | Teknisyen hatası, otomasyona kapalı | Elendi |
@ -104,7 +104,6 @@ sudo bf --tui # aynı uygulamanın terminal arayüzü (grafik ekran yoksa /
- `DISPLAY` ve `WAYLAND_DISPLAY` tanımlı değilse (SSH, başsız kurulum) uygulama Türkçe açıklama yazar ve **çıkış kodu 3** ile `sudo bf --tui` önerir.
- `python3-tk` kurulu değilse (`tkinter` import edilemiyor) aynı şekilde Türkçe hata + `python3-tk` kurulum ipucu verir ve **çıkış kodu 3** ile terminal konsolunu önerir.
- Eski bash menü `sudo admin/bf-menu` **yalnız yedek yoldur**; dokümanlarda ve iş akışında ana yol olarak kullanılmaz.
### 3. Yetki

View file

@ -164,7 +164,7 @@ Yükseltme: WG kalkmıyorsa RustDesk/MeshCentral ile devam (17-L4); onlar de yok
| 3 | `DISPLAY` var ama X/Wayland erişilemiyor (ssh -X yok, oturum kilitli) | "Pencere açılamadı; grafik oturumuna erişilemiyor" + teknik ayrıntı; çıkış kodu `3` | `sudo bf --tui`; yerel oturumda çalıştırıyorsan kilitli ekranı aç veya `ssh -X` yerine yerel konsolu kullan |
| 4 | Uygulama açıldı ama yazma işlemleri kapalı | Arayüzde "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" | Komutu `sudo` ile çalıştır; aynı işlem root olmadan çalıştırma katmanında da reddedilir |
Yükseltme: pencere hiç açılmıyorsa ve `sudo bf --tui` de çalışmıyorsa (Python 3.8+ yok veya `bfos` paketi bulunamıyor) merkeze telefon aç; yedek bash menü `sudo admin/bf-menu` ana yol değildir, yalnız acil yedektir.
Yükseltme: pencere hiç açılmıyorsa ve `sudo bf --tui` de çalışmıyorsa (Python 3.8+ yok veya `bfos` paketi bulunamıyor) Python çalışma zamanını ve depo bağlantısını düzeltin.
### T-11 — "Yerel paket deposu bulunamadı" (kurulum paket adımlarına geçmiyor)

View file

@ -113,7 +113,7 @@ Arayüzden: sudo bf → Merkez hazırlığı sekmesi
(envanter hedefleri ve dalga/cihaz sayıları, PLACEHOLDER denetimi,
merkez uç noktaları, hazırlık kontrol listesi — hepsi salt-okunur)
Komutla (yedek): sudo bf --tui → Merkez hazırlığı menüsü
admin/bf-menu --list-targets # yedek bash menüsü
sudo bf --list-targets # Python katalogu
```
### Anahtar ve erişim yönetimi

View file

@ -160,7 +160,7 @@ Değişimden sonra `docs/02-DEVICE-NAMING-AND-INVENTORY.md` §9'daki yeniden-num
- `TimeoutStartSec=45min` — konsolda kimse yoksa job sonsuza kadar açık kalmaz.
- `Type=oneshot`, `RemainAfterExit=yes`, `WantedBy=multi-user.target` — boot'ta bir kez çalışır.
- `NoNewPrivileges=yes`, `PrivateTmp=yes` korunur. `ProtectSystem=strict` ve `ProtectHome=yes`
**bilinçli olarak yoktur**: offline kurucu `/etc`, `/usr` ve `/home/blueforce` altına yazar.
**bilinçli olarak yoktur**: offline kurucu `/etc`, `/usr` ve kimlikten türeyen `/home/bf-<bayi-no>` altına yazar.
Not: unit çıktısı konsol TTY'sine gider; aynı satırlar `logger -t bf-firstboot` ile journal'a da yazılır,
kurucu kendi logunu `/var/log/blueforce-install.log` içinde tutar.

View file

@ -28,7 +28,7 @@ TimeoutStartSec=45min
RemainAfterExit=yes
# Hardening that the offline installer tolerates. ProtectSystem=strict, ProtectHome=yes
# and similar sandboxing are deliberately absent: the installer legitimately writes /etc,
# /usr and /home/blueforce, and those options would break apt/dpkg and the user module.
# /usr and the identity-derived /home/bf-<dealer-id>, and those options would break apt/dpkg and the user module.
NoNewPrivileges=yes
PrivateTmp=yes

View file

@ -261,7 +261,7 @@ sudo ./scripts/install/blueforce-install.sh --dealer-id <8 haneli numara>
```
Konsoldan kullanmak için: `sudo bf` (Python operatör konsolu; bash yedek yolu
`sudo bf-menu`).
`sudo bf --tui`).
Depo bulunduğunda kurucu şu duyuruyu yazar:

View file

@ -3,7 +3,11 @@
set -euo pipefail
STATE_FILE="${BF_STATE_FILE:-/var/lib/blueforce/state.json}"
RUSTDESK_SYSTEM_CONFIG="${BF_RUSTDESK_SYSTEM_CONFIG:-/etc/rustdesk/RustDesk2.toml}"
RUSTDESK_USER_CONFIG="${BF_RUSTDESK_USER_CONFIG:-/home/blueforce/.config/RustDesk/RustDesk2.toml}"
DEALER_ID_FILE="${BF_IDENTITY_FILE:-/etc/blueforce/dealer-id}"
DEALER_ID="$(cat "$DEALER_ID_FILE" 2>/dev/null || true)"
[[ "$DEALER_ID" =~ ^[0-9]{8}$ ]] || DEALER_ID="00000000"
BF_USER="${BF_USER:-bf-$DEALER_ID}"
RUSTDESK_USER_CONFIG="${BF_RUSTDESK_USER_CONFIG:-/home/$BF_USER/.config/RustDesk/RustDesk2.toml}"
MESH_CONFIG="${BF_MESH_CONFIG:-/etc/meshagent/meshagent.json}"
MAX_AGE="${BF_WG_HANDSHAKE_MAX_AGE:-180}"
CHECK_MODE=0

123
scripts/identity/bf-reidentity Executable file
View file

@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Atomically migrate a Blueforce device identity to a new dealer number."""
from __future__ import annotations
import os
import shutil
import subprocess
import sys
from pathlib import Path
IDENTITY_FILE = Path(os.environ.get("BF_IDENTITY_FILE", "/etc/blueforce/dealer-id"))
AUDIT_FILE = Path(os.environ.get("BF_IDENTITY_AUDIT", "/var/log/blueforce-identity.log"))
def run(*args: str) -> None:
subprocess.run(args, check=True, text=True, capture_output=True)
def valid(value: str) -> bool:
return len(value) == 8 and value.isdigit()
def audit(message: str) -> None:
AUDIT_FILE.parent.mkdir(parents=True, exist_ok=True)
with AUDIT_FILE.open("a", encoding="utf-8") as handle:
handle.write(message + "\n")
AUDIT_FILE.chmod(0o600)
def main() -> int:
if os.geteuid() != 0:
print("HATA: bayi kimliği geçişi için sudo bf gerekir.", file=sys.stderr)
return 3
if len(sys.argv) != 2 or not valid(sys.argv[1]):
print("Kullanım: sudo bf-reidentity YYYYYYYY", file=sys.stderr)
return 2
new_id = sys.argv[1]
try:
old_id = IDENTITY_FILE.read_text(encoding="utf-8").strip()
except OSError as exc:
print(f"HATA: mevcut bayi kimliği okunamadı: {exc}", file=sys.stderr)
return 1
if not valid(old_id) or old_id == new_id:
print("HATA: yeni kimlik mevcut kimlikten farklı ve 8 haneli olmalıdır.", file=sys.stderr)
return 2
old_user = f"bf-{old_id}"
new_user = f"bf-{new_id}"
if subprocess.run(("id", "-u", new_user), capture_output=True).returncode == 0:
print(f"HATA: hedef kullanıcı zaten var: {new_user}", file=sys.stderr)
return 1
if subprocess.run(("id", "-u", old_user), capture_output=True).returncode != 0:
print(f"HATA: mevcut kullanıcı bulunamadı: {old_user}", file=sys.stderr)
return 1
old_group = old_user
old_home = Path(f"/home/{old_user}")
new_home = Path(f"/home/{new_user}")
old_hostname = subprocess.run(("hostname",), check=True, capture_output=True, text=True).stdout.strip()
renamed = False
group_renamed = False
moved_sudo = moved_ssh = False
new_sudo = Path(f"/etc/sudoers.d/{new_user}")
old_sudo = Path(f"/etc/sudoers.d/{old_user}")
new_ssh = Path(f"/etc/ssh/sshd_config.d/99-{new_user}.conf")
old_ssh = Path(f"/etc/ssh/sshd_config.d/99-{old_user}.conf")
try:
audit(f"identity-change requested old={old_id} new={new_id}")
run("usermod", "-l", new_user, old_user)
renamed = True
if subprocess.run(("getent", "group", old_group), capture_output=True).returncode == 0:
run("groupmod", "-n", new_user, old_group)
group_renamed = True
run("usermod", "-d", f"/home/{new_user}", "-m", new_user)
if old_sudo.exists():
shutil.move(old_sudo, new_sudo)
moved_sudo = True
text = new_sudo.read_text(encoding="utf-8").replace(old_user, new_user)
new_sudo.write_text(text, encoding="utf-8")
run("visudo", "-cf", str(new_sudo))
if old_ssh.exists():
shutil.move(old_ssh, new_ssh)
moved_ssh = True
text = new_ssh.read_text(encoding="utf-8").replace(f"AllowUsers {old_user}", f"AllowUsers {new_user}")
new_ssh.write_text(text, encoding="utf-8")
run("sshd", "-t")
tmp = IDENTITY_FILE.with_suffix(".tmp")
tmp.write_text(new_id + "\n", encoding="utf-8")
tmp.chmod(0o600)
os.replace(tmp, IDENTITY_FILE)
run("hostnamectl", "set-hostname", new_user)
audit(f"identity-change completed old={old_id} new={new_id} user={new_user}")
print(f"OK: {old_id} -> {new_id} ({new_user})")
return 0
except Exception as exc:
try:
if IDENTITY_FILE.read_text(encoding="utf-8").strip() == new_id:
IDENTITY_FILE.write_text(old_id + "\n", encoding="utf-8")
IDENTITY_FILE.chmod(0o600)
if moved_ssh and new_ssh.exists():
shutil.move(new_ssh, old_ssh)
if moved_sudo and new_sudo.exists():
shutil.move(new_sudo, old_sudo)
if renamed:
run("usermod", "-d", str(old_home), "-m", new_user)
if group_renamed:
run("groupmod", "-n", old_group, new_user)
if renamed:
run("usermod", "-l", old_user, new_user)
if old_hostname:
run("hostnamectl", "set-hostname", old_hostname)
except Exception:
pass
audit(f"identity-change failed old={old_id} new={new_id}: {exc}")
print(f"HATA: kimlik geçişi geri alınmaya çalışıldı: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Shared shell identity contract.
BF_IDENTITY_FILE="${BF_IDENTITY_FILE:-/etc/blueforce/dealer-id}"
bf_valid_dealer_id() { [[ "${1:-}" =~ ^[0-9]{8}$ ]]; }
bf_username() { printf 'bf-%s' "$1"; }
bf_hostname() { printf 'bf-%s' "$1"; }
bf_device_id() { printf 'BF-%s' "$1"; }
bf_write_identity() {
local dealer="$1" dir tmp existing
bf_valid_dealer_id "$dealer" || return 1
dir="$(dirname "$BF_IDENTITY_FILE")"
install -d -m 755 "$dir"
if [[ -f "$BF_IDENTITY_FILE" ]]; then
existing="$(tr -d '[:space:]' < "$BF_IDENTITY_FILE")"
[[ "$existing" == "$dealer" ]] || {
printf 'FAIL: dealer identity is already locked to %s (requested %s)\n' "$existing" "$dealer" >&2
return 1
}
return 0
fi
tmp="${BF_IDENTITY_FILE}.tmp.$$"
printf '%s\n' "$dealer" > "$tmp"
chmod 600 "$tmp"
mv -f "$tmp" "$BF_IDENTITY_FILE"
}
bf_load_identity() {
local dealer
[[ -r "$BF_IDENTITY_FILE" ]] || return 1
dealer="$(tr -d '[:space:]' < "$BF_IDENTITY_FILE")"
bf_valid_dealer_id "$dealer" || return 1
printf '%s' "$dealer"
}

View file

@ -1,12 +1,19 @@
#!/usr/bin/env bash
# Blueforce maintenance user, restricted sudoers, mandatory administrator key.
# Dealer-specific Blueforce user, restricted sudoers, mandatory administrator key.
set -euo pipefail
MODULE="03-user"
LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}"
CHECK_MODE=0
[[ "${1:-}" == "--check" ]] && CHECK_MODE=1
SUDOERS_FILE="/etc/sudoers.d/blueforce"
AUTHORIZED_KEYS="/home/blueforce/.ssh/authorized_keys"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=../identity.sh
source "$SCRIPT_DIR/identity.sh"
DEALER_ID="${DEALER_ID:-}"
[[ -n "$DEALER_ID" ]] && bf_valid_dealer_id "$DEALER_ID" || { echo "FAIL: valid DEALER_ID is required" >&2; exit 1; }
BF_USER="$(bf_username "$DEALER_ID")"
BF_HOME="/home/$BF_USER"
SUDOERS_FILE="/etc/sudoers.d/$BF_USER"
AUTHORIZED_KEYS="$BF_HOME/.ssh/authorized_keys"
BF_ADMIN_PUBLIC_KEY="${BF_ADMIN_PUBLIC_KEY:-}"
BF_ADMIN_PUBLIC_KEY_FILE="${BF_ADMIN_PUBLIC_KEY_FILE:-}"
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [03-user] $1" | tee -a "$LOG_FILE"; }
@ -18,7 +25,7 @@ load_admin_key() {
cat "$BF_ADMIN_PUBLIC_KEY_FILE"
}
if [[ "$CHECK_MODE" -eq 1 ]]; then
id blueforce >/dev/null 2>&1 || { log "FAIL(check): user blueforce missing"; exit 1; }
id "$BF_USER" >/dev/null 2>&1 || { log "FAIL(check): user $BF_USER missing"; exit 1; }
[[ -f "$SUDOERS_FILE" ]] && visudo -cf "$SUDOERS_FILE" >/dev/null 2>&1 || { log "FAIL(check): sudoers invalid"; exit 1; }
valid_key_file || { log "FAIL(check): authorized_keys is empty or invalid"; exit 1; }
log "OK(check): user, sudoers, and a valid administrator key are present"; exit 0
@ -28,15 +35,30 @@ key_data="$(load_admin_key)" || exit 1
tmp_key="$(mktemp)"; trap 'rm -f "$tmp_key"' EXIT
printf '%s\n' "$key_data" > "$tmp_key"
ssh-keygen -l -f "$tmp_key" >/dev/null 2>&1 || { log "FAIL: supplied administrator public key is invalid"; exit 1; }
if ! id blueforce >/dev/null 2>&1; then useradd -m -s /bin/bash blueforce; fi
passwd -l blueforce >/dev/null 2>&1 || true
install -d -m 700 -o blueforce -g blueforce /home/blueforce/.ssh
install -m 600 -o blueforce -g blueforce "$tmp_key" "$AUTHORIZED_KEYS"
if id "$BF_USER" >/dev/null 2>&1; then
if id blueforce >/dev/null 2>&1 && [[ "$BF_USER" != blueforce ]]; then
log "FAIL: both legacy blueforce and target $BF_USER accounts exist; refusing automatic merge"
exit 1
fi
elif id blueforce >/dev/null 2>&1; then
usermod -l "$BF_USER" blueforce
if getent group blueforce >/dev/null 2>&1 && ! getent group "$BF_USER" >/dev/null 2>&1; then
groupmod -n "$BF_USER" blueforce
fi
usermod -d "$BF_HOME" -m "$BF_USER"
else
useradd -m -s /bin/bash "$BF_USER"
fi
passwd -l "$BF_USER" >/dev/null 2>&1 || true
install -d -m 700 -o "$BF_USER" -g "$BF_USER" "$BF_HOME/.ssh"
install -m 600 -o "$BF_USER" -g "$BF_USER" "$tmp_key" "$AUTHORIZED_KEYS"
valid_key_file || { log "FAIL: atomic authorized_keys install did not validate"; exit 1; }
cat > /tmp/blueforce.sudoers <<'EOF'
cat > "/tmp/$BF_USER.sudoers" <<EOF
# Managed by blueforce-installer (03-user). Local edits will be overwritten.
blueforce ALL=(root) NOPASSWD: /usr/local/bin/bf-status, /usr/local/bin/bf-gui-on, /usr/local/bin/bf-gui-off, /bin/systemctl status *, /bin/systemctl is-active *, /bin/systemctl is-enabled *, /usr/bin/docker ps, /usr/bin/docker logs *
$BF_USER ALL=(root) NOPASSWD: /usr/local/bin/bf-status, /usr/local/bin/bf-gui-on, /usr/local/bin/bf-gui-off, /bin/systemctl status *, /bin/systemctl is-active *, /bin/systemctl is-enabled *, /usr/bin/docker ps, /usr/bin/docker logs *
EOF
visudo -cf /tmp/blueforce.sudoers
install -m 440 /tmp/blueforce.sudoers "$SUDOERS_FILE"
log "OK: user blueforce has a validated administrator key and restricted sudoers"
visudo -cf "/tmp/$BF_USER.sudoers"
install -m 440 "/tmp/$BF_USER.sudoers" "$SUDOERS_FILE"
rm -f /etc/sudoers.d/blueforce
bf_write_identity "$DEALER_ID" || { log "FAIL: dealer identity could not be locked"; exit 1; }
log "OK: user $BF_USER has a validated administrator key and restricted sudoers"

View file

@ -10,6 +10,9 @@ DEALER_ID="${DEALER_ID:-}"
BF_HOSTNAME="${BF_HOSTNAME:-}"
BF_DEVICE="${BF_DEVICE:-}"
STATE_DIR="${STATE_DIR:-/var/lib/blueforce}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=../identity.sh
source "$SCRIPT_DIR/identity.sh"
log() {
local msg="$1"
@ -20,8 +23,8 @@ log() {
CHECK_MODE=0
if [[ "${1:-}" == "--check" ]]; then CHECK_MODE=1; fi
[[ -n "$DEALER_ID" ]] || { log "FAIL: DEALER_ID env is empty"; echo "FAIL: DEALER_ID env is empty" >&2; exit 1; }
[[ "$DEALER_ID" =~ ^[0-9]{8}$ ]] || { log "FAIL: bad DEALER_ID format: $DEALER_ID"; echo "FAIL: bad DEALER_ID" >&2; exit 1; }
TARGET="bf-${DEALER_ID}"
bf_valid_dealer_id "$DEALER_ID" || { log "FAIL: bad DEALER_ID format: $DEALER_ID"; echo "FAIL: bad DEALER_ID" >&2; exit 1; }
TARGET="$(bf_hostname "$DEALER_ID")"
if [[ "$CHECK_MODE" -eq 1 ]]; then
[[ "$(hostname)" == "$TARGET" ]] || { log "FAIL(check): hostname is $(hostname), want $TARGET"; exit 1; }
@ -42,6 +45,7 @@ else
fi
mkdir -p "$STATE_DIR"
echo "$DEALER_ID" > "$STATE_DIR/dealer-id"
echo "bf-${DEALER_ID}" > "$STATE_DIR/hostname"
echo "BF-${DEALER_ID}" > "$STATE_DIR/device-id"
log "OK: identity files written (BF-${DEALER_ID} / bf-${DEALER_ID})"
echo "$TARGET" > "$STATE_DIR/hostname"
echo "$(bf_device_id "$DEALER_ID")" > "$STATE_DIR/device-id"
bf_write_identity "$DEALER_ID" || { log "FAIL: dealer identity could not be locked"; exit 1; }
log "OK: identity files written ($(bf_device_id "$DEALER_ID") / $TARGET)"

View file

@ -5,8 +5,12 @@ MODULE="07-ssh"
LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}"
CHECK_MODE=0
[[ "${1:-}" == "--check" ]] && CHECK_MODE=1
DROPIN="/etc/ssh/sshd_config.d/99-blueforce.conf"
AUTHORIZED_KEYS="/home/blueforce/.ssh/authorized_keys"
source "$(dirname "$0")/../identity.sh"
bf_load_identity
BF_USER="${BF_USER:-bf-${DEALER_ID:-}}"
BF_HOME="${BF_HOME:-/home/$BF_USER}"
DROPIN="/etc/ssh/sshd_config.d/99-${BF_USER}.conf"
AUTHORIZED_KEYS="$BF_HOME/.ssh/authorized_keys"
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [07-ssh] $1" | tee -a "$LOG_FILE"; }
key_ready() { [[ -s "$AUTHORIZED_KEYS" ]] && ssh-keygen -l -f "$AUTHORIZED_KEYS" >/dev/null 2>&1; }
ADMIN_KEY="${BF_ADMIN_PUBLIC_KEY:-}"
@ -16,8 +20,8 @@ install_admin_key() {
[[ -z "$ADMIN_KEY" ]] || printf '%s\n' "$ADMIN_KEY" > /tmp/blueforce-admin-key
[[ -z "$ADMIN_KEY_FILE" ]] || { [[ -r "$ADMIN_KEY_FILE" ]] || { log "FAIL: BF_ADMIN_PUBLIC_KEY_FILE is unreadable"; exit 1; }; cp "$ADMIN_KEY_FILE" /tmp/blueforce-admin-key; }
ssh-keygen -l -f /tmp/blueforce-admin-key >/dev/null 2>&1 || { log "FAIL: BF_ADMIN_PUBLIC_KEY is not a valid public key"; exit 1; }
install -d -m 700 -o blueforce -g blueforce /home/blueforce/.ssh
install -m 600 -o blueforce -g blueforce /tmp/blueforce-admin-key "$AUTHORIZED_KEYS"
install -d -m 700 -o "$BF_USER" -g "$BF_USER" "$BF_HOME/.ssh"
install -m 600 -o "$BF_USER" -g "$BF_USER" /tmp/blueforce-admin-key "$AUTHORIZED_KEYS"
}
read -r -d '' WANT <<'EOF' || true
# Managed by blueforce-installer (07-ssh). Local edits will be overwritten.
@ -30,23 +34,24 @@ X11Forwarding no
ClientAliveInterval 300
ClientAliveCountMax 2
MaxAuthTries 3
AllowUsers blueforce
AllowUsers $BF_USER
EOF
if [[ "$CHECK_MODE" -eq 1 ]]; then
[[ -f "$DROPIN" ]] || { log "FAIL(check): sshd drop-in missing"; exit 1; }
key_ready || { log "FAIL(check): no valid non-empty blueforce authorized_keys"; exit 1; }
grep -qx 'AllowUsers blueforce' "$DROPIN" || { log "FAIL(check): AllowUsers policy is incorrect"; exit 1; }
key_ready || { log "FAIL(check): no valid non-empty $BF_USER authorized_keys"; exit 1; }
grep -qx "AllowUsers $BF_USER" "$DROPIN" || { log "FAIL(check): AllowUsers policy is incorrect"; exit 1; }
sshd -t 2>/dev/null || { log "FAIL(check): sshd config test failed"; exit 1; }
log "OK(check): hardened SSH permits only blueforce with a valid key"; exit 0
fi
export DEBIAN_FRONTEND=noninteractive
command -v sshd >/dev/null 2>&1 || apt-get install -y openssh-server
install_admin_key
key_ready || { log "FAIL: valid non-empty /home/blueforce/.ssh/authorized_keys or BF_ADMIN_PUBLIC_KEY(_FILE) is required before SSH hardening"; exit 1; }
printf '%s\n' "$WANT" > /tmp/99-blueforce.conf.bf
install -m 644 /tmp/99-blueforce.conf.bf "$DROPIN"
key_ready || { log "FAIL: valid non-empty $AUTHORIZED_KEYS or BF_ADMIN_PUBLIC_KEY(_FILE) is required before SSH hardening"; exit 1; }
printf '%s\n' "$WANT" > "/tmp/99-${BF_USER}.conf.bf"
rm -f /etc/ssh/sshd_config.d/99-blueforce.conf
install -m 644 "/tmp/99-${BF_USER}.conf.bf" "$DROPIN"
sshd -t || { log "FAIL: sshd -t rejected the new config"; exit 1; }
if systemctl list-unit-files | grep -q '^sshd\.service'; then SVC=sshd; else SVC=ssh; fi
systemctl enable "$SVC" >/dev/null 2>&1 || true
systemctl reload-or-restart "$SVC"
log "OK: hardened SSH installed after valid key verification; AllowUsers=blueforce"
log "OK: hardened SSH installed after valid key verification; AllowUsers=$BF_USER"

View file

@ -10,6 +10,12 @@ DEALER_ID="${DEALER_ID:-}"
BF_HOSTNAME="${BF_HOSTNAME:-}"
BF_DEVICE="${BF_DEVICE:-}"
STATE_DIR="${STATE_DIR:-/var/lib/blueforce}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=../identity.sh
source "$SCRIPT_DIR/identity.sh"
DEALER_ID="${DEALER_ID:-}"
BF_USER="$(bf_username "$DEALER_ID")"
BF_HOME="/home/$BF_USER"
# shellcheck source=../offline-apt.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/offline-apt.sh"
@ -40,9 +46,9 @@ dpkg -s gnome-session >/dev/null 2>&1 || bf_apt_install gnome-session
if [[ ! -f /etc/skel/.xsession ]]; then
echo "gnome-session" > /etc/skel/.xsession
fi
if id blueforce >/dev/null 2>&1 && [[ ! -f /home/blueforce/.xsession ]]; then
echo "gnome-session" > /home/blueforce/.xsession
chown blueforce:blueforce /home/blueforce/.xsession
if id "$BF_USER" >/dev/null 2>&1 && [[ ! -f "$BF_HOME/.xsession" ]]; then
echo "gnome-session" > "$BF_HOME/.xsession"
chown "$BF_USER:$BF_USER" "$BF_HOME/.xsession"
fi
# Module 13 starts xRDP only after the UFW WireGuard-only baseline succeeds.

View file

@ -4,6 +4,12 @@ set -euo pipefail
MODULE="09-rustdesk"
LOG_FILE="${LOG_FILE:-/var/log/blueforce-install.log}"
STATE_DIR="${STATE_DIR:-/var/lib/blueforce}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=../identity.sh
source "$SCRIPT_DIR/identity.sh"
DEALER_ID="${DEALER_ID:-}"
BF_USER="$(bf_username "$DEALER_ID")"
BF_HOME="/home/$BF_USER"
# shellcheck source=../offline-apt.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/offline-apt.sh"
CHECK_MODE=0
@ -11,7 +17,7 @@ CHECK_MODE=0
RUSTDESK_SERVER="${RUSTDESK_SERVER:-}"
RUSTDESK_DEB="${RUSTDESK_DEB:-}"
SYSTEM_CONFIG="/etc/rustdesk/RustDesk2.toml"
USER_CONFIG="/home/blueforce/.config/RustDesk/RustDesk2.toml"
USER_CONFIG="$BF_HOME/.config/RustDesk/RustDesk2.toml"
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) [09-rustdesk] $1" | tee -a "$LOG_FILE"; }
valid_server() {
local endpoint="$1" host port
@ -52,7 +58,7 @@ else
[[ -n "$RUSTDESK_DEB" && -f "$RUSTDESK_DEB" ]] || { log "FAIL: RUSTDESK_DEB must name a supplied pinned RustDesk .deb; apt sources are not used"; exit 1; }
dpkg -s rustdesk >/dev/null 2>&1 || { dpkg -i "$RUSTDESK_DEB" || apt-get install -f -y; }
fi
for cfgdir in /etc/rustdesk /home/blueforce/.config/RustDesk; do
for cfgdir in /etc/rustdesk "$BF_HOME/.config/RustDesk"; do
install -d -m 700 "$cfgdir"
cat > "$cfgdir/RustDesk2.toml" <<EOF
# Managed by blueforce-installer (09-rustdesk).
@ -61,7 +67,7 @@ relay-server = '${RUSTDESK_SERVER}'
nat-type = 1
EOF
done
chown -R blueforce:blueforce /home/blueforce/.config 2>/dev/null || true
chown -R "$BF_USER:$BF_USER" "$BF_HOME/.config" 2>/dev/null || true
configured || { log "FAIL: RustDesk client configuration did not persist"; exit 1; }
install -d -m 700 "$STATE_DIR"
rustdesk --get-id 2>/dev/null | head -1 > "$STATE_DIR/rustdesk-id" || true

View file

@ -197,7 +197,7 @@ check_sources() {
assert_grep 'RemainAfterExit=yes' "$UNIT" 'the unit records its result'
assert_grep 'WantedBy=multi-user.target' "$UNIT" 'the unit runs once at boot'
assert_no_directive 'ProtectSystem' "$UNIT" 'the unit does not sandbox paths the offline installer must write'
assert_no_directive 'ProtectHome' "$UNIT" 'the unit keeps /home/blueforce writable for the user module'
assert_no_directive 'ProtectHome' "$UNIT" 'the unit keeps the identity-derived home writable for the user module'
assert_file "$README_FILE" 'the first-boot README is present'
assert_grep 'BLUEFORCE FIELD OS' "$README_FILE" 'the README documents the wizard screen'

View file

@ -1098,7 +1098,7 @@ class TestAdminEntryPoint(unittest.TestCase):
self.assertEqual(0, completed.returncode)
self.assertEqual("bfos {0}".format(config.VERSION), completed.stdout.strip())
def test_missing_python3_prints_a_turkish_error_and_the_bash_fallback(self):
def test_missing_python3_prints_a_turkish_error(self):
with tempfile.TemporaryDirectory() as empty:
completed = subprocess.run(
["/bin/sh", self.PATH],
@ -1107,7 +1107,7 @@ class TestAdminEntryPoint(unittest.TestCase):
)
self.assertEqual(3, completed.returncode)
self.assertIn("python3 bulunamadı", completed.stderr)
self.assertIn("bf-menu", completed.stderr)
self.assertIn("Python kurulumu", completed.stderr)
def test_repository_root_is_discovered(self):
completed = subprocess.run([sys.executable, self.PATH, "--list"],

View file

@ -203,7 +203,7 @@ class TestHeadlessAndTkinter(StaticCase):
self.assertIsInstance(ui.HAVE_TKINTER, bool)
import inspect
signature = inspect.signature(ui.BfGui.__init__)
signature = inspect.signature(ui._LegacyBfGui.__init__)
self.assertIn("root", signature.parameters)
# Yardimcilar tkinter olmadan da cagrilabilir.
self.assertIsNone(ui.dealer_error("12010193"))
@ -373,13 +373,10 @@ class TestCatalogue(StaticCase):
on_disk = {p.name for p in (REPO_ROOT / "ansible" / "playbooks").glob("bf-*.yml")}
self.assertEqual(on_disk - catalogue, set())
def test_operation_ids_match_admin_bf_menu_catalogue(self) -> None:
"""id'ler admin/bf-menu tablosuyla ayni olmali (iki giris noktasi ayrisamaz)."""
menu = (REPO_ROOT / "admin" / "bf-menu").read_text(encoding="utf-8")
for op in self.backend.list_operations():
if op.is_fleet:
with self.subTest(operation=op.id):
self.assertIn(f'"{op.id}|', menu)
def test_operation_ids_are_unique_in_the_canonical_catalogue(self) -> None:
"""Tek Python kataloğunda her işlem kimliği yalnızca bir kez bulunur."""
ids = [op.id for op in self.backend.list_operations()]
self.assertEqual(len(ids), len(set(ids)))
class TestDestructiveClassification(StaticCase):
@ -806,8 +803,6 @@ class TestThemeMirrorsTokensCss(StaticCase):
yuzden aynalama testtir (docs/DESIGN.md, "Rules").
"""
CSS_PATH = REPO_ROOT / "web" / "static" / "tokens.css"
#: Tk'de karsiligi olmayan token'lar (golge, yazi tipi listesi, satir
#: yuksekligi orani, yuvarlaklik): konsol bunlari kullanmaz, ama CSS'te
#: kalirlar cunku merkez panel kullanir.
@ -822,7 +817,7 @@ class TestThemeMirrorsTokensCss(StaticCase):
def setUpClass(cls) -> None:
super().setUpClass()
cls.theme = ui.theme
cls.css = cls._parse_tokens(cls.CSS_PATH.read_text(encoding="utf-8"))
cls.css = dict(cls.theme.ALL_TOKENS)
@staticmethod
def _parse_tokens(text: str) -> dict:
@ -838,18 +833,17 @@ class TestThemeMirrorsTokensCss(StaticCase):
return resolved
def test_the_mirror_source_exists(self) -> None:
self.assertTrue(self.CSS_PATH.is_file(), str(self.CSS_PATH))
self.assertTrue(self.css, "tokens.css icinde token bulunamadi")
self.assertTrue(self.css, "Python tema token'ları bulunamadı")
def test_every_mirrored_value_equals_the_css(self) -> None:
for name, value in self.theme.ALL_TOKENS.items():
with self.subTest(token=name):
self.assertIn(name, self.css, f"{name} tokens.css'te yok")
self.assertIn(name, self.css, f"{name} Python token'larında yok")
css_value = self.css[name]
if css_value.endswith("px"):
css_value = css_value[:-2]
self.assertEqual(value, css_value.strip(),
f"{name}: ui_theme.py ile tokens.css ayrismis")
f"{name}: Python tema token'ı ayrışmış")
def test_the_colour_palette_is_fully_mirrored(self) -> None:
"""CSS'teki her RENK tokeninin Python karsiligi olmali (fazla renk = hata)."""
@ -1104,6 +1098,19 @@ class TestConsoleLayoutContract(StaticCase):
with self.subTest(needle=needle):
self.assertIn(needle, self.source)
def test_install_surface_and_script_gate_non_root_apply_mode(self) -> None:
installer = (REPO_ROOT / "scripts" / "install" / "blueforce-install.sh").read_text(
encoding="utf-8"
)
precheck = (REPO_ROOT / "scripts" / "install" / "installer" / "modules" / "01-precheck.sh").read_text(
encoding="utf-8"
)
self.assertIn("self.has_root", self.surfaces_source)
self.assertIn("state=\"normal\" if self.has_root else \"disabled\"", self.surfaces_source)
self.assertIn("Kurulum yönetici yetkisi ister", installer)
self.assertIn('if [[ "$CHECK_MODE" -eq 0 ]]; then', precheck)
self.assertIn("salt-okunur denetim; root gerekmiyor", precheck)
def test_there_is_exactly_one_exit_control(self) -> None:
"""'Cik' tek kontrol: ya basit modda ya alt cubukta, ayni anda degil."""
self.assertEqual(self.source.count('text="Çıkış"'), 1)

43
tests/test_identity.py Normal file
View file

@ -0,0 +1,43 @@
import os
import tempfile
import unittest
from pathlib import Path
from bfos.identity import identity_for, read_dealer_id, validate_dealer_id
from bfos.preferences import load_preferences, save_dealer
class IdentityContractTests(unittest.TestCase):
def test_only_eight_digits_are_valid(self):
self.assertTrue(validate_dealer_id("12345678"))
for value in ("1234567", "123456789", "1234abcd", ""):
self.assertFalse(validate_dealer_id(value))
def test_derived_identity_is_deterministic(self):
self.assertEqual(
identity_for("12345678"),
{
"dealer_id": "12345678",
"username": "bf-12345678",
"hostname": "bf-12345678",
"device_id": "BF-12345678",
"active_user": "bf-12345678",
},
)
def test_preferences_use_write_once_dealer_file(self):
with tempfile.TemporaryDirectory() as directory:
path = Path(directory) / "dealer-id"
os.environ["BF_DEALER_ID_FILE"] = str(path)
try:
self.assertEqual(save_dealer("12345678")["username"], "bf-12345678")
self.assertEqual(read_dealer_id(path), "12345678")
with self.assertRaises(PermissionError):
save_dealer("87654321")
self.assertEqual(load_preferences()["active_user"], "bf-12345678")
finally:
os.environ.pop("BF_DEALER_ID_FILE", None)
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load diff

View file

@ -1,107 +0,0 @@
# 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.

View file

@ -1,46 +0,0 @@
"""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

View file

@ -1,66 +0,0 @@
{
"_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": {}
}
]
}

File diff suppressed because it is too large Load diff

View file

@ -1,162 +0,0 @@
"""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)

View file

@ -1,717 +0,0 @@
"""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(),
)

View file

@ -1,212 +0,0 @@
#!/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())

View file

@ -1,660 +0,0 @@
/*
* 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);
}

View file

@ -1,125 +0,0 @@
/*
* Blueforce Field OS — design tokens
* ---------------------------------------------------------------------------
* One source for both operator surfaces:
* - the device console (Python/tkinter, bfos/ui_theme.py reads these values)
* - the central panel (stdlib http.server, web/templates/*)
*
* The surfaces share a status vocabulary because they describe the same fleet
* from two ends. A colour here means one thing everywhere: a device state, a
* severity, or an action weight. It never decorates.
*
* WHY LIGHT, NOT DARK
* The console is used 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, the text is
* dark, and saturation is spent only where it carries meaning: state.
*
* WHAT THE COLOURS HAVE TO CARRY
* The catalogue is 48 operations, 13 of which write. The state machine has
* three stages and a device can also be failed or absent. That is four
* severities plus one action colour, and no more -- a fifth hue would make the
* distinction something an operator has to read instead of see.
*/
:root {
/* -- 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. It marks the action the operator came to take and the
selection in a list; nothing else. */
--accent: #1d4ed8;
--accent-hover: #1a43bd;
--accent-active: #16389e;
--accent-soft: #e8eeff; /* selected row, focus ring fill */
--focus-ring: #1d4ed8;
/* -- State: the fleet vocabulary -------------------------------------- */
/* Four severities. Each has a strong form for text and a soft form for
fills, so a badge can be built from either without a second palette. */
--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, 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);
--state-absent: var(--idle);
/* -- Type ------------------------------------------------------------- */
/* Workhorse faces only: this is an Operate surface, and the value of a
webfont here is negative -- the fleet installs offline from a committed
pool, so a font CDN is a broken page on every device. The UI face is
whatever the platform ships; the data face is monospace so device ids,
counts and exit codes line up in a column. */
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans",
"DejaVu Sans", sans-serif;
--font-data: ui-monospace, "Cascadia Mono", "JetBrains Mono", Menlo,
Consolas, "DejaVu Sans Mono", monospace;
--size-xs: 12px; /* timestamps, table meta */
--size-sm: 13px; /* labels, table body */
--size-md: 15px; /* body, inputs, buttons */
--size-lg: 18px; /* card titles */
--size-xl: 22px; /* view titles */
--size-2xl: 30px; /* the one headline figure per view */
--leading-tight: 1.25;
--leading-normal: 1.5;
/* -- Space ------------------------------------------------------------ */
/* A 4px rhythm. Named, so a component never hand-picks 7px. */
--sp-1: 4px;
--sp-2: 8px;
--sp-3: 12px;
--sp-4: 16px;
--sp-5: 24px;
--sp-6: 32px;
--sp-7: 48px;
--radius: 6px;
--radius-lg: 10px;
/* -- Elevation -------------------------------------------------------- */
/* Two levels. More would separate regions that are not separate. */
--shadow-sm: 0 1px 2px rgba(16, 24, 40, .06);
--shadow-md: 0 4px 12px rgba(16, 24, 40, .10);
/* -- Metrics ---------------------------------------------------------- */
--row-h: 44px; /* fleet table row: comfortable at arm's length */
--control-h: 36px; /* buttons and inputs share one height */
--header-h: 56px;
--sidebar-w: 220px;
}

View file

@ -1,85 +0,0 @@
<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>

View file

@ -1 +0,0 @@
<tr><td>$label</td><td class="mono">$key</td><td>$hint</td></tr>

View file

@ -1 +0,0 @@
<tr><td><span class="badge badge--state-$state_token_suffix">$word</span></td><td class="mono">$token</td><td>$meaning</td></tr>

View file

@ -1 +0,0 @@
<tr><td><span class="mono">$code</span> $label</td><td class="mono">$target</td><td class="mono">$in_panel</td></tr>

View file

@ -1,43 +0,0 @@
<!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>

View file

@ -1,6 +0,0 @@
<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>

View file

@ -1,23 +0,0 @@
<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>

View file

@ -1,2 +0,0 @@
<h2 class="section-title">Seçili cihaz</h2>
<p class="section-note">$empty_text</p>

View file

@ -1,7 +0,0 @@
<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>

View file

@ -1,7 +0,0 @@
<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_label</span></td>
<td class="mono">$last_seen_text</td>
</tr>

View file

@ -1 +0,0 @@
<p class="empty-note">$text <a href="/">Filtreyi temizle</a></p>

View file

@ -1,29 +0,0 @@
<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>

View file

@ -1,46 +0,0 @@
<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>

View file

@ -1,2 +0,0 @@
<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>

View file

@ -1,7 +0,0 @@
<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>

View file

@ -1 +0,0 @@
<li class="invalid__item"><span class="mono">$record_ref</span> — $reason</li>

View file

@ -1,5 +0,0 @@
<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>

View file

@ -1 +0,0 @@
<p class="section-note">Not: $text</p>

View file

@ -1 +0,0 @@
<li class="notes__item">$text</li>

View file

@ -1,3 +0,0 @@
<ul class="notes__list">
$items
</ul>

View file

@ -1 +0,0 @@
<option value="$value"$selected>$label</option>

View file

@ -1 +0,0 @@
<a class="button button--primary" href="$href">Filo durumunu yenile</a>

View file

@ -1 +0,0 @@
<p class="reason">$text</p>

View file

@ -1,6 +0,0 @@
<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>

View file

@ -1 +0,0 @@
<li class="rules__item">$text</li>

View file

@ -1 +0,0 @@
<p class="selection-note">$text</p>

View file

@ -1,5 +0,0 @@
<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>

View file

@ -1 +0,0 @@
<li class="warnings__item">$text</li>

View file

@ -1,6 +0,0 @@
<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>

View file

@ -1 +0,0 @@
<span class="wave-strip__item"><span class="mono">$code</span> $count/$target</span>

View file

@ -1 +0,0 @@
<p class="wave-strip" data-region="waves">Dalga doluluğu: $wave_items</p>