Files

393 lines
36 KiB
HTML

{{define "settings-body"}}<div class="page" data-module="settings">
<style>
/* An install command, with a button to copy it at its right. */
/* Users: role, machines, password sign-in. */
.tablewrap { overflow-x:auto; }
table.users th { font-size:12px; font-weight:500; color:var(--muted); text-align:left; white-space:nowrap; }
table.users td { vertical-align:middle; }
table.users select.u-role { width:auto; height:30px; padding:2px 30px 2px 8px; font-size:13px; }
table.users .u-agents { vertical-align:middle; margin-left:4px; }
table.users .u-count { font-size:12px; margin-left:2px; }
table.users .u-lock { width:16px; height:16px; cursor:pointer; }
.u-note { font-size:12px; color:var(--ok); opacity:0; }
.u-note.bad { color:var(--danger); }
.u-note.fade { animation:u-note 3s ease forwards; }
@keyframes u-note { 0%, 70% { opacity:1; } 100% { opacity:0; } }
.u-add { margin-top:14px; }
.u-pick { display:flex; flex-wrap:wrap; gap:6px; align-items:center; animation:u-in .2s ease; }
.u-pick[hidden] { display:none; }
.u-pick > .muted:first-child { flex-basis:100%; }
.u-chip { display:inline-flex; align-items:center; gap:6px; margin:0; padding:5px 10px; border:1px solid var(--border); border-radius:16px;
font-size:13px; color:var(--text); cursor:pointer; transition:border-color .15s, background .15s; }
.u-chip:has(input:checked) { border-color:var(--accent); background:#12202f; }
.u-chip input { width:14px; height:14px; margin:0; }
@keyframes u-in { from { opacity:0; transform:translateY(-4px); } }
#uadlg { max-width:480px; }
.ua-tools { display:flex; gap:8px; margin-bottom:10px; }
.ua-tools input { flex:1; height:32px; }
.ua-list { max-height:50vh; overflow:auto; border:1px solid var(--border); border-radius:9px; padding:4px; }
.ua-item { display:flex; align-items:center; gap:10px; margin:0; padding:8px 10px; border-radius:7px; cursor:pointer; color:var(--text); font-size:14px; }
.ua-item:hover { background:#151a21; }
.ua-item[hidden] { display:none; }
.ua-item input { width:16px; height:16px; margin:0; }
.ua-host { margin-left:auto; font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.copybox { position:relative; }
.copybox pre { padding-right:48px; }
.copybox .copy { position:absolute; top:7px; right:7px; width:30px; height:30px; padding:0;
display:inline-flex; align-items:center; justify-content:center; background:var(--panel); }
.copybox .copy.done { color:var(--ok); border-color:var(--ok); }
.codeform { display:flex; flex-wrap:wrap; align-items:center; gap:8px 10px; margin-top:10px; font-size:13px; }
.codeform label { margin:0; }
.codeform input[type=number] { width:72px; height:30px; padding:4px 8px; }
.codeform .ico { display:inline-flex; align-items:center; gap:6px; height:30px; }
</style>
<div class="wrap">
<h1>Настройки</h1>
{{if .Error}}<div class="err">{{.Error}}</div>{{end}}
<div class="card">
<h2>Установка на новую машину</h2>
<p class="muted">Выполните на Linux-клиенте под root. Скрипт сам скачает бинарник,
поставит сервис и зарегистрирует машину.</p>
<label>Короткая команда (секрет уже вшит в ссылку)</label>
<div class="copybox"><pre id="short">{{.InstallShort}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="short" title="Скопировать" aria-label="Скопировать"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
<form method="post" action="/settings/install/code" class="codeform"
data-confirm="Сделать новый код? Старая короткая ссылка перестанет работать." data-danger>
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label for="codelen">Символов в коде</label>
<input type="number" id="codelen" name="len" min="3" max="24" value="{{.CodeLen}}">
<button type="submit" class="btn ghost small ico"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/></svg>Новый код</button>
<span class="muted">латиница и цифры, от 3 до 24; новый код отменяет старую ссылку</span>
</form>
<details style="margin-top:10px">
<summary class="muted" style="cursor:pointer">Полные варианты (секрет в команде)</summary>
<label>curl</label>
<div class="copybox"><pre id="curl">{{.InstallCurl}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="curl" title="Скопировать" aria-label="Скопировать"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
<label>wget</label>
<div class="copybox"><pre id="wget">{{.InstallWget}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="wget" title="Скопировать" aria-label="Скопировать"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
{{if .Direct}}
<label>Короткая через прямой адрес (если основной недоступен)</label>
<div class="copybox"><pre id="dshort">{{.DirectShort}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="dshort" title="Скопировать" aria-label="Скопировать"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
<label>curl через прямой адрес</label>
<div class="copybox"><pre id="dcurl">{{.DirectCurl}}</pre><button type="button" class="btn ghost small icon sq copy" data-copy="dcurl" title="Скопировать" aria-label="Скопировать"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="12" height="12" rx="2"/><path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1"/></svg></button></div>
{{end}}
<p class="muted">Без переменных окружения скрипт спросит секрет интерактивно:
<code>curl -fsSL {{.Host}}/install.sh | sudo sh</code></p>
</details>
</div>
<div class="card">
<h2>Адрес хоста</h2>
<form method="post" action="/settings/host" class="hostform">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label for="host">Основной</label>
<input type="text" id="host" name="host" value="{{.Host}}" placeholder="https://lpra.example.com">
<p class="muted">URL, на который звонят агенты. Подставляется в скрипт установки.</p>
<label for="direct">Прямой (без Cloudflare) - необязательно</label>
<input type="text" id="direct" name="direct" value="{{.Direct}}" placeholder="https://direct.lpra.example.com">
<p class="muted">Адрес сервера в обход прокси: агенты и установщик переходят на него, если основной не отвечает.</p>
<div><button type="submit">Сохранить</button></div>
</form>
</div>
<div class="card">
<h2>Прямой туннель (tcp/udp проброс)</h2>
{{if not .TunnelDefault}}
<p class="muted">Прямой туннель отключён при запуске сервера (пустой <code>-tunnel</code> / <code>LPRA_TUNNEL_ADDR</code>).</p>
{{else}}
<p class="muted">Адрес, к которому агенты подключаются для проброса в режимах tcp/udp. Агенты забирают его
с панели сами - при подключении и при каждом опросе, так что смена применяется без переустановки.
Хост можно оставить пустым (тогда берётся хост из адреса панели) или указать IP/другое имя.</p>
<form method="post" action="/settings/tunnel" class="row">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="text" name="tunnel_host" value="{{.TunnelHost}}" placeholder="хост (как у панели)" style="max-width:260px">
<input type="number" name="tunnel_port" min="1" max="65535" value="{{if .TunnelCustom}}{{.TunnelPort}}{{end}}" placeholder="{{.TunnelDefault}}" style="max-width:120px">
<button type="submit">Сохранить</button>
</form>
<p class="muted" style="font-size:13px">Сейчас сервер слушает порт <b>{{.TunnelPort}}</b> (tcp и udp). При смене порта сервер
сразу начинает слушать новый, агенты переподключаются в течение ~30 секунд, правило ufw переносится.
Агенты старше 0.0.3 и агенты с заданными <code>TUNNEL_ADDR</code>/<code>TUNNEL_PORT</code> адрес с панели не берут.</p>
{{end}}
</div>
<div class="card" id="db">
<h2>База данных</h2>
<p class="muted">Сейчас: <b>{{if eq .DBNow "postgres"}}PostgreSQL{{else}}SQLite{{end}}</b>.
{{if ne .DBNow .DBChosen.Type}}<span class="err" style="display:inline">Выбрана {{if eq .DBChosen.Type "postgres"}}PostgreSQL{{else}}SQLite{{end}} - перезапустите сервер, чтобы перейти.</span>{{end}}
При первом запуске на пустой PostgreSQL всё копируется из SQLite-файла (он остаётся как есть).</p>
<form method="post" action="/settings/db" id="dbform" class="ai-form db-form{{if eq .DBChosen.Type "postgres"}} pg{{end}}">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label>Хранить в<select name="db_type" onchange="this.form.classList.toggle('pg', this.value === 'postgres')">
<option value="sqlite"{{if ne .DBChosen.Type "postgres"}} selected{{end}}>SQLite (файл рядом с сервером)</option>
<option value="postgres"{{if eq .DBChosen.Type "postgres"}} selected{{end}}>PostgreSQL</option>
</select></label>
<div class="pg-fields">
<div class="frow2"><label>Хост<input type="text" name="pg_host" value="{{with .DBChosen.PG}}{{.Host}}{{end}}" placeholder="localhost"></label>
<label class="port">Порт<input type="number" name="pg_port" min="1" max="65535" value="{{with .DBChosen.PG}}{{if .Port}}{{.Port}}{{end}}{{end}}" placeholder="5432"></label></div>
<label>База<input type="text" name="pg_name" value="{{with .DBChosen.PG}}{{.Name}}{{end}}" placeholder="lpra"></label>
<label>Пользователь<input type="text" name="pg_user" value="{{with .DBChosen.PG}}{{.User}}{{end}}" autocomplete="off"></label>
<label>Пароль<input type="password" name="pg_password" placeholder="{{with .DBChosen.PG}}{{if .Password}}задан - пусто, чтобы оставить{{end}}{{end}}" autocomplete="new-password"></label>
<label>SSL<select name="pg_sslmode">
{{$m := "prefer"}}{{with .DBChosen.PG}}{{if .SSLMode}}{{$m = .SSLMode}}{{end}}{{end}}
<option value="disable"{{if eq $m "disable"}} selected{{end}}>disable</option>
<option value="prefer"{{if eq $m "prefer"}} selected{{end}}>prefer</option>
<option value="require"{{if eq $m "require"}} selected{{end}}>require</option>
<option value="verify-full"{{if eq $m "verify-full"}} selected{{end}}>verify-full</option>
</select></label>
</div>
</form>
<div class="row db-actions">
<button type="submit" form="dbform">Проверить и сохранить</button>
<form method="post" action="/settings/db/restart" data-confirm="Перезапустить сервер? Терминалы и передачи прервутся, страница переподключится сама." data-danger>
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button type="submit" class="btn ghost">Перезапустить сервер</button>
</form>
</div>
</div>
<div class="card" id="ai">
<h2>ИИ-ассистент</h2>
<form method="post" action="/settings/ai" class="ai-form" data-search="{{.Search.Engine}}">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<p class="muted" style="margin:0">Моделей может быть несколько - в чате появится выбор; первая - по умолчанию.</p>
<div class="ai-models">
{{range .AIModels}} <details class="ai-model"><summary class="ai-model-sum"><span class="ai-model-title">{{if .Name}}{{.Name}}{{else}}Модель{{end}}</span><span class="ai-model-sub">{{.Type}}{{if .Model}} · {{.Model}}{{end}}</span></summary>
<div class="ai-model-body">
<input type="hidden" name="m_id" value="{{.ID}}">
<div class="ai-model-head"><label class="ai-model-name">Название в чате<input type="text" name="m_name" value="{{.Name}}" placeholder="например, Claude"></label>
<label class="ai-model-short">Коротко<input type="text" name="m_short" value="{{.Short}}" maxlength="12" placeholder="до 12" title="Название на кнопке модели в чате, до 12 символов"></label>
<button type="button" class="btn ghost small close ai-model-del" title="Удалить модель" aria-label="Удалить модель">Удалить</button></div>
<label>Тип API<select name="m_type">
<option value="gradio"{{if eq .Type "gradio"}} selected{{end}}>Gradio (Qwen Omni через прокси)</option>
<option value="openai"{{if eq .Type "openai"}} selected{{end}}>OpenAI-совместимый</option>
<option value="anthropic"{{if eq .Type "anthropic"}} selected{{end}}>Anthropic</option>
</select></label>
<label>Адрес<input type="url" name="m_url" value="{{.URL}}" placeholder="https://api.openai.com/v1"></label>
<label>Модель<input type="text" name="m_model" value="{{.Model}}" placeholder="для OpenAI и Anthropic"></label>
<label>Ключ<span class="keybox"><input type="password" name="m_key" value="{{.Key}}" placeholder="не задан" autocomplete="off" spellcheck="false">
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
</div></details>
{{end}} </div>
<template class="ai-model-new">
<details class="ai-model" open><summary class="ai-model-sum"><span class="ai-model-title">Новая модель</span><span class="ai-model-sub"></span></summary>
<div class="ai-model-body">
<input type="hidden" name="m_id" value="">
<div class="ai-model-head"><label class="ai-model-name">Название в чате<input type="text" name="m_name" value="" placeholder="например, Claude"></label>
<label class="ai-model-short">Коротко<input type="text" name="m_short" value="" maxlength="12" placeholder="до 12" title="Название на кнопке модели в чате, до 12 символов"></label>
<button type="button" class="btn ghost small close ai-model-del" title="Удалить модель" aria-label="Удалить модель">Удалить</button></div>
<label>Тип API<select name="m_type">
<option value="gradio">Gradio (Qwen Omni через прокси)</option>
<option value="openai">OpenAI-совместимый</option>
<option value="anthropic">Anthropic</option>
</select></label>
<label>Адрес<input type="url" name="m_url" value="" placeholder="https://api.openai.com/v1"></label>
<label>Модель<input type="text" name="m_model" value="" placeholder="для OpenAI и Anthropic"></label>
<label>Ключ<span class="keybox"><input type="password" name="m_key" value="" placeholder="не задан" autocomplete="off" spellcheck="false">
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
</div></details>
</template>
<div><button type="button" class="btn ghost small ai-model-add">+ Добавить модель</button></div>
<details class="ai-search"><summary class="ai-model-sum"><span class="ai-model-title">Поиск в интернете</span><span class="ai-model-sub">Запасной - DuckDuckGo</span></summary>
<div class="ai-model-body">
<label>Поисковик<select name="ai_search" onchange="this.form.dataset.search = this.value">
<option value=""{{if eq .Search.Engine ""}} selected{{end}}>DuckDuckGo - бесплатно, без настройки</option>
<option value="searxng"{{if eq .Search.Engine "searxng"}} selected{{end}}>SearXNG - свой сервер</option>
<option value="brave"{{if eq .Search.Engine "brave"}} selected{{end}}>Brave Search API - нужен ключ</option>
<option value="tavily"{{if eq .Search.Engine "tavily"}} selected{{end}}>Tavily - API для ИИ, нужен ключ</option>
</select></label>
<label class="s-url">Адрес SearXNG<input type="url" name="ai_search_url" value="{{.Search.URL}}" placeholder="http://127.0.0.1:8888 (в settings.yml: search.formats - json)"></label>
<label class="s-key">Ключ поисковика<span class="keybox"><input type="password" name="ai_search_key" value="{{.Search.Key}}" placeholder="не задан" autocomplete="off" spellcheck="false">
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
</div></details>
<details class="ai-search"><summary class="ai-model-sum"><span class="ai-model-title">Генерация изображений</span><span class="ai-model-sub">{{if .ImageKey}}включена{{else}}ключ не задан{{end}}</span></summary>
<div class="ai-model-body">
<p class="muted" style="margin:0">Модели сами рисуют и правят фото, когда это нужно по разговору, - через <a href="https://s2.x-grk.com/api/vertex/docs" target="_blank" rel="noopener">Vertex API</a>. Нет ключа - нет и инструмента.</p>
<label>API-ключ<span class="keybox"><input type="password" name="ai_image_key" value="{{.ImageKey}}" placeholder="vx_… не задан" autocomplete="off" spellcheck="false">
<button type="button" class="btn ghost small icon sq" title="Показать" aria-label="Показать ключ" onclick="var i=this.previousElementSibling;i.type=i.type==='password'?'text':'password'"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/></svg></button></span></label>
</div></details>
<div><button type="submit">Сохранить</button></div>
</form>
</div>
<div class="card" id="autoupdate">
<h2>Автообновление</h2>
<form method="post" action="/settings/autoupdate" class="ai-form">
<input type="hidden" name="csrf" value="{{.CSRF}}">
{{/* The same spoilers as the assistant's. */}}
<details class="ai-search"><summary class="ai-model-sum"><span class="ai-model-title">Автообновление клиентов</span><span class="ai-model-sub">{{if .Auto.AgentOn}}каждые {{.Auto.AgentEvery}} {{if eq .Auto.AgentUnit "d"}}сут.{{else if eq .Auto.AgentUnit "h"}}ч.{{else}}мин.{{end}}{{else}}выключено{{end}}</span></summary>
<div class="ai-model-body">
<p class="muted" style="margin:0">Клиенты раз в указанное время сверяют свою версию с версией сервера и, если у сервера новее, обновляются сами - так же, как по кнопке «Обновить». Нужны права root или sudo без пароля; агентам старше 0.2.5 один раз обновиться придётся вручную.</p>
<label class="check"><input type="checkbox" name="agent_auto" value="1"{{if .Auto.AgentOn}} checked{{end}}> Включить автообновление клиентов</label>
<div class="auto-every"><span class="muted">Проверять раз в</span>
<input type="number" name="agent_every" min="1" max="999" value="{{.Auto.AgentEvery}}">
<select name="agent_unit"><option value="min"{{if eq .Auto.AgentUnit "min"}} selected{{end}}>минут</option><option value="h"{{if eq .Auto.AgentUnit "h"}} selected{{end}}>часов</option><option value="d"{{if eq .Auto.AgentUnit "d"}} selected{{end}}>суток</option></select></div>
</div></details>
<details class="ai-search"><summary class="ai-model-sum"><span class="ai-model-title">Автообновление сервера</span><span class="ai-model-sub">{{if .Auto.ServerOn}}каждые {{.Auto.ServerEvery}} {{if eq .Auto.ServerUnit "d"}}сут.{{else if eq .Auto.ServerUnit "h"}}ч.{{else}}мин.{{end}}{{else}}выключено{{end}} · сейчас {{.Auto.Status.Current}}</span></summary>
<div class="ai-model-body">
<p class="muted" style="margin:0">Сервер раз в указанное время сверяет свою версию с последним релизом на GitLab. Если вышел новый, администратору при входе (после PIN) показывается окно: версия и кнопки «Обновить» и «Отказаться». «Обновить» запускает тот же скрипт, что и вручную: <code>wget -qO- https://gitlab.com/itzredtea/lpra/-/raw/main/server-install.sh | sudo sh</code> - от root, поэтому нужны root и systemd; панель при этом недоступна около минуты.</p>
<label class="check"><input type="checkbox" name="server_auto" value="1"{{if .Auto.ServerOn}} checked{{end}}> Предлагать обновление сервера при входе</label>
<div class="auto-every"><span class="muted">Проверять раз в</span>
<input type="number" name="server_every" min="1" max="999" value="{{.Auto.ServerEvery}}">
<select name="server_unit"><option value="min"{{if eq .Auto.ServerUnit "min"}} selected{{end}}>минут</option><option value="h"{{if eq .Auto.ServerUnit "h"}} selected{{end}}>часов</option><option value="d"{{if eq .Auto.ServerUnit "d"}} selected{{end}}>суток</option></select></div>
<div class="auto-status muted" aria-live="polite">Версия сервера: <b>{{.Auto.Status.Current}}</b>{{if .Auto.Status.Latest}} · на GitLab: <b>{{.Auto.Status.Latest}}</b>{{end}}{{if .Auto.Status.Checked}} · проверено {{.Auto.Status.Checked}}{{end}}{{if .Auto.Status.Error}}<br><span class="bad">{{.Auto.Status.Error}}</span>{{end}}</div>
<div class="row">
<button type="button" class="btn ghost small auto-check">Проверить сейчас</button>
<button type="button" class="btn small auto-run"{{if not .Auto.Status.Newer}} hidden{{end}}>Обновить сейчас</button>
</div>
</div></details>
<div><button type="submit">Сохранить</button></div>
</form>
</div>
<div class="card">
<h2>Enroll-секрет</h2>
<p class="muted">Общий секрет для регистрации новых агентов.</p>
<div class="row">
<code>{{.Secret}}</code>
<form method="post" action="/settings/secret/rotate" style="display:inline"
data-confirm="Сменить секрет? Старые one-liner перестанут работать для новых установок." data-danger>
<input type="hidden" name="csrf" value="{{.CSRF}}">
<button class="btn ghost small" type="submit">Сменить</button>
</form>
</div>
</div>
<div class="card">
<h2 id="users">Пользователи</h2>
<p class="muted" style="font-size:13px">Администратор видит всё. Пользователь - только выданные ему машины, без настроек.
«Пароль» - можно ли входить по паролю (выключить можно, когда у человека есть ключ доступа);
«Закрепить» - он не сможет сам поменять это в своём аккаунте.</p>
<div class="tablewrap"><table class="users">
<thead><tr><th>Имя</th><th>Роль</th><th>Вход</th><th title="Вход по паролю">Пароль</th><th title="Не даёт менять вход по паролю самому">Закрепить</th><th></th></tr></thead>
<tbody>
{{range .Admins}}
<tr data-id="{{.ID}}" data-name="{{.Username}}">
<td>{{.Username}}{{if eq .ID $.Me.ID}} <span class="muted">(вы)</span>{{end}}</td>
<td class="nowrap">
<select class="u-role" aria-label="Роль">
<option value="admin"{{if .IsAdmin}} selected{{end}}>Администратор</option>
<option value="user"{{if not .IsAdmin}} selected{{end}}>Пользователь</option>
</select>
<button type="button" class="btn ghost small icon sq u-agents" title="Машины пользователя" aria-label="Машины пользователя"{{if .IsAdmin}} hidden{{end}}><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg></button>
<span class="muted u-count"{{if .IsAdmin}} hidden{{end}}>{{index $.AgentCounts .ID}}</span>
</td>
<td class="muted">{{with index $.KeyCounts .ID}}ключей: {{.}}{{else}}только пароль{{end}}</td>
<td><input type="checkbox" class="sw u-pw" aria-label="Вход по паролю"{{if not (index $.PassOff .ID)}} checked{{end}}{{if not (index $.KeyCounts .ID)}} disabled title="Нет ключей доступа - пароль выключить нельзя"{{end}}></td>
<td><input type="checkbox" class="u-lock" aria-label="Закрепить"{{if .PwLocked}} checked{{end}}></td>
<td style="text-align:right" class="nowrap">
{{if index $.KeyCounts .ID}}
<form method="post" action="/settings/admins/keys/reset" style="display:inline"
data-confirm="Сбросить все ключи доступа {{.Username}}? Вход по паролю снова включится." data-danger>
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="id" value="{{.ID}}">
<button class="btn ghost small" type="submit" title="Удалить все ключи доступа и снова включить вход по паролю">Сбросить ключи</button>
</form>
{{end}}
<form method="post" action="/settings/admins/delete" style="display:inline"
data-confirm="Удалить {{.Username}}?" data-danger>
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="id" value="{{.ID}}">
<button class="btn danger small" type="submit">Удалить</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table></div>
<form method="post" action="/settings/admins/add" class="u-add">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<div class="row">
<input type="text" name="username" placeholder="логин" style="max-width:200px" required autocomplete="off">
<input type="password" name="password" placeholder="пароль (мин. 8)" style="max-width:200px" required autocomplete="new-password">
<select name="role" id="newrole" style="max-width:180px">
<option value="admin">Администратор</option>
<option value="user">Пользователь</option>
</select>
<button type="submit">Добавить</button>
</div>
<div class="u-pick" id="newagents" hidden>
<div class="muted" style="font-size:13px;margin:10px 0 6px">Машины, к которым у него будет доступ:</div>
{{range .AllAgents}}<label class="u-chip"><input type="checkbox" name="agents" value="{{.ID}}"><span>{{.Name}}</span></label>{{else}}<span class="muted">машин пока нет</span>{{end}}
</div>
</form>
</div>
<dialog id="uadlg" tabindex="-1">
<div class="dlg-h"><strong style="flex:1">Машины пользователя <span id="uaname"></span></strong>
<button type="button" class="btn ghost small icon close x" id="uaclose" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>
<div class="dlg-b">
<div class="ua-tools"><input type="text" id="uafilter" placeholder="поиск" autocomplete="off"><button type="button" class="btn ghost small" id="uaall">Все</button><button type="button" class="btn ghost small" id="uanone">Ни одной</button></div>
<div class="ua-list" id="ualist"></div>
<div class="row" style="justify-content:flex-end;margin-top:14px"><button type="button" class="btn ghost" id="uacancel">Отмена</button><button type="button" id="uasave">Сохранить</button></div>
</div>
</dialog>
<div class="card">
<h2>Firewall (ufw)</h2>
{{if not .UfwInstalled}}
<p class="muted">ufw не установлен на хосте - интеграция недоступна.</p>
{{else if not .UfwRunnable}}
<p class="muted">ufw установлен, но сервер не может им управлять (нужны права root).
Запустите сервер под root или настройте sudo для ufw.</p>
{{else}}
<p class="muted">
Статус ufw: {{if .UfwActive}}<span style="color:var(--ok)">активен</span>{{else}}<span style="color:var(--danger)">выключен</span>{{end}}
· Интеграция: {{if .UfwEnabled}}<span style="color:var(--ok)">включена</span>{{else}}<span class="muted">выключена</span>{{end}}
</p>
<p class="muted" style="font-size:13px">При создании правила проброса порт открывается в ufw автоматически (для tcp/udp - также порт туннеля{{if .TunnelPort}} {{.TunnelPort}}{{end}}); для http-проброса открывается tcp-порт хоста. Порты правил, слушающих только 127.0.0.1, не открываются.</p>
{{if .UfwError}}<div class="err">Последняя команда ufw завершилась ошибкой: <code>{{.UfwError}}</code></div>{{end}}
<form method="post" action="/settings/ufw/toggle" style="display:inline">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="hidden" name="enabled" value="{{if .UfwEnabled}}0{{else}}1{{end}}">
<button class="btn {{if .UfwEnabled}}ghost{{end}} small" type="submit">
{{if .UfwEnabled}}Выключить интеграцию{{else}}Включить интеграцию{{end}}
</button>
</form>
{{if .UfwRules}}
<h2 style="margin-top:18px; font-size:15px">Правила, созданные панелью</h2>
<table>
<tbody>
{{range .UfwRules}}
<tr>
<td><code>{{.Port}}/{{.Proto}}</code></td>
<td class="muted">{{if .Manual}}вручную{{else if .RuleID}}проброс{{else}}туннель{{end}}</td>
<td style="text-align:right">
<form method="post" action="/settings/ufw/revoke" style="display:inline"
data-confirm="Отменить правило ufw {{.Port}}/{{.Proto}}?" data-danger>
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="id" value="{{.ID}}">
<button class="btn danger small" type="submit">Отменить</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="muted" style="font-size:13px; margin-top:10px">Панель пока не открывала портов в ufw.</p>
{{end}}
{{if and .UfwActive .UfwEnabled}}
<h2 style="margin-top:18px; font-size:15px">Открыть порт вручную</h2>
<form method="post" action="/settings/ufw/add" class="row">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<input type="number" name="port" min="1" max="65535" placeholder="порт" style="max-width:120px" required>
<select name="proto" style="max-width:100px">
<option value="tcp">tcp</option>
<option value="udp">udp</option>
</select>
<button type="submit">Открыть</button>
</form>
<p class="muted" style="font-size:13px">Такое правило не привязано к пробросу и остаётся, пока его не отменить здесь.</p>
{{end}}
{{end}}
</div>
<p class="muted" style="font-size:12px; text-align:center">LPRA сервер · версия {{.ServerVersion}}</p>
</div>
</div>{{end}}