Files
Claude 4098c270a4 Интерфейс панели был полностью перерисован
Новый дизайн в стиле коммерческих продуктов, рассчитанный на обычного
пользователя:
- светлая и тёмная темы (следует системной), шрифт Inter и иконки
  Lucide вшиты в панель — работает без интернета;
- общий каркас base.html и компоненты _ui.html вместо 14 независимых
  страниц; адаптивная вёрстка для телефона;
- простой язык: «Работает / Выключена», «Автозапуск», «Доступ из
  интернета» вместо running, autostart, DNAT; технические данные
  убраны во вкладку «Для специалистов»;
- главная: загрузка процессора, памяти и хранилища, карточки машин с
  главным действием и меню остальных, приветственный сценарий для
  первой машины;
- мастер создания машины из трёх шагов с готовыми конфигурациями;
- страница машины с вкладками, переключателем автозапуска и порядком
  загрузки кнопками;
- «Сеть и доступ»: открытие SSH, сайта, RDP и т. д. из списка,
  ручные IP машин, понятная диагностика;
- загрузка образов перетаскиванием с прогрессом и оставшимся временем;
- подтверждения опасных действий в диалоге и уведомления;
- раздел «Помощь»;
- web/presenters.py переводит вывод virsh и /proc в данные для
  страниц; tests/dev_server.py запускает панель с имитацией virsh.

Также: исправлено определение модели CPU на x86 («207» вместо
названия процессора), история версии 0.10.0 и документация обновлены
под Ubuntu 26.04 LTS, в README добавлены скриншоты.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019LEUXMcdZo7b87mjkUsPgg
2026-09-25 07:01:41 +00:00

186 строки
12 KiB
HTML

