Лежащий в ящике смартфон на Android обычно считают бесполезным, хотя у него есть то, чего не хватает самодельным панелям: экран приличного размера, питание от обычного разъёма и корпус, который можно закрепить где угодно. Серверу остаётся отдавать страницу с показателями. Разбираем, как связать устройства по USB через adb reverse, собрать лёгкое веб-приложение на стандартной библиотеке Python и превратить телефон в постоянно включённую панель.

Зачем нужен отдельный экран и что он даёт по сравнению с консолью

Метрики сервера доступны в консоли в любой момент, и это ровно та причина, по которой на них никто не смотрит. Чтобы проверить состояние, нужно совершить действие: открыть терминал, подключиться, ввести команду. В обычной жизни на это нет времени, а в момент сбоя тем более.

Постоянно включённый экран меняет механику восприятия. Показатели попадают в поле зрения случайно, и отклонение замечается до того, как превратилось в проблему. Загрузка держалась на десяти процентах месяцами, а сегодня полоса занимает половину ширины, и это видно боковым зрением.

Второе преимущество касается размещения. Телефон вешают рядом с рабочим столом или на стене в коридоре. Стоимость панели равна нулю, тогда как отдельный монитор с креплением и маленьким компьютером обходится в заметную сумму.

Третье преимущество неочевидно: экран связан с сервером только одной страницей. Если сервер падает, панель продолжает работать и показывает отсутствие данных, что информативнее потухшего монитора.

Подключение по USB и проброс порта командой adb reverse

Связь между устройствами строится на стандартном мосте отладки. На телефоне включается режим разработчика, затем отладка по USB, после чего устройство подключается кабелем к серверу.

Проверка соединения:

adb devices

В выводе должен появиться серийный номер и состояние device. Если вместо него стоит unauthorized, на экране телефона ждёт подтверждение доверия к этому компьютеру, и его нужно принять.

Ключевой шаг, проброс порта в обратную сторону:

adb reverse tcp:8099 tcp:8099

Команда связывает порт 8099 на телефоне с портом 8099 на сервере. Браузер на телефоне, обращаясь к адресу 127.0.0.1:8099, попадает на службу сервера, и настроек маршрутизатора не требуется.

Отличие от прямого проброса принципиальное. Команда adb forward tcp:8099 tcp:8099 открывает порт на сервере и перенаправляет обращения к телефону, что нужно для обратной задачи. Для панели мониторинга используется именно reverse.

Проверить список активных пробросов и снять лишний:

adb reverse --list
adb reverse --remove-all

Кабель надёжнее беспроводной связи и заодно решает вопрос питания. Минус один: проброс не переживает перезагрузку телефона.

Веб-приложение на стандартной библиотеке Python, читающее proc

Служба на сервере обязана быть простой и не падать. Для чтения показателей хватает стандартной библиотеки, без внешних зависимостей, что снимает вопросы об окружении и обновлениях.

#!/usr/bin/env python3
import json
import os
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer


def cpu_percent(interval: float = 0.5):
    def read():
        with open("/proc/stat") as fh:
            vals = list(map(int, fh.readline().split()[1:]))
        return vals[3] + vals[4], sum(vals)

    idle0, total0 = read()
    time.sleep(interval)
    idle1, total1 = read()
    dt = total1 - total0
    return round(100.0 * (1.0 - (idle1 - idle0) / dt), 1) if dt > 0 else 0.0


def memory():
    info = {}
    with open("/proc/meminfo") as fh:
        for line in fh:
            key, val = line.split(":", 1)
            info[key] = int(val.strip().split()[0])
    total, avail = info["MemTotal"], info["MemAvailable"]
    return {"percent": round(100.0 * (total - avail) / total, 1)}


def disk(path="/"):
    st = os.statvfs(path)
    total = st.f_blocks * st.f_frsize
    free = st.f_bavail * st.f_frsize
    return {"free_gb": round(free / 1024**3, 1),
            "percent": round(100.0 * (total - free) / total, 1)}


