23b2d048c1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
249 lines
11 KiB
Python
Executable File
249 lines
11 KiB
Python
Executable File
#!/usr/bin/python3
|
|
# Экран установки bj при первой загрузке сервера (bj-firstboot) — в том же оформлении, что экран установки
|
|
# системы (bj-install-progress): рамка Bridge&Join, шаги установщика с отметками, полоса хода. Запускает
|
|
# установщик, весь его вывод пишет в журнал (--log) и по этому выводу рисует экран; код возврата — как у
|
|
# установщика. Последний кадр остаётся на экране (сверху) — под ним bj-firstboot выводит учётные записи.
|
|
#
|
|
# bj-firstboot-screen --log /var/log/bj/firstboot-install.log -- bj-installer install --bundle …
|
|
#
|
|
# Установщик с BJ_SCREEN=1 печатает в начале «@@plan шаг|шаг|…», дальше — «[i/n] Шаг готово|ошибка»,
|
|
# строки-подробности с отступом, в конце «bj установлен» с адресом и кодом или «Установка не завершена.».
|
|
# Знаки ✓ ▶ █ ░ ═ — только со шрифтом консоли установщика (BJ_SCREEN_FONT=1), иначе простые символы.
|
|
# Проверка без установки: bj-firstboot-screen --demo <файл вывода установщика> [--html кадр.html]
|
|
|
|
import os
|
|
import re
|
|
import select
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
|
|
FANCY = os.environ.get("BJ_SCREEN_FONT") == "1"
|
|
G = dict(ok="✓", run="▶", wait="·", err="✗", full="█", empty="░", h="═", v="║", tl="╔", tr="╗", bl="╚", br="╝") if FANCY else \
|
|
dict(ok="+", run=">", wait=".", err="x", full="#", empty="-", h="=", v="|", tl="+", tr="+", bl="+", br="+")
|
|
RESET, BOLD, DIM = "\033[0m", "\033[1m", "\033[2m"
|
|
GREEN, CYAN, RED, WHITE, GREY, YELLOW = "\033[32m", "\033[36m", "\033[31m", "\033[97m", "\033[90m", "\033[33m"
|
|
STEP_RE = re.compile(r"\[(\d+)/(\d+)\]\s+(.*?)\s*(готово|ошибка)?\s*$")
|
|
|
|
|
|
class State:
|
|
def __init__(self):
|
|
self.plan, self.status, self.cur = [], {}, 0 # status: номер шага → ok|err
|
|
self.cur_started = time.time()
|
|
self.started = time.time()
|
|
self.caption = ""
|
|
self.done = False
|
|
self.error = ""
|
|
self.urls, self.code = [], ""
|
|
self.buf = ""
|
|
|
|
def feed(self, text):
|
|
self.buf += text
|
|
while "\n" in self.buf:
|
|
line, self.buf = self.buf.split("\n", 1)
|
|
self.line(line)
|
|
m = STEP_RE.search(self.buf) # строка шага без конца — шаг идёт
|
|
if m:
|
|
self.start_step(int(m.group(1)), int(m.group(2)), m.group(3))
|
|
|
|
def start_step(self, i, n, title):
|
|
if not self.plan:
|
|
self.plan = [""] * n
|
|
if i <= len(self.plan) and not self.plan[i - 1]:
|
|
self.plan[i - 1] = title
|
|
if i != self.cur:
|
|
self.cur, self.cur_started, self.caption = i, time.time(), ""
|
|
|
|
def line(self, line):
|
|
s = line.strip()
|
|
if s.startswith("@@plan "):
|
|
self.plan = s[7:].split("|")
|
|
return
|
|
m = STEP_RE.search(line)
|
|
if m:
|
|
i = int(m.group(1))
|
|
self.start_step(i, int(m.group(2)), m.group(3))
|
|
if m.group(4) == "готово":
|
|
self.status[i] = "ok"
|
|
elif m.group(4) == "ошибка":
|
|
self.status[i] = "err"
|
|
return
|
|
if s in ("готово", "ошибка") and self.cur: # отметка шага пришла отдельной строкой
|
|
self.status[self.cur] = "ok" if s == "готово" else "err"
|
|
return
|
|
if s.startswith("Установка не завершена."):
|
|
self.error = s[len("Установка не завершена."):].strip()
|
|
elif s == "bj установлен":
|
|
self.done = True
|
|
elif "/first-run" in s and s.startswith("http"):
|
|
self.urls.append(s)
|
|
elif s.startswith("Одноразовый код первичной настройки:"):
|
|
self.code = s.split(":", 1)[1].strip()
|
|
elif line.startswith(" ") and s and self.cur and not self.done:
|
|
self.caption = s # подробность последнего шага
|
|
|
|
def percent(self):
|
|
n = max(1, len(self.plan))
|
|
k = sum(1 for v in self.status.values() if v == "ok")
|
|
if self.cur and self.cur not in self.status: # текущий шаг — доля по времени, не больше 90 %
|
|
k += min(0.9, (time.time() - self.cur_started) / 60.0)
|
|
return min(100, int(100 * k / n))
|
|
|
|
|
|
def mmss(sec):
|
|
sec = int(max(0, sec))
|
|
return "%d:%02d" % (sec // 60, sec % 60)
|
|
|
|
|
|
def frame(st, cols, rows, final=False):
|
|
W = 70
|
|
inner = W - 2
|
|
lines = []
|
|
|
|
def row(parts=()):
|
|
parts, text = list(parts), ""
|
|
for i, (t, c) in enumerate(parts):
|
|
if len(text) + len(t) > inner:
|
|
parts = parts[:i] + [(t[: inner - len(text)], c)]
|
|
text += parts[-1][0]
|
|
break
|
|
text += t
|
|
lines.append([(G["v"], GREY)] + parts + [(" " * (inner - len(text)), ""), (G["v"], GREY)])
|
|
|
|
lines.append([(G["tl"] + G["h"] * inner + G["tr"], GREY)])
|
|
row()
|
|
row([(" Bridge&Join", BOLD + WHITE)])
|
|
row([(" шлюз, объединяющий системы финансовой организации", DIM)])
|
|
row()
|
|
row([(" Установка bj на сервер", BOLD)])
|
|
row()
|
|
for i, title in enumerate(st.plan, 1):
|
|
title = title or "…"
|
|
stv = st.status.get(i)
|
|
if stv == "ok":
|
|
row([(" " + G["ok"] + " ", GREEN), (title, "")])
|
|
elif stv == "err":
|
|
row([(" " + G["err"] + " ", RED), (title, BOLD + RED)])
|
|
elif i == st.cur and not final:
|
|
t = mmss(time.time() - st.cur_started)
|
|
row([(" " + G["run"] + " ", CYAN), (title, BOLD + CYAN), (" " * max(1, inner - 9 - len(title) - len(t)), ""), (t, DIM)])
|
|
else:
|
|
row([(" " + G["wait"] + " ", GREY), (title, GREY)])
|
|
row()
|
|
if st.error or (final and not st.done):
|
|
row([(" " + G["err"] + " Установка не завершена.", BOLD + RED)])
|
|
for chunk in wrap(st.error or "подробности — в журнале установки", inner - 6):
|
|
row([(" " + chunk, "")])
|
|
elif st.done:
|
|
row([(" " + G["ok"] + " bj установлен", BOLD + GREEN)])
|
|
row()
|
|
row([(" Откройте в браузере на своём компьютере:", "")])
|
|
for u in st.urls[:3]:
|
|
row([(" " + u, BOLD + WHITE)])
|
|
if st.code:
|
|
row([(" Одноразовый код первичной настройки: ", ""), (st.code, BOLD + YELLOW)])
|
|
row()
|
|
row([(" Браузер предупредит о сертификате — это сертификат самого", DIM)])
|
|
row([(" сервера bj: подтвердите исключение.", DIM)])
|
|
else:
|
|
pct = st.percent()
|
|
bar_w = 48
|
|
full = int(bar_w * pct / 100)
|
|
row([(" ", ""), (G["full"] * full, CYAN), (G["empty"] * (bar_w - full), GREY), (" %3d %%" % pct, BOLD)])
|
|
row([(" " + st.caption, DIM)] if st.caption else [])
|
|
row()
|
|
row([(" Не выключайте сервер: обычно это занимает несколько минут.", "")])
|
|
row()
|
|
lines.append([(G["bl"] + G["h"] * inner + G["br"], GREY)])
|
|
foot = "прошло " + mmss(time.time() - st.started)
|
|
lines.append([(" " * max(0, (W - len(foot)) // 2) + foot, GREY)])
|
|
left = max(0, (cols - W) // 2)
|
|
top = 1 if final else max(0, (rows - len(lines)) // 2) # итог — сверху: ниже bj-firstboot выведет учётные записи
|
|
return [[("", "")]] * top + [[(" " * left, "")] + ln for ln in lines]
|
|
|
|
|
|
def wrap(text, width):
|
|
out, cur = [], ""
|
|
for w in text.split():
|
|
if cur and len(cur) + 1 + len(w) > width:
|
|
out.append(cur)
|
|
cur = w
|
|
else:
|
|
cur = (cur + " " + w).strip()
|
|
if cur:
|
|
out.append(cur)
|
|
return out[:4] or [""]
|
|
|
|
|
|
def to_ansi(fr):
|
|
return "\033[H\033[2J" + "".join("".join((c + t + RESET) if c else t for t, c in ln) + "\n" for ln in fr)
|
|
|
|
|
|
def to_html(fr):
|
|
css = {GREEN: "#4e9a06", CYAN: "#06989a", RED: "#cc0000", GREY: "#888", DIM: "#9a9a9a", WHITE: "#fff", YELLOW: "#c4a000"}
|
|
rows = []
|
|
for ln in fr:
|
|
h = ""
|
|
for t, c in ln:
|
|
t = t.replace("&", "&").replace("<", "<")
|
|
if not c:
|
|
h += t
|
|
continue
|
|
color = next((css[k] for k in (GREEN, CYAN, RED, GREY, WHITE, YELLOW, DIM) if k in c), "#ddd")
|
|
h += '<span style="color:%s;font-weight:%s">%s</span>' % (color, "700" if BOLD in c else "400", t)
|
|
rows.append(h)
|
|
return ('<!doctype html><meta charset="utf-8"><body style="margin:0;background:#000"><pre style="margin:0;padding:8px;'
|
|
'font:16px/1 \'DejaVu Sans Mono\',monospace;color:#ddd;background:#000">' + "\n".join(rows) + "</pre>")
|
|
|
|
|
|
def main():
|
|
a = sys.argv[1:]
|
|
if len(a) >= 2 and a[0] == "--demo":
|
|
st = State()
|
|
st.feed(open(a[1], encoding="utf-8").read())
|
|
st.started -= 95
|
|
st.cur_started -= 24
|
|
fr = frame(st, 100, 40, final=st.done or bool(st.error))
|
|
if len(a) >= 4 and a[2] == "--html":
|
|
open(a[3], "w", encoding="utf-8").write(to_html(fr))
|
|
else:
|
|
sys.stdout.write(to_ansi(fr))
|
|
return 0
|
|
if len(a) < 4 or a[0] != "--log" or a[2] != "--":
|
|
sys.stderr.write("bj-firstboot-screen --log ФАЙЛ -- команда …\n")
|
|
return 2
|
|
log_path, cmd = a[1], a[3:]
|
|
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
|
log = open(log_path, "ab", buffering=0)
|
|
log.write(("\n==== %s: %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), " ".join(cmd))).encode())
|
|
env = dict(os.environ, BJ_SCREEN="1", NO_COLOR="1")
|
|
p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, env=env, bufsize=0)
|
|
st = State()
|
|
sys.stdout.write("\033[?25l")
|
|
try:
|
|
while True:
|
|
r, _, _ = select.select([p.stdout], [], [], 1.0)
|
|
if r:
|
|
chunk = os.read(p.stdout.fileno(), 65536)
|
|
if not chunk:
|
|
break
|
|
log.write(chunk)
|
|
st.feed(chunk.decode("utf-8", "replace"))
|
|
cols, rows = shutil.get_terminal_size((100, 40))
|
|
sys.stdout.write(to_ansi(frame(st, cols, rows)))
|
|
sys.stdout.flush()
|
|
code = p.wait()
|
|
if code != 0 and not st.error:
|
|
st.error = "установщик завершился с кодом %d" % code
|
|
cols, rows = shutil.get_terminal_size((100, 40))
|
|
sys.stdout.write(to_ansi(frame(st, cols, rows, final=True)))
|
|
return code
|
|
finally:
|
|
sys.stdout.write("\033[?25h")
|
|
sys.stdout.flush()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|