{% extends "base.html" %}
{% from "_ui.html" import icon, badge, alert, empty %}
{% set page_title = 'Сеть и доступ' %}
{% block page_header %}
<div class="page-header">
<div>
<h1>Сеть и доступ</h1>
<p class="lead">Откройте доступ к машинам из интернета: сайт, SSH, удалённый рабочий стол. Всё остальное остаётся закрытым.</p>
</div>
</div>
{% endblock %}
{% block content %}
{% if diagnostics %}
{% set failed = diagnostics.checks|rejectattr('ok')|list %}
<article class="card" style="margin-bottom:20px">
<div class="card-header">
<div><h2>Проверка доступа: {{ diagnostics.vm_name }}, порт {{ diagnostics.external_port }}</h2><p>{{ diagnostics.protocol|upper }} {{ diagnostics.external_port }} → {{ diagnostics.vm_ip or 'IP не найден' }}:{{ diagnostics.guest_port }}</p></div>
{% if failed %}{{ badge('Есть проблемы: ' ~ failed|length, 'warning') }}{% else %}{{ badge('Всё в порядке', 'success') }}{% endif %}
</div>
<div class="card-body stack-sm">
{{ alert('warning' if failed else 'success', diagnostics.verdict) }}
</div>
<div class="checklist">
{% for check in diagnostics.checks %}
<div class="check-item {{ 'success' if check.ok else 'danger' }}">{{ icon('circle-check' if check.ok else 'circle-x') }}<div style="min-width:0"><strong>{{ check.name }}</strong><span class="truncate" style="display:block">{{ check.detail|truncate(160) }}</span></div></div>
{% endfor %}
</div>
<div class="card-footer" style="justify-content:flex-start;display:grid;gap:8px">
<span class="subtle small">Проверить входящий трафик на сервере:</span>
<div class="code-line">{{ diagnostics.commands.watch_external }}<button type="button" class="btn btn-ghost btn-icon btn-sm" data-copy="{{ diagnostics.commands.watch_external }}" style="margin-left:auto">{{ icon('copy', 'icon-sm') }}</button></div>
</div>
</article>
{% endif %}
<section class="grid grid-main">
<article class="card">
<div class="card-header">
<div><h2>Доступ из интернета</h2><p>Порты сервера, которые ведут внутрь машин</p></div>
{% if ctx.forwards %}<form method="post" action="/network/apply" class="inline-form" data-toast="Правила применены заново"><button class="btn btn-sm btn-ghost">{{ icon('refresh-cw', 'icon-sm') }}Применить заново</button></form>{% endif %}
</div>
{% if ctx.forwards %}
<div class="list">
{% for item in ctx.forwards %}
{% set service = item.note or (item.guest_port|port_service) or ('Порт ' ~ item.guest_port_label) %}
<div class="list-item">
<span class="list-icon success">{{ icon('globe') }}</span>
<div class="grow">
<div class="title">{{ service }}</div>
<div class="meta">Сервер : <b class="mono">{{ item.external_port_label }}</b> → {{ item.vm_name }} : <b class="mono">{{ item.guest_port_label }}</b> · {{ item.protocol|upper }}{% if item.is_range %} · диапазон{% endif %}</div>
</div>
<form method="post" action="/network/diagnose" class="inline-form">
<input type="hidden" name="vm_name" value="{{ item.vm_name }}"><input type="hidden" name="external_port" value="{{ item.external_port }}"><input type="hidden" name="guest_port" value="{{ item.guest_port }}"><input type="hidden" name="protocol" value="{{ item.protocol }}">
<button class="btn btn-sm hide-sm">{{ icon('activity', 'icon-sm') }}Проверить</button>
</form>
<form method="post" action="/network/forward/{{ item.id }}/delete" class="inline-form" data-confirm="Доступ к «{{ service }}» из интернета будет закрыт." data-confirm-title="Закрыть доступ?" data-confirm-button="Закрыть" data-toast="Доступ закрыт" data-toast-tone="warning">
<button class="btn btn-sm btn-icon btn-ghost" aria-label="Удалить">{{ icon('trash-2', 'icon-sm') }}</button>
</form>
</div>
{% endfor %}
</div>
{% else %}
{{ empty('shield-check', 'Всё закрыто', 'Машины выходят в интернет, но снаружи к ним подключиться нельзя. Откройте нужный порт в форме справа.') }}
{% endif %}
</article>
<article class="card">
<div class="card-header"><div><h2>Открыть доступ</h2><p>Сделайте сервис машины доступным снаружи</p></div></div>
{% if vms %}
<form method="post" action="/network/forward/add" data-toast="Доступ открыт">
<div class="card-body form-grid">
<div class="field">
<label for="fw-vm">Машина</label>
<select class="select" id="fw-vm" name="vm_name" required>
{% for vm in vms %}<option value="{{ vm.name }}">{{ vm.name }}{% if vm.ip and vm.ip != '—' %} · {{ vm.ip }}{% endif %}</option>{% endfor %}
</select>
</div>
<div class="field">
<label for="fw-preset">Что открыть</label>
<select class="select" id="fw-preset" data-port-preset>
<option value="">Выберите сервис…</option>
{% for p in port_presets %}<option value="{{ p.port }}" data-protocol="{{ p.protocol }}">{{ p.label }}</option>{% endfor %}
<option value="custom">Другой порт</option>
</select>
</div>
<div class="form-row">
<div class="field"><label for="fw-guest">Порт в машине</label><input class="input" id="fw-guest" type="text" name="guest_port" pattern="[0-9]{1,5}([:\-][0-9]{1,5})?" placeholder="22" required></div>
<div class="field"><label for="fw-ext">Порт на сервере</label><input class="input" id="fw-ext" type="text" name="external_port" pattern="[0-9]{1,5}([:\-][0-9]{1,5})?" placeholder="2222" required></div>
</div>
<div class="field">
<label>Протокол</label>
<div class="segmented" role="radiogroup">
<label style="display:contents"><input type="radio" name="protocol" value="tcp" checked hidden><button type="button" class="active" data-proto="tcp">TCP</button></label>
<label style="display:contents"><input type="radio" name="protocol" value="udp" hidden><button type="button" data-proto="udp">UDP</button></label>
</div>
</div>
<details class="advanced">
<summary>{{ icon('wrench', 'icon-sm') }}Дополнительно<span class="chev">{{ icon('chevron-down', 'icon-sm') }}</span></summary>
<div class="advanced-body form-grid">
<div class="field"><label for="fw-ip">IP-адрес машины</label><input class="input" id="fw-ip" type="text" name="guest_ip" value="auto"><span class="hint">«auto» — определить автоматически</span></div>
<div class="field"><label for="fw-note">Название</label><input class="input" id="fw-note" type="text" name="note" maxlength="120" placeholder="например, Сайт компании"></div>
</div>
</details>
</div>
<div class="card-footer"><button type="submit" class="btn btn-primary">{{ icon('globe', 'icon-sm') }}Открыть доступ</button></div>
</form>
{% else %}
<div class="card-body subtle">Сначала <a href="/vm/create">создайте машину</a>.</div>
{% endif %}
</article>
</section>
<section class="grid grid-2 mt-3">
<article class="card">
<div class="card-header">
<div><h2>Внутренняя сеть машин</h2><p>Через неё машины выходят в интернет</p></div>
{% if ctx.nat.exists %}{{ badge('Работает', 'success') }}{% else %}{{ badge('Не создана', 'warning') }}{% endif %}
</div>
<div class="card-body stack-sm">
<div class="kv">
<div><span>Адреса машин</span><strong class="mono">{{ ctx.nat.subnet }}</strong></div>
<div><span>Шлюз</span><strong class="mono">{{ ctx.nat.gateway }}</strong></div>
<div><span>Выход в интернет</span><strong class="mono">{{ ctx.external_interface }}</strong></div>
<div><span>Маршрутизация</span><strong>{{ 'Включена' if ctx.ip_forward == 'enabled' else 'Выключена' }}</strong></div>
</div>
<form method="post" action="/network/nat/setup" data-toast="Сеть проверена и настроена"><button class="btn btn-sm">{{ icon('wrench', 'icon-sm') }}{{ 'Проверить и починить' if ctx.nat.exists else 'Создать сеть' }}</button></form>
</div>
</article>
<article class="card">
<div class="card-header"><div><h2>Адреса машин</h2><p>Если адрес не определился сам, укажите его вручную</p></div></div>
<div class="list">
{% for vm in vms %}
<form method="post" action="/network/vm-ip/save" class="list-item" data-toast="Адрес сохранён">
<span class="list-icon">{{ icon('monitor') }}</span>
<div class="grow"><div class="title">{{ vm.name }}</div><div class="meta">{{ vm.ip if vm.ip and vm.ip != '—' else 'адрес не определён' }}{% if vm.manual_ip %} · задан вручную{% endif %}</div></div>
<input type="hidden" name="vm_name" value="{{ vm.name }}">
<input class="input" style="width:150px;height:32px" type="text" name="manual_ip" value="{{ vm.manual_ip }}" placeholder="авто" aria-label="IP вручную">
<button class="btn btn-sm">Сохранить</button>
</form>
{% else %}
<div class="list-item subtle">Машин пока нет</div>
{% endfor %}
</div>
</article>
</section>
<details class="advanced mt-3">
<summary>{{ icon('terminal', 'icon-sm') }}Для специалистов: DHCP и правила nftables<span class="chev">{{ icon('chevron-down', 'icon-sm') }}</span></summary>
<div class="advanced-body grid grid-2">
<div><div class="terminal-head"><i></i><i></i><i></i><span>virsh net-dhcp-leases {{ ctx.nat.name }}</span></div><pre class="terminal">{{ ctx.nat.leases or '—' }}</pre></div>
<div><div class="terminal-head"><i></i><i></i><i></i><span>/etc/virtuality/nftables/virtuality.nft</span></div><pre class="terminal">{{ ctx.nft_rules }}</pre></div>
</div>
</details>
{% endblock %}
{% block scripts %}
<script>
(function () {
var preset = document.querySelector('[data-port-preset]');
if (!preset) return;
var guest = document.getElementById('fw-guest'), ext = document.getElementById('fw-ext'), note = document.getElementById('fw-note');
function setProto(value) {
document.querySelectorAll('[data-proto]').forEach(function (btn) {
var on = btn.dataset.proto === value;
btn.classList.toggle('active', on);
btn.previousElementSibling.checked = on;
});
}
document.querySelectorAll('[data-proto]').forEach(function (btn) { btn.addEventListener('click', function () { setProto(btn.dataset.proto); }); });
preset.addEventListener('change', function () {
var opt = preset.options[preset.selectedIndex];
if (!opt.value || opt.value === 'custom') { if (opt.value === 'custom') guest.focus(); return; }
guest.value = opt.value;
var port = parseInt(opt.value, 10);
ext.value = opt.value.indexOf('-') > -1 ? opt.value : (port === 22 ? '2222' : port === 3389 ? '33890' : opt.value);
setProto(opt.dataset.protocol || 'tcp');
if (note && !note.value) note.value = opt.text.split(' — ')[0];
});
guest.addEventListener('input', function () { if (!ext.dataset.touched) ext.value = guest.value; });
ext.addEventListener('input', function () { ext.dataset.touched = '1'; });
})();
</script>
{% endblock %}