From 6ef321dc9f660745d0cbb2b2ef0ff102c67c3b16 Mon Sep 17 00:00:00 2001 From: h Date: Mon, 12 Oct 2026 23:50:09 +0200 Subject: [PATCH] feat(ui): emoji icons on menu buttons and screen titles --- src/healthbot/alerts.py | 12 ++--- src/healthbot/digest.py | 10 ++-- src/healthbot/handlers/actions.py | 73 +++++++++++++------------ src/healthbot/handlers/add.py | 26 ++++----- src/healthbot/handlers/fleet.py | 83 ++++++++++++++++------------- src/healthbot/handlers/menu.py | 10 ++-- src/healthbot/handlers/runs.py | 2 +- src/healthbot/handlers/services.py | 8 +-- src/healthbot/handlers/settings.py | 36 ++++++------- src/healthbot/keyboards/__init__.py | 66 +++++++++++++---------- src/healthbot/monitor.py | 8 +-- src/healthbot/runs.py | 4 +- src/healthbot/ui.py | 8 ++- 13 files changed, 187 insertions(+), 159 deletions(-) diff --git a/src/healthbot/alerts.py b/src/healthbot/alerts.py index 9e5f561..2383c95 100644 --- a/src/healthbot/alerts.py +++ b/src/healthbot/alerts.py @@ -178,17 +178,17 @@ def alert_keyboard(payload: dict) -> InlineKeyboardMarkup | None: kb = InlineKeyboardBuilder() if env.komodo.enabled and target_id: if target_type == "Stack": - kb.button(text="Стек", callback_data=f"go:stack:{target_id}") - kb.button(text="Логи", callback_data=f"go:log:{target_id}") + kb.button(text="📦 Стек", callback_data=f"go:stack:{target_id}") + kb.button(text="📜 Логи", callback_data=f"go:log:{target_id}") elif target_type == "Server": - kb.button(text="Сервер", callback_data=f"go:srv:{target_id}") + kb.button(text="🖥 Сервер", callback_data=f"go:srv:{target_id}") elif target_type == "Action": - kb.button(text="Действие", callback_data=f"go:act:a:{target_id}") + kb.button(text="🎛 Действие", callback_data=f"go:act:a:{target_id}") elif target_type == "Procedure": - kb.button(text="Процедура", callback_data=f"go:act:p:{target_id}") + kb.button(text="🧩 Процедура", callback_data=f"go:act:p:{target_id}") mute = f"al:mute:{kind}" if kind != "Test" and len(mute) <= CALLBACK_LIMIT: - kb.button(text="Не присылать такие", callback_data=mute) + kb.button(text="🔕 Не присылать такие", callback_data=mute) buttons = len(list(kb.buttons)) if not buttons: return None diff --git a/src/healthbot/digest.py b/src/healthbot/digest.py index c9dc289..01f160a 100644 --- a/src/healthbot/digest.py +++ b/src/healthbot/digest.py @@ -38,10 +38,10 @@ async def _services_block() -> list[str]: uptime = (window - lost) / window * 100 if window else None falls = sum(row[2] for row in rows) if not falls: - return [f"Сервисы: аптайм {pct(uptime)}, падений не было"] + return [f"📋 Сервисы: аптайм {pct(uptime)}, падений не было"] word = plural(falls, "падение", "падения", "падений") lines = [ - f"Сервисы: аптайм {pct(uptime)}, {falls} {word}, " + f"📋 Сервисы: аптайм {pct(uptime)}, {falls} {word}, " f"даунтайм {human_duration(lost)}" ] for down, service, count in sorted(rows, key=lambda row: -row[0])[:SHOWN]: @@ -71,10 +71,10 @@ async def _fleet_block() -> list[str]: komodo.names(), ) except KomodoError as exc: - return [f"Флот: {esc(exc)}"] + return [f"🛰 Флот: {esc(exc)}"] tz = db.settings.get("tz") failed = [u for u in updates if u.done and not u.success] - lines = [f"Флот: операций {len(updates)}, неудачных {len(failed)}"] + lines = [f"🛰 Флот: операций {len(updates)}, неудачных {len(failed)}"] grouped: dict[tuple[str, str], list[Update]] = {} for update in failed: grouped.setdefault((update.operation, update.target_id), []).append(update) @@ -93,7 +93,7 @@ async def digest_text() -> str: blocks = [await _services_block()] if env.komodo.enabled: blocks.append(await _fleet_block()) - lines = ["Сводка за сутки"] + lines = ["🗞 Сводка за сутки"] for block in blocks: if block: lines += ["", *block] diff --git a/src/healthbot/handlers/actions.py b/src/healthbot/handlers/actions.py index 10eb4b6..4c88d42 100644 --- a/src/healthbot/handlers/actions.py +++ b/src/healthbot/handlers/actions.py @@ -19,7 +19,7 @@ from healthbot.actionargs import ( ) from healthbot.filters import Admin from healthbot.handlers.menu import sync_commands -from healthbot.keyboards import back_to, back_to_menu +from healthbot.keyboards import back_to, back_to_menu, pin_text from healthbot.komodo import Detail, KomodoError, Resource, Update, komodo from healthbot.runs import track from healthbot.ui import ( @@ -47,7 +47,8 @@ WIDE_FORM = 6 MAX_LABEL = 40 KIND_TITLE = {"a": "Действия", "p": "Процедуры"} KIND_ONE = {"a": "Действие", "p": "Процедура"} -KIND_BACK = {"a": "К действию", "p": "К процедуре"} +KIND_BACK = {"a": "⬅️ К действию", "p": "⬅️ К процедуре"} +KIND_ICON = {"a": "🎛", "p": "🧩"} USAGE_FIELDS = 3 RUN_STATE = {"Ok": "успешно", "Failed": "с ошибкой", "Running": "идёт сейчас"} MODE_TEXT = {PinMode.INSTANT: "запускается сразу", PinMode.FORM: "открывает форму"} @@ -96,12 +97,13 @@ async def list_view(kind: str, page: int) -> tuple[str, types.InlineKeyboardMark rows = [2] * (len(chunk) // 2) + [1] * (len(chunk) % 2) nav = pager(kb, f"act:list:{kind}", page, total) kb.button( - text=f"{KIND_TITLE[other]}: {other_count}", callback_data=f"act:list:{other}:0" + text=f"{KIND_ICON[other]} {KIND_TITLE[other]}: {other_count}", + callback_data=f"act:list:{other}:0", ) - kb.button(text="Закреплённые", callback_data="set:pins") - kb.button(text="Меню", callback_data="menu:main") + kb.button(text="📌 Закреплённые", callback_data="set:pins") + kb.button(text="🏠 Меню", callback_data="menu:main") kb.adjust(*rows, *([nav] if nav else []), 2, 1) - text = f"{KIND_TITLE[kind]}: {len(items)}" + text = f"{KIND_ICON[kind]} {KIND_TITLE[kind]}: {len(items)}" if not items: text += "\nКлюч бота не видит ни одного. Нужен доступ Execute в Komodo." return text, kb.as_markup() @@ -170,7 +172,7 @@ async def card_view( await state.set_state(None) await state.update_data(form=form) - lines = [f"{esc(detail.name)}"] + lines = [f"{KIND_ICON[kind]} {esc(detail.name)}"] if detail.description: lines.append(esc(detail.description)) if detail.stages: @@ -195,18 +197,18 @@ async def card_view( for index, key in enumerate(defaults): value = form["values"].get(key, defaults[key]) kb.button( - text=label(f"{key}: {render(value)}", 40), + text=label(f"✏️ {key}: {render(value)}", 40), callback_data=f"act:f:{index}:{ref}", ) width = 2 if len(defaults) > WIDE_FORM else 1 rows = [width] * (len(defaults) // width) + [1] * (len(defaults) % width) - kb.button(text="Запустить", callback_data=f"act:run:{ref}", style=SUCCESS) - kb.button(text="Закрепить", callback_data=f"act:pin:{ref}") + kb.button(text="▶️ Запустить", callback_data=f"act:run:{ref}", style=SUCCESS) + kb.button(text="📌 Закрепить", callback_data=f"act:pin:{ref}") tail = 2 if form["values"]: - kb.button(text="Сбросить", callback_data=f"act:reset:{ref}") + kb.button(text="↩️ Сбросить", callback_data=f"act:reset:{ref}") tail = 3 - kb.button(text="К списку", callback_data=f"act:list:{kind}:0") + kb.button(text="⬅️ К списку", callback_data=f"act:list:{kind}:0") kb.adjust(*rows, tail, 1) return "\n".join(lines), kb.as_markup() @@ -341,10 +343,10 @@ async def act_field(callback: types.CallbackQuery, state: FSMContext) -> None: kb = InlineKeyboardBuilder() if key in form["values"]: kb.button( - text=label(f"Вернуть {render(default)}", 40), + text=label(f"↩️ Вернуть {render(default)}", 40), callback_data=f"act:fd:{_ref(form)}", ) - kb.button(text="Отмена", callback_data=f"act:back:{_ref(form)}") + kb.button(text="✖️ Отмена", callback_data=f"act:back:{_ref(form)}") kb.adjust(1) await show( callback, @@ -380,7 +382,7 @@ async def on_value(message: types.Message, state: FSMContext) -> None: await show( message, f"{esc(key)}: {esc(exc)}. Пришли ещё раз.", - back_to("Отмена", f"act:back:{_ref(form)}"), + back_to("✖️ Отмена", f"act:back:{_ref(form)}"), ) return except KomodoError as exc: @@ -413,7 +415,10 @@ async def _execute( # noqa: PLR0913 origin, title or name, starter, - back=[(KIND_BACK[kind], f"act:open:{kind}:{target_id}"), ("Меню", "menu:main")], + back=[ + (KIND_BACK[kind], f"act:open:{kind}:{target_id}"), + ("🏠 Меню", "menu:main"), + ], verbose=kind == "a", extra=_args_code(args), new=new, @@ -430,8 +435,8 @@ async def act_run(callback: types.CallbackQuery, state: FSMContext) -> None: await _execute(callback, form["kind"], form["id"], form["name"], form["values"]) return kb = InlineKeyboardBuilder() - kb.button(text="Запустить", callback_data=f"act:go:{_ref(form)}", style=SUCCESS) - kb.button(text="Отмена", callback_data=f"act:back:{_ref(form)}") + kb.button(text="▶️ Запустить", callback_data=f"act:go:{_ref(form)}", style=SUCCESS) + kb.button(text="✖️ Отмена", callback_data=f"act:back:{_ref(form)}") kb.adjust(2) changes = f"\nИзменено: {_values_line(form['values'])}" if form["values"] else "" await show( @@ -461,9 +466,9 @@ async def act_pin(callback: types.CallbackQuery, state: FSMContext) -> None: ) kb = InlineKeyboardBuilder() ref = _ref(form) - kb.button(text="Запускать сразу", callback_data=f"act:pinmode:instant:{ref}") - kb.button(text="Открывать форму", callback_data=f"act:pinmode:form:{ref}") - kb.button(text="Отмена", callback_data=f"act:back:{ref}") + kb.button(text="⚡ Запускать сразу", callback_data=f"act:pinmode:instant:{ref}") + kb.button(text="📝 Открывать форму", callback_data=f"act:pinmode:form:{ref}") + kb.button(text="✖️ Отмена", callback_data=f"act:back:{ref}") kb.adjust(2, 1) await show( callback, @@ -486,17 +491,17 @@ def pin_view(pin: Pin) -> tuple[str, types.InlineKeyboardMarkup]: values, ] kb = InlineKeyboardBuilder() - kb.button(text="Переименовать", callback_data=f"pin:ren:{pin.id}") + kb.button(text="✏️ Переименовать", callback_data=f"pin:ren:{pin.id}") other = PinMode.FORM if pin.mode is PinMode.INSTANT else PinMode.INSTANT kb.button( - text="Открывать форму" if other is PinMode.FORM else "Запускать сразу", + text="📝 Открывать форму" if other is PinMode.FORM else "⚡ Запускать сразу", callback_data=f"pin:mode:{pin.id}", ) - kb.button(text="Выше", callback_data=f"pin:up:{pin.id}") - kb.button(text="Ниже", callback_data=f"pin:down:{pin.id}") - kb.button(text="Открепить", callback_data=f"pin:del:{pin.id}", style=DANGER) - kb.button(text="Закреплённые", callback_data="set:pins") - kb.button(text="Меню", callback_data="menu:main") + kb.button(text="⬆️ Выше", callback_data=f"pin:up:{pin.id}") + kb.button(text="⬇️ Ниже", callback_data=f"pin:down:{pin.id}") + kb.button(text="🗑 Открепить", callback_data=f"pin:del:{pin.id}", style=DANGER) + kb.button(text="📌 Закреплённые", callback_data="set:pins") + kb.button(text="🏠 Меню", callback_data="menu:main") kb.adjust(2, 2, 1, 2) return "\n".join(lines), kb.as_markup() @@ -527,12 +532,12 @@ async def pins_view() -> tuple[str, types.InlineKeyboardMarkup]: pins = await db.pins.all() kb = InlineKeyboardBuilder() for pin in pins: - kb.button(text=label(pin.label, 40), callback_data=f"pin:open:{pin.id}") + kb.button(text=label(pin_text(pin), 40), callback_data=f"pin:open:{pin.id}") if enabled(): - kb.button(text="Действия", callback_data="act:list:a:0") - kb.button(text="Меню", callback_data="menu:main") + kb.button(text="🎛 Действия", callback_data="act:list:a:0") + kb.button(text="🏠 Меню", callback_data="menu:main") kb.adjust(*[1] * len(pins), 2) - text = "Закреплённые" + text = "📌 Закреплённые" if not pins: text += "\nПока пусто. Открой действие и нажми «Закрепить»." return text, kb.as_markup() @@ -630,7 +635,7 @@ async def pin_rename(callback: types.CallbackQuery, state: FSMContext) -> None: await show( callback, f"Пришли новое название для кнопки {esc(pin.label)}.", - back_to("Отмена", f"pin:open:{pin.id}"), + back_to("✖️ Отмена", f"pin:open:{pin.id}"), ) await callback.answer() @@ -685,7 +690,7 @@ async def on_command(message: types.Message, state: FSMContext) -> None: await show( message, "Запуск действий командами выключен.", - back_to("Настройки", "set:main"), + back_to("⚙️ Настройки", "set:main"), ) return if resource is None and not (enabled() and db.settings.get("commands")): diff --git a/src/healthbot/handlers/add.py b/src/healthbot/handlers/add.py index 2ff1a2a..ef91515 100644 --- a/src/healthbot/handlers/add.py +++ b/src/healthbot/handlers/add.py @@ -134,12 +134,12 @@ def _choose_view(data: dict) -> tuple[str, types.InlineKeyboardMarkup]: lines.append("За чем следить?") if path: - kb.button(text="Назад", callback_data="add:up") + kb.button(text="⬆️ Назад", callback_data="add:up") rows.append(1) - kb.button(text=f"HTTP-статус {data['status']}", callback_data="add:status") - kb.button(text="Текст в ответе", callback_data="add:text") - kb.button(text="Тело целиком", callback_data="add:body") - kb.button(text="Отмена", callback_data="add:cancel") + kb.button(text=f"🔢 HTTP-статус {data['status']}", callback_data="add:status") + kb.button(text="🔤 Текст в ответе", callback_data="add:text") + kb.button(text="📄 Тело целиком", callback_data="add:body") + kb.button(text="✖️ Отмена", callback_data="add:cancel") kb.adjust(*rows, 2, 2) return "\n".join(lines), kb.as_markup() @@ -151,10 +151,10 @@ async def _probe(url: str) -> Probe: def _unreachable_kb(*, editing: bool) -> types.InlineKeyboardMarkup: kb = InlineKeyboardBuilder() - kb.button(text="Повторить", callback_data="add:retry") + kb.button(text="🔄 Повторить", callback_data="add:retry") if not editing: - kb.button(text="Добавить всё равно", callback_data="add:force") - kb.button(text="Отмена", callback_data="add:cancel") + kb.button(text="➕ Добавить всё равно", callback_data="add:force") + kb.button(text="✖️ Отмена", callback_data="add:cancel") kb.adjust(2, 1) return kb.as_markup() @@ -227,7 +227,7 @@ async def _add_many(message: types.Message, urls: list[str]) -> None: lines = [f"Добавил {len(added)} из {len(urls)}, слежу за HTTP-статусом.", *added] if skipped: lines += ["", "Пропустил:", *skipped] - await show(message, "\n".join(lines), back_to("Сервисы", "menu:list:0")) + await show(message, "\n".join(lines), back_to("📋 Сервисы", "menu:list:0")) async def _on_urls(message: types.Message, state: FSMContext, urls: list[str]) -> None: @@ -265,7 +265,7 @@ async def on_new_url(message: types.Message, state: FSMContext) -> None: await show(message, "Сервис уже удалён.", back_to_menu()) return urls = extract_urls(message.text) - back = back_to("Отмена", f"svc:open:{service.id}") + back = back_to("✖️ Отмена", f"svc:open:{service.id}") if urls is None or len(urls) != 1: await show(message, "Нужна одна ссылка.", back) return @@ -292,7 +292,7 @@ async def svc_url(callback: types.CallbackQuery, state: FSMContext) -> None: callback, f"Пришли новый адрес для {esc(service.name)}.\n" f"Сейчас: {esc(service.url)}", - back_to("Отмена", f"svc:open:{service.id}"), + back_to("✖️ Отмена", f"svc:open:{service.id}"), ) await callback.answer() @@ -372,7 +372,7 @@ async def on_pick_text(callback: types.CallbackQuery, state: FSMContext) -> None await show( callback, "Пришли текст, который должен быть в ответе.", - back_to("Отмена", "add:cancel"), + back_to("✖️ Отмена", "add:cancel"), ) await callback.answer() @@ -385,7 +385,7 @@ async def on_needle(message: types.Message, state: FSMContext) -> None: await show( message, "Сейчас такого текста в ответе нет. Пришли другой.", - back_to("Отмена", "add:cancel"), + back_to("✖️ Отмена", "add:cancel"), ) return await _save(message, state, match_type=MatchType.CONTAINS, needle=needle) diff --git a/src/healthbot/handlers/fleet.py b/src/healthbot/handlers/fleet.py index b57dba9..f0bce69 100644 --- a/src/healthbot/handlers/fleet.py +++ b/src/healthbot/handlers/fleet.py @@ -39,6 +39,7 @@ OPS: dict[str, tuple[str, str, str | None]] = { "sto": ("Stop", "StopStack", DANGER), "sta": ("Start", "StartStack", None), } +OP_ICONS = {"dep": "🚀", "res": "🔁", "sto": "⏹", "sta": "▶️"} async def _guard(callback: types.CallbackQuery) -> bool: @@ -71,7 +72,7 @@ async def fleet_view() -> tuple[str, types.InlineKeyboardMarkup]: komodo.servers(), komodo.stacks(), komodo.open_alerts() ) servers.sort(key=lambda s: s.name) - lines = [f"Флот: серверов {len(servers)}, стеков {len(stacks)}", ""] + lines = [f"🛰 Флот: серверов {len(servers)}, стеков {len(stacks)}", ""] kb = InlineKeyboardBuilder() for server in servers: mine = [s for s in stacks if s.server_id == server.id] @@ -95,14 +96,14 @@ async def fleet_view() -> tuple[str, types.InlineKeyboardMarkup]: broken = [s for s in stacks if not s.healthy] extra = 0 if broken: - kb.button(text=f"Не работают: {len(broken)}", callback_data="fleet:bad") + kb.button(text=f"⚠️ Не работают: {len(broken)}", callback_data="fleet:bad") extra += 1 if alerts: - kb.button(text=f"Алерты: {len(alerts)}", callback_data="fleet:alerts") + kb.button(text=f"🔔 Алерты: {len(alerts)}", callback_data="fleet:alerts") extra += 1 - kb.button(text="Журнал", callback_data="fleet:journal") - kb.button(text="Обновить", callback_data="fleet:main") - kb.button(text="Меню", callback_data="menu:main") + kb.button(text="🕘 Журнал", callback_data="fleet:journal") + kb.button(text="🔄 Обновить", callback_data="fleet:main") + kb.button(text="🏠 Меню", callback_data="menu:main") kb.adjust(*rows, *([extra] if extra else []), 3) return "\n".join(lines), kb.as_markup() @@ -122,7 +123,7 @@ async def server_view( servers, stacks = await asyncio.gather(komodo.servers(), komodo.stacks()) server = next((s for s in servers if s.id == server_id), None) if server is None: - return "Сервер не найден.", back_to("К флоту", "fleet:main") + return "Сервер не найден.", back_to("⬅️ К флоту", "fleet:main") lines = [f"{server.mark} {esc(server.name)}"] if server.state == "NotOk": @@ -161,8 +162,8 @@ async def server_view( kb = InlineKeyboardBuilder() rows = _stack_buttons(kb, mine[page * STACKS_PAGE : (page + 1) * STACKS_PAGE]) nav = pager(kb, f"fleet:srv:{server_id}", page, total_pages) - kb.button(text="Обновить", callback_data=f"fleet:srv:{server_id}:{page}") - kb.button(text="К флоту", callback_data="fleet:main") + kb.button(text="🔄 Обновить", callback_data=f"fleet:srv:{server_id}:{page}") + kb.button(text="⬅️ К флоту", callback_data="fleet:main") kb.adjust(*rows, *([nav] if nav else []), 2) return "\n".join(lines), kb.as_markup() @@ -170,7 +171,9 @@ async def server_view( async def stack_view(stack_id: str) -> tuple[str, types.InlineKeyboardMarkup]: stack = await komodo.stack(stack_id) if stack is None: - return "Стек не найден, возможно его удалили.", back_to("К флоту", "fleet:main") + return "Стек не найден, возможно его удалили.", back_to( + "⬅️ К флоту", "fleet:main" + ) lines = [ f"{stack.mark} {esc(stack.name)}", @@ -202,12 +205,16 @@ async def stack_view(stack_id: str) -> tuple[str, types.InlineKeyboardMarkup]: shown = ("dep", "res", "sto") if stack.state != "down" else ("dep", "sta") for key in shown: text, _, style = OPS[key] - kb.button(text=text, callback_data=f"fleet:ask:{key}:{stack.id}", style=style) - kb.button(text="Логи", callback_data=f"fleet:logs:{stack.id}") - kb.button(text="История", callback_data=f"fleet:hist:{stack.id}") - kb.button(text="Обновить", callback_data=f"fleet:stack:{stack.id}") + kb.button( + text=f"{OP_ICONS[key]} {text}", + callback_data=f"fleet:ask:{key}:{stack.id}", + style=style, + ) + kb.button(text="📜 Логи", callback_data=f"fleet:logs:{stack.id}") + kb.button(text="🕘 История", callback_data=f"fleet:hist:{stack.id}") + kb.button(text="🔄 Обновить", callback_data=f"fleet:stack:{stack.id}") kb.button( - text="К серверу" if stack.server_id else "К флоту", + text="⬅️ К серверу" if stack.server_id else "⬅️ К флоту", callback_data=f"fleet:srv:{stack.server_id}:0" if stack.server_id else "fleet:main", @@ -296,14 +303,14 @@ async def _bad_view() -> tuple[str, types.InlineKeyboardMarkup]: (s for s in await komodo.stacks() if not s.healthy), key=lambda s: (s.server_name, s.name), ) - lines = ["Не работают", ""] + lines = ["⚠️ Не работают", ""] lines.extend( f"{s.mark} {esc(s.name)} · {esc(s.server_name)} · {esc(s.state)}" for s in broken ) kb = InlineKeyboardBuilder() rows = _stack_buttons(kb, broken[:40]) - kb.button(text="К флоту", callback_data="fleet:main") + kb.button(text="⬅️ К флоту", callback_data="fleet:main") kb.adjust(*rows, 1) return "\n".join(lines), kb.as_markup() @@ -318,13 +325,13 @@ async def fleet_bad(callback: types.CallbackQuery) -> None: async def _alerts_view() -> tuple[str, types.InlineKeyboardMarkup]: tz = db.settings.get("tz") alerts = await komodo.open_alerts() - lines = ["Открытые алерты"] + lines = ["🔔 Открытые алерты"] if not alerts: lines.append("Сейчас ни одного.") for alert in alerts[:10]: since = stamp(from_ms(alert.get("ts") or 0), tz) lines += ["", format_alert(alert), f"с {since}"] - return "\n".join(lines), back_to("К флоту", "fleet:main") + return "\n".join(lines), back_to("⬅️ К флоту", "fleet:main") @router.callback_query(F.data == "fleet:alerts") @@ -350,9 +357,11 @@ async def fleet_ask(callback: types.CallbackQuery) -> None: return kb = InlineKeyboardBuilder() kb.button( - text=text, callback_data=f"fleet:do:{key}:{stack_id}", style=style or PRIMARY + text=f"{OP_ICONS[key]} {text}", + callback_data=f"fleet:do:{key}:{stack_id}", + style=style or PRIMARY, ) - kb.button(text="Отмена", callback_data=f"fleet:stack:{stack_id}") + kb.button(text="✖️ Отмена", callback_data=f"fleet:stack:{stack_id}") kb.adjust(2) await show( callback, @@ -384,8 +393,8 @@ async def fleet_do(callback: types.CallbackQuery) -> None: f"{text} {name}", starter, back=[ - ("К стеку", f"fleet:stack:{stack_id}"), - ("Логи", f"fleet:logs:{stack_id}"), + ("⬅️ К стеку", f"fleet:stack:{stack_id}"), + ("📜 Логи", f"fleet:logs:{stack_id}"), ], verbose=False, ) @@ -402,7 +411,7 @@ async def _log_view( ) -> tuple[str, types.InlineKeyboardMarkup]: stack = await komodo.stack(stack_id) if stack is None: - return "Стек не найден.", back_to("К флоту", "fleet:main") + return "Стек не найден.", back_to("⬅️ К флоту", "fleet:main") services = _picked(stack, index) log = await komodo.stack_log(stack_id, services, LOG_TAIL) if services or len(stack.services) == 1: @@ -410,15 +419,15 @@ async def _log_view( scope = f" · {esc(services[0])}" if services else "" body = pre(log, LOG_CHARS) if log else "лог пуст" kb = InlineKeyboardBuilder() - kb.button(text="Обновить", callback_data=f"fleet:log:{stack_id}:{index}") - kb.button(text="Файлом", callback_data=f"fleet:logf:{stack_id}:{index}") + kb.button(text="🔄 Обновить", callback_data=f"fleet:log:{stack_id}:{index}") + kb.button(text="📎 Файлом", callback_data=f"fleet:logf:{stack_id}:{index}") rows = [2] if len(stack.services) > 1: - kb.button(text="Другой сервис", callback_data=f"fleet:logs:{stack_id}") - kb.button(text="К стеку", callback_data=f"fleet:stack:{stack_id}") + kb.button(text="🔀 Другой сервис", callback_data=f"fleet:logs:{stack_id}") + kb.button(text="⬅️ К стеку", callback_data=f"fleet:stack:{stack_id}") rows.append(2) else: - kb.button(text="К стеку", callback_data=f"fleet:stack:{stack_id}") + kb.button(text="⬅️ К стеку", callback_data=f"fleet:stack:{stack_id}") rows.append(1) kb.adjust(*rows) return f"{esc(stack.name)}{scope}\n{body}", kb.as_markup() @@ -427,7 +436,7 @@ async def _log_view( async def _log_picker(stack_id: str) -> tuple[str, types.InlineKeyboardMarkup]: stack = await komodo.stack(stack_id) if stack is None: - return "Стек не найден.", back_to("К флоту", "fleet:main") + return "Стек не найден.", back_to("⬅️ К флоту", "fleet:main") if len(stack.services) <= 1: return await _log_view(stack_id, ALL_SERVICES) kb = InlineKeyboardBuilder() @@ -435,8 +444,10 @@ async def _log_picker(stack_id: str) -> tuple[str, types.InlineKeyboardMarkup]: kb.button( text=label(service, 28), callback_data=f"fleet:log:{stack_id}:{index}" ) - kb.button(text="Все сервисы", callback_data=f"fleet:log:{stack_id}:{ALL_SERVICES}") - kb.button(text="К стеку", callback_data=f"fleet:stack:{stack_id}") + kb.button( + text="🗂 Все сервисы", callback_data=f"fleet:log:{stack_id}:{ALL_SERVICES}" + ) + kb.button(text="⬅️ К стеку", callback_data=f"fleet:stack:{stack_id}") count = len(stack.services) kb.adjust(*[2] * (count // 2), *[1] * (count % 2), 2) return f"{esc(stack.name)}: чьи логи?", kb.as_markup() @@ -559,7 +570,7 @@ async def _history_view(stack_id: str) -> tuple[str, types.InlineKeyboardMarkup] title = esc(stack.name) if stack else "Стек" kb = InlineKeyboardBuilder() count = _failed_buttons(kb, failed) - kb.button(text="К стеку", callback_data=f"fleet:stack:{stack_id}") + kb.button(text="⬅️ К стеку", callback_data=f"fleet:stack:{stack_id}") kb.adjust(*[1] * count, 1) body = "\n".join(lines) if lines else "Операций ещё не было." return f"{title}: операции\n\n{body}", kb.as_markup() @@ -578,11 +589,11 @@ async def _journal_view() -> tuple[str, types.InlineKeyboardMarkup]: lines, failed = await _update_lines(updates, JOURNAL, with_target=True) kb = InlineKeyboardBuilder() count = _failed_buttons(kb, failed) - kb.button(text="Обновить", callback_data="fleet:journal") - kb.button(text="К флоту", callback_data="fleet:main") + kb.button(text="🔄 Обновить", callback_data="fleet:journal") + kb.button(text="⬅️ К флоту", callback_data="fleet:main") kb.adjust(*[1] * count, 2) body = "\n".join(lines) if lines else "Операций ещё не было." - return f"Журнал\n\n{body}", kb.as_markup() + return f"🕘 Журнал\n\n{body}", kb.as_markup() @router.callback_query(F.data == "fleet:journal") diff --git a/src/healthbot/handlers/menu.py b/src/healthbot/handlers/menu.py index d265213..5311732 100644 --- a/src/healthbot/handlers/menu.py +++ b/src/healthbot/handlers/menu.py @@ -78,13 +78,13 @@ async def _services_block() -> list[str]: works = plural(working, "работает", "работают", "работают") if not down and not paused: every = "все " if working > 1 else "" - return [f"Сервисы: {every}{working} {works}"] + return [f"📋 Сервисы: {every}{working} {works}"] parts = [f"{working} {works}"] if down: parts.append(f"{len(down)} {plural(len(down), 'лежит', 'лежат', 'лежат')}") if paused: parts.append(f"{paused} на паузе") - lines = [f"Сервисы: {', '.join(parts)}"] + lines = [f"📋 Сервисы: {', '.join(parts)}"] lines.extend([await _down_line(s) for s in down[:SHOWN_PROBLEMS]]) return lines @@ -95,11 +95,11 @@ async def _fleet_block() -> list[str]: komodo.servers(), komodo.stacks(), komodo.open_alerts() ) except KomodoError as exc: - return [f"Флот: {esc(exc)}"] + return [f"🛰 Флот: {esc(exc)}"] reachable = {s.id for s in servers if s.state == "Ok"} good = sum(s.healthy for s in stacks) summary = "все работают" if good == len(stacks) else f"работают {good}" - lines = [f"Флот: серверов {len(servers)}, стеков {len(stacks)}, {summary}"] + lines = [f"🛰 Флот: серверов {len(servers)}, стеков {len(stacks)}, {summary}"] for server in servers: if server.state == "NotOk": lines.append(f"🔴 {esc(server.name)} не отвечает") @@ -123,7 +123,7 @@ async def dashboard() -> tuple[str, types.InlineKeyboardMarkup]: try: fleet = await asyncio.wait_for(_fleet_block(), FLEET_TIMEOUT) except TimeoutError: - fleet = ["Флот: Komodo не отвечает"] + fleet = ["🛰 Флот: Komodo не отвечает"] lines += ["", *fleet] pins = await db.pins.all() if actions else [] return "\n".join(lines), main_menu(pins, actions=bool(actions)) diff --git a/src/healthbot/handlers/runs.py b/src/healthbot/handlers/runs.py index 24cf60d..16916c6 100644 --- a/src/healthbot/handlers/runs.py +++ b/src/healthbot/handlers/runs.py @@ -32,7 +32,7 @@ async def run_status(callback: types.CallbackQuery) -> None: await callback.answer() if not update.done: kb = InlineKeyboardBuilder() - kb.button(text="Проверить", callback_data=f"run:st:{update_id}") + kb.button(text="🔍 Проверить", callback_data=f"run:st:{update_id}") await show( callback, f"⏳ {esc(title)} идёт уже {clock(update.seconds)}", diff --git a/src/healthbot/handlers/services.py b/src/healthbot/handlers/services.py index ba611d0..593c502 100644 --- a/src/healthbot/handlers/services.py +++ b/src/healthbot/handlers/services.py @@ -54,7 +54,7 @@ async def list_view(page: int = 0) -> tuple[str, types.InlineKeyboardMarkup]: parts = [f"🟢 {up}", f"🔴 {len(watched) - up}"] if paused: parts.append(f"⏸ {paused}") - return f"Сервисы {' '.join(parts)}", services_list(services, page) + return f"📋 Сервисы {' '.join(parts)}", services_list(services, page) @router.message(Command("list")) @@ -311,7 +311,7 @@ async def svc_incidents(callback: types.CallbackQuery) -> None: total = downtime(incidents, parse(service.created_at), now()) lines += ["", f"Всего {len(incidents)}, даунтайм {human_duration(total)}"] await show( - callback, "\n".join(lines), back_to("К сервису", f"svc:open:{service.id}") + callback, "\n".join(lines), back_to("⬅️ К сервису", f"svc:open:{service.id}") ) await callback.answer() @@ -326,7 +326,7 @@ async def svc_rename(callback: types.CallbackQuery, state: FSMContext) -> None: await show( callback, f"Пришли новое имя для {esc(service.name)}.", - back_to("Отмена", f"svc:open:{service.id}"), + back_to("✖️ Отмена", f"svc:open:{service.id}"), ) await callback.answer() @@ -378,7 +378,7 @@ async def stats_view() -> tuple[str, types.InlineKeyboardMarkup]: rows.append((uptime(service, incidents, month) or 0.0, service, incidents)) rows.sort(key=lambda row: row[0]) - lines = ["Статистика", ""] + lines = ["📊 Статистика", ""] for _, service, incidents in rows: speed = ( f" · {service.last_latency_ms} мс" diff --git a/src/healthbot/handlers/settings.py b/src/healthbot/handlers/settings.py index 2f5c691..85c28e9 100644 --- a/src/healthbot/handlers/settings.py +++ b/src/healthbot/handlers/settings.py @@ -21,9 +21,9 @@ router.message.filter(Admin()) router.callback_query.filter(Admin()) TOGGLES = { - "actions": ("Действия Komodo", "включены", "выключены"), - "commands": ("Действия как команды", "включены", "выключены"), - "confirm": ("Подтверждение запуска", "включено", "выключено"), + "actions": ("🎛 Действия Komodo", "включены", "выключены"), + "commands": ("⌨️ Действия как команды", "включены", "выключены"), + "confirm": ("🛡 Подтверждение запуска", "включено", "выключено"), } DIGEST_HOURS = (7, 8, 9, 10, 12, 18, 21) @@ -43,7 +43,7 @@ async def _komodo_line() -> str: async def settings_view() -> tuple[str, types.InlineKeyboardMarkup]: tz = db.settings.get("tz") - lines = ["Настройки", await _komodo_line()] + lines = ["⚙️ Настройки", await _komodo_line()] kb = InlineKeyboardBuilder() if env.komodo.enabled: for key, (title, on, off) in TOGGLES.items(): @@ -59,14 +59,14 @@ async def settings_view() -> tuple[str, types.InlineKeyboardMarkup]: ) if env.web.enabled: muted = len(db.settings.get("muted")) - kb.button(text=f"Алерты Komodo, скрыто: {muted}", callback_data="set:alerts") - kb.button(text=f"Часовой пояс: {tz}", callback_data="set:tz") + kb.button(text=f"🔔 Алерты Komodo, скрыто: {muted}", callback_data="set:alerts") + kb.button(text=f"🕒 Часовой пояс: {tz}", callback_data="set:tz") hour = db.settings.get("digest") digest = "выключена" if hour is None else f"в {hour:02d}:00" - kb.button(text=f"Сводка за сутки: {digest}", callback_data="set:digest") + kb.button(text=f"🗞 Сводка за сутки: {digest}", callback_data="set:digest") if env.komodo.enabled and db.settings.get("actions"): - kb.button(text="Закреплённые", callback_data="set:pins") - kb.button(text="Меню", callback_data="menu:main") + kb.button(text="📌 Закреплённые", callback_data="set:pins") + kb.button(text="🏠 Меню", callback_data="menu:main") kb.adjust(1) return "\n".join(lines), kb.as_markup() @@ -106,9 +106,9 @@ def _alerts_view() -> tuple[str, types.InlineKeyboardMarkup]: for kind in kinds: mark = "🔕" if kind in muted else "🔔" kb.button(text=f"{mark} {kind}", callback_data=f"set:mute:{kind}") - kb.button(text="К настройкам", callback_data="set:main") + kb.button(text="⬅️ К настройкам", callback_data="set:main") kb.adjust(1) - return "Алерты Komodo", kb.as_markup() + return "🔔 Алерты Komodo", kb.as_markup() @router.callback_query(F.data == "set:alerts") @@ -158,7 +158,7 @@ async def alert_mute(callback: types.CallbackQuery) -> None: kind = tail_arg(callback.data) if kind not in db.settings.get("muted"): await _flip_mute(kind) - await _swap_alert_button(callback, f"{kind} скрыт, вернуть", f"al:show:{kind}") + await _swap_alert_button(callback, f"🔔 {kind} скрыт, вернуть", f"al:show:{kind}") @router.callback_query(F.data.startswith("al:show:")) @@ -166,7 +166,7 @@ async def alert_unmute(callback: types.CallbackQuery) -> None: kind = tail_arg(callback.data) if kind in db.settings.get("muted"): await _flip_mute(kind) - await _swap_alert_button(callback, "Не присылать такие", f"al:mute:{kind}") + await _swap_alert_button(callback, "🔕 Не присылать такие", f"al:mute:{kind}") @router.callback_query(F.data == "set:tz") @@ -178,7 +178,7 @@ async def settings_tz(callback: types.CallbackQuery, state: FSMContext) -> None: callback, "Пришли часовой пояс, например Europe/Warsaw.\n" f"Сейчас: {esc(tz)}, на часах {local}", - back_to("Отмена", "set:main"), + back_to("✖️ Отмена", "set:main"), ) await callback.answer() @@ -193,7 +193,7 @@ async def on_tz(message: types.Message, state: FSMContext) -> None: message, "Такого пояса нет. Нужно имя из базы tz, например " "Europe/Warsaw или UTC.", - back_to("Отмена", "set:main"), + back_to("✖️ Отмена", "set:main"), ) return await state.clear() @@ -207,12 +207,12 @@ async def settings_digest(callback: types.CallbackQuery) -> None: kb = InlineKeyboardBuilder() for hour in DIGEST_HOURS: kb.button(text=f"{hour:02d}:00", callback_data=f"set:dg:{hour}") - kb.button(text="Выключить", callback_data="set:dg:off") - kb.button(text="К настройкам", callback_data="set:main") + kb.button(text="🚫 Выключить", callback_data="set:dg:off") + kb.button(text="⬅️ К настройкам", callback_data="set:main") kb.adjust(4, 3, 2) await show( callback, - "Сводка за сутки\n" + "🗞 Сводка за сутки\n" "Раз в день: аптайм и падения сервисов, операции и алерты флота.\n" f"Время по поясу {esc(db.settings.get('tz'))}.", kb.as_markup(), diff --git a/src/healthbot/keyboards/__init__.py b/src/healthbot/keyboards/__init__.py index 3816729..befadf6 100644 --- a/src/healthbot/keyboards/__init__.py +++ b/src/healthbot/keyboards/__init__.py @@ -1,38 +1,44 @@ from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from aiogram.utils.keyboard import InlineKeyboardBuilder -from healthbot.ui import DANGER, PRIMARY, label, pager, pages -from utils.db.models import Pin, Service +from healthbot.ui import DANGER, PRIMARY, label, pager, pages, with_icon +from utils.db.models import Pin, PinMode, Service from utils.env import env SERVICES_PAGE = 12 +def pin_text(pin: Pin) -> str: + return with_icon(pin.label, "⚡" if pin.mode is PinMode.INSTANT else "📝") + + def main_menu(pins: list[Pin], *, actions: bool) -> InlineKeyboardMarkup: kb = InlineKeyboardBuilder() rows: list[int] = [] for pin in pins: kb.button( - text=label(pin.label, 30), callback_data=f"pin:run:{pin.id}", style=PRIMARY + text=label(pin_text(pin), 30), + callback_data=f"pin:run:{pin.id}", + style=PRIMARY, ) rows += [2] * (len(pins) // 2) + [1] * (len(pins) % 2) - kb.button(text="Сервисы", callback_data="menu:list:0") + kb.button(text="📋 Сервисы", callback_data="menu:list:0") if env.komodo.enabled: - kb.button(text="Флот", callback_data="fleet:main") + kb.button(text="🛰 Флот", callback_data="fleet:main") if actions: - kb.button(text="Действия", callback_data="act:list:a:0") - kb.button(text="Статистика", callback_data="menu:stats") + kb.button(text="🎛 Действия", callback_data="act:list:a:0") + kb.button(text="📊 Статистика", callback_data="menu:stats") rows += [2, 2] else: - kb.button(text="Статистика", callback_data="menu:stats") + kb.button(text="📊 Статистика", callback_data="menu:stats") rows += [2, 1] else: - kb.button(text="Статистика", callback_data="menu:stats") + kb.button(text="📊 Статистика", callback_data="menu:stats") rows += [2] - kb.button(text="Добавить сервис", callback_data="menu:add") - kb.button(text="Настройки", callback_data="set:main") - kb.button(text="Обновить", callback_data="menu:main") + kb.button(text="➕ Добавить сервис", callback_data="menu:add") + kb.button(text="⚙️ Настройки", callback_data="set:main") + kb.button(text="🔄 Обновить", callback_data="menu:main") kb.adjust(*rows, 2, 1) return kb.as_markup() @@ -60,33 +66,35 @@ def services_list(services: list[Service], page: int) -> InlineKeyboardMarkup: callback_data=f"svc:open:{svc.id}", ) nav = pager(kb, "menu:list", page, total) - kb.button(text="Добавить", callback_data="menu:add") - kb.button(text="Меню", callback_data="menu:main") + kb.button(text="➕ Добавить", callback_data="menu:add") + kb.button(text="🏠 Меню", callback_data="menu:main") kb.adjust(*[1] * len(chunk), *([nav] if nav else []), 2) return kb.as_markup() def service_card(service: Service) -> InlineKeyboardMarkup: kb = InlineKeyboardBuilder() - kb.button(text="Проверить", callback_data=f"svc:check:{service.id}", style=PRIMARY) - kb.button(text="Инциденты", callback_data=f"svc:inc:{service.id}") + kb.button( + text="🔍 Проверить", callback_data=f"svc:check:{service.id}", style=PRIMARY + ) + kb.button(text="🚨 Инциденты", callback_data=f"svc:inc:{service.id}") if service.is_paused: - kb.button(text="Возобновить", callback_data=f"svc:resume:{service.id}") + kb.button(text="▶️ Возобновить", callback_data=f"svc:resume:{service.id}") else: - kb.button(text="Пауза", callback_data=f"svc:pause:{service.id}") - kb.button(text="Правило", callback_data=f"svc:rule:{service.id}") - kb.button(text="Имя", callback_data=f"svc:rename:{service.id}") - kb.button(text="Адрес", callback_data=f"svc:url:{service.id}") - kb.button(text="Удалить", callback_data=f"svc:del:{service.id}") - kb.button(text="К списку", callback_data="menu:list:0") + kb.button(text="⏸ Пауза", callback_data=f"svc:pause:{service.id}") + kb.button(text="🎯 Правило", callback_data=f"svc:rule:{service.id}") + kb.button(text="✏️ Имя", callback_data=f"svc:rename:{service.id}") + kb.button(text="🔗 Адрес", callback_data=f"svc:url:{service.id}") + kb.button(text="🗑 Удалить", callback_data=f"svc:del:{service.id}") + kb.button(text="⬅️ К списку", callback_data="menu:list:0") kb.adjust(2) return kb.as_markup() def confirm_delete(service_id: int) -> InlineKeyboardMarkup: kb = InlineKeyboardBuilder() - kb.button(text="Удалить", callback_data=f"svc:delyes:{service_id}", style=DANGER) - kb.button(text="Отмена", callback_data=f"svc:open:{service_id}") + kb.button(text="🗑 Удалить", callback_data=f"svc:delyes:{service_id}", style=DANGER) + kb.button(text="✖️ Отмена", callback_data=f"svc:open:{service_id}") kb.adjust(2) return kb.as_markup() @@ -94,9 +102,9 @@ def confirm_delete(service_id: int) -> InlineKeyboardMarkup: def after_check(service_id: int, *, offer_baseline: bool) -> InlineKeyboardMarkup: kb = InlineKeyboardBuilder() if offer_baseline: - kb.button(text="Принять как эталон", callback_data=f"svc:base:{service_id}") - kb.button(text="Ещё раз", callback_data=f"svc:check:{service_id}") - kb.button(text="К сервису", callback_data=f"svc:open:{service_id}") + kb.button(text="✅ Принять как эталон", callback_data=f"svc:base:{service_id}") + kb.button(text="🔄 Ещё раз", callback_data=f"svc:check:{service_id}") + kb.button(text="⬅️ К сервису", callback_data=f"svc:open:{service_id}") kb.adjust(*([1] if offer_baseline else []), 2) return kb.as_markup() @@ -108,4 +116,4 @@ def back_to(text: str, data: str) -> InlineKeyboardMarkup: def back_to_menu() -> InlineKeyboardMarkup: - return back_to("Меню", "menu:main") + return back_to("🏠 Меню", "menu:main") diff --git a/src/healthbot/monitor.py b/src/healthbot/monitor.py index 3cee02a..d1e0025 100644 --- a/src/healthbot/monitor.py +++ b/src/healthbot/monitor.py @@ -30,17 +30,17 @@ def _head(service: Service, icon: str, tail: str) -> str: def _down_kb(service: Service, incident_id: int | None) -> InlineKeyboardMarkup: kb = InlineKeyboardBuilder() - kb.button(text="Проверить", callback_data=f"go:check:{service.id}") - kb.button(text="Открыть", callback_data=f"go:svc:{service.id}") + kb.button(text="🔍 Проверить", callback_data=f"go:check:{service.id}") + kb.button(text="📂 Открыть", callback_data=f"go:svc:{service.id}") if incident_id is not None: - kb.button(text="Не напоминать", callback_data=f"svc:mute:{incident_id}") + kb.button(text="🔕 Не напоминать", callback_data=f"svc:mute:{incident_id}") kb.adjust(2, 1) return kb.as_markup() def _open_kb(service: Service) -> InlineKeyboardMarkup: kb = InlineKeyboardBuilder() - kb.button(text="Открыть", callback_data=f"go:svc:{service.id}") + kb.button(text="📂 Открыть", callback_data=f"go:svc:{service.id}") return kb.as_markup() diff --git a/src/healthbot/runs.py b/src/healthbot/runs.py index 4bbb5e4..fa51bb7 100644 --- a/src/healthbot/runs.py +++ b/src/healthbot/runs.py @@ -62,7 +62,7 @@ def result_kb(update: Update | None, back: Back) -> InlineKeyboardMarkup: kb.button(text=text, callback_data=data) rows = [len(back)] if back else [] if update is not None and len(output_of(update, only_failed=False)) > OUTPUT_LIMIT: - kb.button(text="Весь вывод файлом", callback_data=f"run:file:{update.id}") + kb.button(text="📎 Весь вывод файлом", callback_data=f"run:file:{update.id}") rows.append(1) kb.adjust(*rows or [1]) return kb.as_markup() @@ -119,7 +119,7 @@ async def _track( # noqa: PLR0913 return check = InlineKeyboardBuilder() - check.button(text="Проверить", callback_data=f"run:st:{started.id}") + check.button(text="🔍 Проверить", callback_data=f"run:st:{started.id}") try: update = await komodo.wait(started.id, RUN_TIMEOUT) except KomodoError as exc: diff --git a/src/healthbot/ui.py b/src/healthbot/ui.py index 7ae7bf9..fbbe3d4 100644 --- a/src/healthbot/ui.py +++ b/src/healthbot/ui.py @@ -88,6 +88,10 @@ def plural(count: int, one: str, few: str, many: str) -> str: return many +def with_icon(text: str, icon: str) -> str: + return f"{icon} {text}" if text[:1].isalnum() else text + + def pages(total: int, size: int) -> int: return max(1, (total + size - 1) // size) @@ -97,10 +101,10 @@ def pager(kb: InlineKeyboardBuilder, prefix: str, page: int, total_pages: int) - return 0 count = 0 if page > 0: - kb.button(text="‹ Назад", callback_data=f"{prefix}:{page - 1}") + kb.button(text="◀️ Назад", callback_data=f"{prefix}:{page - 1}") count += 1 if page < total_pages - 1: - kb.button(text="Дальше ›", callback_data=f"{prefix}:{page + 1}") + kb.button(text="Дальше ▶️", callback_data=f"{prefix}:{page + 1}") count += 1 return count