class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path != "/metrics.json":
            self.send_error(404)
            return
        with open("/proc/loadavg") as fh:
            load = fh.read().split()[:3]
        payload = {"ts": int(time.time()), "cpu": cpu_percent(),
                   "mem": memory(), "load": load, "disk": disk()}
        body = json.dumps(payload).encode()
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.send_header("Cache-Control", "no-store")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def log_message(self, fmt, *args):
        pass


if __name__ == "__main__":
    ThreadingHTTPServer(("127.0.0.1", 8099), Handler).serve_forever()

Расчёт загрузки процессора заслуживает пояснений. Файл /proc/stat отдаёт накопленные счётчики времени, поэтому мгновенного значения в нём нет. Загрузка считается как отношение приращения времени простоя к общему приращению за интервал, и без паузы между чтениями цифра выйдет бессмысленной.

Память берут по полю MemAvailable, а не по MemFree. Свободной памяти в Linux почти всегда мало, поскольку её занимает кэш, отдаваемый по первому требованию.

Привязка к локальному адресу здесь достаточна, потому что доступ с телефона идёт через проброс, а не по сети.

Страница с крупными цифрами и обновление по таймеру

Второй файл, сама страница. Требования специфические: крупные цифры, читаемые с расстояния, тёмный фон и отсутствие прокрутки.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Сервер</title>
<style>
  body{background:#111;color:#eee;font-family:sans-serif;margin:0;padding:16px}
  .row{display:flex;justify-content:space-between;padding:14px 0;
       border-bottom:1px solid #333}
  .val{font-size:44px;font-weight:700}
  .lbl{font-size:16px;color:#999}
  .warn{color:#ffb020}
  .crit{color:#ff5c5c}
</style>
</head>
<body>
  <div class="row"><span class="lbl">Процессор</span><span class="val" id="cpu">-</span></div>
  <div class="row"><span class="lbl">Память</span><span class="val" id="mem">-</span></div>
  <div class="row"><span class="lbl">Загрузка 1 мин</span><span class="val" id="load">-</span></div>
  <div class="row"><span class="lbl">Диск, занято</span><span class="val" id="disk">-</span></div>
  <div class="row"><span class="lbl">Обновлено</span><span class="val" id="ts">-</span></div>
<script>
function cls(v){return v>90?'crit':v>70?'warn':'';}
async function tick(){
  try{
    const r = await fetch('/metrics.json', {cache:'no-store'});
    const d = await r.json();
    set('cpu', d.cpu+'%', d.cpu);
    set('mem', d.mem.percent+'%', d.mem.percent);
    set('load', d.load[0]);
    set('disk', d.disk.percent+'%', d.disk.percent);
    document.getElementById('ts').textContent =
      new Date(d.ts*1000).toLocaleTimeString('ru-RU');
  }catch(e){
    set('cpu','нет связи'); set('mem',''); set('load',''); set('disk','');
  }
}
function set(id, text, v){
  const el = document.getElementById(id);
  el.textContent = text;
  el.className = 'val ' + (v===undefined ? '' : cls(v));
}
tick(); setInterval(tick, 5000);
</script>
</body>
</html>

Интервал обновления в пять секунд выбран не случайно. Чаще не нужно, поскольку показатели меняются плавно, а каждый запрос заставляет сервер читать proc и считать загрузку с полусекундной паузой.

Обработка ошибки сети здесь важнее, чем в обычных страницах. При недоступности сервера на экране появляется сообщение, а не застывшие старые цифры.

Настройка телефона как постоянно включённой панели

Со стороны телефона нужно решить четыре задачи:

  1. Держать экран включённым, для чего в разделе для разработчиков есть переключатель Stay awake, работающий всё время, пока подключена зарядка;
  2. Открыть страницу в полноэкранном режиме, через намерение с нужным флагом или готовое kiosk-приложение;
  3. Не дать системе усыпить браузер, добавив его в исключения из экономии энергии;
  4. Ограничить износ аккумулятора, выставив потолок заряда на восемьдесят процентов, если система такое умеет.

Экран не гаснет, пока устройство подключено к зарядке, соответствующий переключатель находится в разделе для разработчиков и называется Stay awake. Поскольку телефон постоянно на кабеле, опция работает всё время.

Полноэкранный режим достигается запуском браузера через намерение:

adb shell am start -n com.android.chrome/com.google.android.apps.chrome.Main \
  --es com.android.chrome.extra.OPEN_NEW_INCOGNITO_TAB false \
  -d http://127.0.0.1:8099/

Имя компонента в другой системе уточняют командой adb shell cmd package resolve-activity --brief. Проще взять готовое kiosk-приложение, которое держит один адрес в полноэкранном режиме и возвращается к нему после перезагрузки.

Яркость стоит зафиксировать на среднем уровне. Автоматическая подстройка в тёмной комнате снижает яркость до почти неразличимой, а на максимальном уровне экран выгорает и греется.

Аккумулятор слабое место постоянной работы от сети. Держать заряд на ста процентах месяцами вредно, поэтому потолок ставят на восемьдесят процентов. Батарея без такого ограничения со временем вздувается.

Автоматический запуск службы и восстановление проброса после перезагрузки

Обе части конструкции должны подниматься без участия человека. Служба сбора метрик оформляется юнитом systemd:

[Unit]
Description=Server metrics page
After=network.target

[Service]
Type=simple
ExecStart=/usr/bin/python3 /srv/dashboard/metrics.py
Restart=always
RestartSec=5
User=metrics
DynamicUser=yes

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now metrics.service
systemctl status metrics.service --no-pager

Проброс живёт до перезагрузки телефона, поэтому его восстанавливают автоматически. Скрипт с повтором запускается по таймеру и ничего не делает, если проброс уже есть:

#!/usr/bin/env bash
set -uo pipefail
export PATH=$PATH:/usr/lib/android-sdk/platform-tools

adb devices | grep -qw device || exit 0
if ! adb reverse --list | grep -q 'tcp:8099'; then
  adb reverse tcp:8099 tcp:8099
  logger -t dash "проброс порта восстановлен"
fi
*/2 * * * * /usr/local/sbin/adb-reverse-check.sh >/dev/null 2>&1

Проверка наличия устройства обязательна: без неё при отключённом телефоне журнал заполняется сообщениями об ошибке каждые две минуты.

Отдельный вопрос, как открыть страницу после перезагрузки телефона. Полноэкранный браузер сам к адресу не вернётся, это делает kiosk-приложение с автозапуском.

Когда кабель неудобен, проброс заменяют прямым обращением по локальной сети: службу слушают на внутреннем интерфейсе сервера, а в браузере телефона прописывают его адрес. Порт при этом держат закрытым снаружи, поскольку страница с метриками раскрывает устройство сервера.

Что ещё стоит вывести на экран и как оформить уровни тревоги

Четырёх показателей хватает на первую неделю, дальше становится ясно, чего не достаёт. Набор расширяется без усложнения серверной части.

Стоит вывести состояние контейнеров или служб. Одна строка с числом работающих и упавших единиц даёт больше информации, чем подробный список.

def services():
    out = subprocess.run(
        ["systemctl", "list-units", "--state=failed", "--no-legend", "--plain"],
        capture_output=True, text=True, check=False,
    ).stdout.strip()
    return {"failed": len(out.splitlines()) if out else 0}

Полезно добавить возраст последней резервной копии в часах. Такой индикатор сразу делает заметной ситуацию, когда копирование молча перестало работать неделю назад.

Отдельная строка для свободного места в гигабайтах, а не в процентах. На большом диске пять процентов это десятки гигабайт, и процентная величина вводит в заблуждение.

Цветовая индикация работает лучше цифр при разумных порогах. До семидесяти процентов белый, до девяноста янтарный, выше красный. Порог ниже смысла нет: постоянный янтарный цвет при штатной работе приучает игнорировать панель.

Смысл конструкции в том, что состояние сервера становится видимым без усилия. Один взгляд при проходе мимо заменяет ежевечерний заход в терминал, а отклонение видно раньше, чем оно приведёт к отказу.