Лежащий в ящике смартфон на 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 и считать загрузку с полусекундной паузой.
Обработка ошибки сети здесь важнее, чем в обычных страницах. При недоступности сервера на экране появляется сообщение, а не застывшие старые цифры.
Настройка телефона как постоянно включённой панели
Со стороны телефона нужно решить четыре задачи:
- Держать экран включённым, для чего в разделе для разработчиков есть переключатель Stay awake, работающий всё время, пока подключена зарядка;
- Открыть страницу в полноэкранном режиме, через намерение с нужным флагом или готовое kiosk-приложение;
- Не дать системе усыпить браузер, добавив его в исключения из экономии энергии;
- Ограничить износ аккумулятора, выставив потолок заряда на восемьдесят процентов, если система такое умеет.
Экран не гаснет, пока устройство подключено к зарядке, соответствующий переключатель находится в разделе для разработчиков и называется 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}
Полезно добавить возраст последней резервной копии в часах. Такой индикатор сразу делает заметной ситуацию, когда копирование молча перестало работать неделю назад.
Отдельная строка для свободного места в гигабайтах, а не в процентах. На большом диске пять процентов это десятки гигабайт, и процентная величина вводит в заблуждение.
Цветовая индикация работает лучше цифр при разумных порогах. До семидесяти процентов белый, до девяноста янтарный, выше красный. Порог ниже смысла нет: постоянный янтарный цвет при штатной работе приучает игнорировать панель.
Смысл конструкции в том, что состояние сервера становится видимым без усилия. Один взгляд при проходе мимо заменяет ежевечерний заход в терминал, а отклонение видно раньше, чем оно приведёт к отказу.