feat: redesign portal UX and stabilize web session runtime

This commit is contained in:
2026-04-13 08:35:07 +00:00
commit fc46d90194
29 changed files with 3915 additions and 0 deletions

11
app/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

1613
app/main.py Normal file

File diff suppressed because it is too large Load Diff

9
app/requirements.txt Normal file
View File

@@ -0,0 +1,9 @@
fastapi==0.116.1
uvicorn[standard]==0.35.0
sqlalchemy==2.0.43
psycopg2-binary==2.9.10
python-multipart==0.0.20
jinja2==3.1.6
passlib[argon2]==1.7.4
docker==7.1.0
itsdangerous==2.2.0

BIN
app/static/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120" fill="none">
<rect width="120" height="120" rx="16" fill="#E8EFF6"/>
<rect x="20" y="28" width="80" height="16" rx="6" fill="#B8CBDC"/>
<rect x="20" y="52" width="80" height="40" rx="8" fill="#CBD9E7"/>
<circle cx="36" cy="72" r="8" fill="#9FB4C8"/>
<rect x="50" y="66" width="38" height="12" rx="6" fill="#9FB4C8"/>
</svg>

After

Width:  |  Height:  |  Size: 421 B

330
app/static/style.css Normal file
View File

@@ -0,0 +1,330 @@
:root {
--bg: #f4f6f8;
--fg: #1a2634;
--card: #ffffff;
--accent: #0f5b94;
--line: #d6e1eb;
}
body {
margin: 0;
font-family: "IBM Plex Sans", sans-serif;
color: var(--fg);
background: radial-gradient(circle at top right, #d7e8f7, var(--bg) 45%);
}
.center-box {
min-height: 100vh;
display: grid;
place-content: center;
gap: 1rem;
padding: 1.2rem;
}
.brand-logo {
width: min(440px, 80vw);
height: auto;
justify-self: center;
}
.brand-logo-fullscreen {
width: min(23vw, 360px);
max-height: 14vh;
object-fit: contain;
}
.login-page .panel {
width: min(520px, 92vw);
justify-self: center;
}
.login-title {
text-align: center;
margin: 0;
font-size: clamp(1.1rem, 2.2vw, 1.6rem);
}
.header-logo {
width: 120px;
height: auto;
}
.panel {
background: var(--card);
padding: 1.25rem;
border-radius: 12px;
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.08);
display: grid;
gap: 0.5rem;
min-width: 320px;
}
input, button, textarea {
padding: 0.6rem;
border-radius: 8px;
border: 1px solid #bdd1e2;
font: inherit;
}
textarea {
min-height: 92px;
resize: vertical;
}
button {
background: var(--accent);
color: #fff;
border: none;
cursor: pointer;
}
.btn-link {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.58rem 0.9rem;
border-radius: 8px;
background: #0f5b94;
color: #fff;
text-decoration: none;
font-weight: 600;
}
.btn-link.secondary {
background: #e7eef5;
color: #16344f;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
padding: 1rem 1.25rem;
background: #fff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1rem;
padding: 1rem;
}
.admin-layout {
display: grid;
grid-template-columns: 1fr;
gap: 1rem;
padding: 1rem;
max-width: 1400px;
margin: 0 auto;
}
.admin-intro {
border: 1px solid var(--line);
border-radius: 10px;
background: #f8fbfe;
padding: 0.8rem 0.9rem;
color: #2b4760;
line-height: 1.4;
}
.summary-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.6rem;
margin: 0.7rem 0 0.8rem;
}
.summary-card {
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
padding: 0.65rem 0.75rem;
}
.summary-label {
color: #53718c;
font-size: 0.8rem;
}
.summary-value {
margin-top: 0.2rem;
font-weight: 700;
color: #14354f;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.labelled-grid {
gap: 0.7rem;
}
.field-col {
display: grid;
gap: 0.32rem;
}
.field-col > span {
font-size: 0.83rem;
color: #44627d;
font-weight: 600;
}
.field-help {
margin: -0.2rem 0 0.4rem;
color: #52708a;
font-size: 0.85rem;
}
.admin-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.admin-table th,
.admin-table td {
border-bottom: 1px solid #e2e8ef;
padding: 0.45rem 0.35rem;
text-align: left;
vertical-align: top;
}
.actions {
display: flex;
gap: 0.35rem;
}
.acl-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.35rem;
margin: 0.6rem 0;
}
.tab-row {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.tab-btn {
background: #e7eef5;
color: #15344f;
}
.active-tab {
background: #0f5b94;
color: #fff;
}
.split {
display: grid;
grid-template-columns: 320px 1fr;
gap: 1rem;
}
.list-title {
font-weight: 600;
margin-bottom: 0.4rem;
}
.list-box {
border: 1px solid var(--line);
border-radius: 10px;
background: #f8fbfe;
max-height: 460px;
overflow: auto;
padding: 0.3rem;
}
.list-search {
width: 100%;
box-sizing: border-box;
margin-bottom: 0.45rem;
background: #fff;
}
.list-item {
width: 100%;
text-align: left;
margin-bottom: 0.35rem;
background: #fff;
color: #17354f;
border: 1px solid #d6e1eb;
}
.list-item.selected-item {
border-color: #4b8fc4;
box-shadow: inset 0 0 0 1px rgba(75, 143, 196, 0.35);
background: #f1f8ff;
}
.service-row {
display: flex;
align-items: center;
gap: 0.6rem;
}
.service-thumb {
width: 40px;
height: 40px;
border-radius: 8px;
object-fit: cover;
border: 1px solid #d8e3ed;
background: #edf3f9;
flex: 0 0 40px;
}
.service-icon-preview {
width: 80px;
height: 80px;
border-radius: 10px;
object-fit: cover;
border: 1px solid #d8e3ed;
background: #edf3f9;
}
.icon-row {
display: flex;
gap: 0.8rem;
align-items: center;
}
.status-dot {
display: inline-block;
width: 9px;
height: 9px;
border-radius: 999px;
margin-right: 0.35rem;
}
.status-ok { background: #17a35d; }
.status-degraded { background: #f1a312; }
.status-down { background: #d33d3d; }
.health-box, .icon-box {
border: 1px solid #d6e1eb;
background: #f8fbfe;
border-radius: 10px;
padding: 0.8rem;
}
.container-table-wrap {
margin-top: 0.6rem;
max-height: 220px;
overflow: auto;
border: 1px solid #d6e1eb;
border-radius: 8px;
background: #fff;
}
.muted {
color: #4b6178;
font-size: 0.86rem;
}
@media (max-width: 900px) {
.split {
grid-template-columns: 1fr;
}
.brand-logo-fullscreen {
width: min(42vw, 260px);
max-height: 20vh;
}
}
.tile {
display: block;
text-decoration: none;
background: var(--card);
color: inherit;
border-radius: 12px;
padding: 1rem;
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.06);
border: 1px solid transparent;
transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease;
}
.tile:hover {
transform: translateY(-2px);
border-color: #bdd3e6;
box-shadow: 0 14px 26px rgba(15, 64, 103, 0.12);
}
.tile-icon {
width: 56px;
height: 56px;
border-radius: 10px;
object-fit: cover;
border: 1px solid #d8e3ed;
background: #edf3f9;
margin-bottom: 0.5rem;
}
.tile h3 {
margin: 0.1rem 0 0.25rem;
}
.tile p {
margin: 0;
color: #48637c;
}
.tile small {
display: block;
margin-top: 0.45rem;
color: #4b6178;
}

871
app/templates/admin.html Normal file
View File

@@ -0,0 +1,871 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Администрирование</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<header class="header">
<div style="display:flex; align-items:center; gap:0.6rem;">
<img src="/static/logo.png" alt="MONT" class="header-logo" />
<div>МОНТ - инфра полигон | Админ: {{ admin.username }}</div>
</div>
<a href="/" class="btn-link secondary">Главная панель</a>
</header>
<main class="admin-layout">
<section class="panel">
<div class="admin-intro">
Основной режим: <b>WEB</b>. Пользователь выбирает сервис, а портал открывает нужный URL в заранее прогретом браузере.
Поле <b>pool size</b> задаёт, сколько таких прогретых контейнеров держать для конкретного сервиса.
</div>
</section>
<section class="panel">
<div class="tab-row">
<button class="tab-btn" data-tab="users" onclick="showTab('users')">Users</button>
<button class="tab-btn" data-tab="web" onclick="showTab('web')">WEB</button>
<button class="tab-btn" data-tab="rdp" onclick="showTab('rdp')">RDP</button>
<button class="tab-btn" data-tab="stats" onclick="showTab('stats')">Stats</button>
</div>
</section>
<section id="tab-users" class="panel admin-tab">
<h3>Пользователи</h3>
<div class="split">
<div>
<div class="list-title">Список пользователей</div>
<input class="list-search" id="users_search" placeholder="Поиск пользователя..." oninput="filterList('users_search', '#users_list .user-item')" />
<div class="list-box" id="users_list">
{% for u in users %}
<button class="list-item user-item" data-user-id="{{u.id}}" data-filter="{{u.username|lower}}" onclick='selectUser({{u.id}}, {{u.username|tojson}}, {{u.active|tojson}}, {{u.is_admin|tojson}}, {{u.expires_at.isoformat()|tojson}})'>
<div>{{u.username}}</div>
<small class="user-days" data-exp="{{u.expires_at.isoformat()}}"></small>
</button>
{% endfor %}
</div>
</div>
<div>
<div class="list-title">Редактирование пользователя</div>
<div class="form-grid">
<input id="u_id" type="hidden" />
<input id="u_name" placeholder="username" />
<input id="u_exp" type="date" required />
<input id="u_pwd" placeholder="new password (optional)" type="password" />
<select id="u_active"><option value="true">active</option><option value="false">inactive</option></select>
<select id="u_admin"><option value="false">user</option><option value="true">admin</option></select>
</div>
<div class="actions">
<button onclick="saveUser()">Save</button>
<button onclick="deleteUser()">Delete</button>
<button onclick="clearUserForm()">Clear</button>
</div>
<div style="margin-top:1rem;">
<div class="list-title">ACL выбранного пользователя</div>
<div class="acl-grid">
{% for s in services %}
<label><input type="checkbox" class="acl_service" value="{{s.id}}" /> {{s.name}} ({{s.slug}})</label>
{% endfor %}
</div>
<button onclick="saveAclForSelectedUser()">Save ACL</button>
</div>
<hr>
<div class="list-title">Добавить пользователя</div>
<div class="form-grid">
<input id="new_u_name" placeholder="username" />
<input id="new_u_pwd" placeholder="password" type="password" />
<input id="new_u_exp" type="date" required />
<select id="new_u_active"><option value="true">active</option><option value="false">inactive</option></select>
<select id="new_u_admin"><option value="false">user</option><option value="true">admin</option></select>
</div>
<button onclick="createUser()">Add User</button>
</div>
</div>
</section>
<section id="tab-web" class="panel admin-tab" style="display:none;">
<h3>WEB сервисы</h3>
<div class="admin-intro">
<b>Как читать статусы:</b> в строке сервиса <b>прогрето X / Y</b> и <b>занято: N</b> относятся к этому конкретному сервису.
</div>
<div class="summary-strip">
<div class="summary-card">
<div class="summary-label">WEB сервисов</div>
<div class="summary-value">{{ web_totals.services }}</div>
</div>
<div class="summary-card">
<div class="summary-label">Всего прогрето</div>
<div class="summary-value">{{ web_totals.running }} / {{ web_totals.desired }}</div>
</div>
<div class="summary-card">
<div class="summary-label">Сейчас занято</div>
<div class="summary-value">{{ web_totals.active_sessions }}</div>
</div>
</div>
<div class="split">
<div>
<div class="list-title">Список WEB</div>
<input class="list-search" id="web_search" placeholder="Поиск WEB сервиса..." oninput="filterList('web_search', '#web_list .web-item')" />
<div class="list-box" id="web_list">
{% for s in web_services %}
<button class="list-item service-row web-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectWebService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.warm_pool_size}})'>
<img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
<div>
<div>{{s.name}}</div>
<small>
<span class="status-dot status-{{ service_health[s.id].health }}"></span>
{{service_health[s.id].health}} | прогрето: {{service_health[s.id].running}} / {{service_health[s.id].desired}} | занято: {{service_health[s.id].active_sessions}}
</small>
</div>
</button>
{% endfor %}
</div>
</div>
<div>
<div class="list-title">Редактирование WEB</div>
<div class="field-help">Для пользователей показывается только название и описание. Технический тип скрыт.</div>
<div class="form-grid labelled-grid">
<input id="w_id" type="hidden" />
<input id="w_slug" type="hidden" />
<label class="field-col">
<span>Название сервиса</span>
<input id="w_name" placeholder="Например: CRM, Router, Wiki" oninput="autogenSlug('w_name','w_slug')" />
</label>
<label class="field-col">
<span>URL для открытия</span>
<input id="w_target" placeholder="Например: https://crm.company.local" />
</label>
<label class="field-col">
<span>Описание для пользователя</span>
<textarea id="w_comment" placeholder="Коротко: что это за сервис"></textarea>
</label>
<label class="field-col">
<span>Сколько держать прогретых</span>
<input id="w_pool" type="number" min="0" placeholder="Например: 2" />
</label>
<label class="field-col">
<span>Статус</span>
<select id="w_active"><option value="true">active</option><option value="false">inactive</option></select>
</label>
</div>
<div class="actions">
<button onclick="saveWebService()">Save</button>
<button onclick="prewarmNow('w_id')">Prewarm now</button>
<button onclick="deleteService('w_id')">Delete</button>
<button onclick="clearWebForm()">Clear</button>
</div>
<div class="health-box" id="w_health_box" style="display:none; margin-top:1rem;">
<div class="list-title">Container Health</div>
<div id="w_health_summary"></div>
<div class="actions" style="margin-top:0.5rem;">
<button onclick="refreshSelectedServiceStatus('web')">Refresh status</button>
<button id="w_health_toggle" onclick="toggleHealthDetails('w')">Показать детали контейнеров</button>
</div>
<div class="container-table-wrap" id="w_health_table_wrap" style="display:none;">
<table class="admin-table" id="w_health_table">
<thead>
<tr>
<th>name</th>
<th>state</th>
<th>status</th>
<th>image</th>
<th>labels</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="icon-box" style="margin-top:1rem;">
<div class="list-title">Иконка сервиса</div>
<div class="icon-row">
<img id="w_icon_preview" class="service-icon-preview" src="/static/service-placeholder.svg" alt="icon" />
<div>
<input id="w_icon_file" type="file" accept="image/png,image/jpeg,image/webp" />
<div class="actions" style="margin-top:0.4rem;">
<button onclick="uploadServiceIcon('w')">Upload</button>
<button onclick="pasteServiceIcon('w')">Paste</button>
<button onclick="removeServiceIcon('w')">Remove icon</button>
</div>
</div>
</div>
</div>
<hr>
<div class="list-title">Добавить WEB</div>
<div class="field-help">Рекомендуется начинать с pool size = 2-3 для часто используемых сервисов.</div>
<div class="form-grid labelled-grid">
<input id="new_w_slug" type="hidden" />
<label class="field-col">
<span>Название сервиса</span>
<input id="new_w_name" placeholder="Например: CRM, Router, Wiki" oninput="autogenSlug('new_w_name','new_w_slug')" />
</label>
<label class="field-col">
<span>URL для открытия</span>
<input id="new_w_target" placeholder="Например: https://crm.company.local" />
</label>
<label class="field-col">
<span>Описание для пользователя</span>
<textarea id="new_w_comment" placeholder="Коротко: что это за сервис"></textarea>
</label>
<label class="field-col">
<span>Сколько держать прогретых</span>
<input id="new_w_pool" type="number" min="0" value="2" placeholder="Например: 2" />
</label>
<label class="field-col">
<span>Статус</span>
<select id="new_w_active"><option value="true">active</option><option value="false">inactive</option></select>
</label>
</div>
<button onclick="createWebService()">Add WEB</button>
</div>
</div>
</section>
<section id="tab-rdp" class="panel admin-tab" style="display:none;">
<h3>RDP сервисы</h3>
<div class="split">
<div>
<div class="list-title">Список RDP</div>
<input class="list-search" id="rdp_search" placeholder="Поиск RDP сервиса..." oninput="filterList('rdp_search', '#rdp_list .rdp-item')" />
<div class="list-box" id="rdp_list">
{% for s in rdp_services %}
<button class="list-item service-row rdp-item" data-service-id="{{s.id}}" data-filter="{{(s.name ~ ' ' ~ s.slug)|lower}}" onclick='selectRdpService({{s.id}}, {{s.name|tojson}}, {{s.slug|tojson}}, {{s.target|tojson}}, {{s.comment|tojson}}, {{s.icon_path|tojson}}, {{s.active|tojson}}, {{s.warm_pool_size}})'>
<img class="service-thumb" src="{{ s.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
<div>
<div>{{s.name}}</div>
<small>
<span class="status-dot status-{{ service_health[s.id].health }}"></span>
{{service_health[s.id].health}} | {{service_health[s.id].running}} / {{service_health[s.id].desired}} | active: {{service_health[s.id].active_sessions}}
</small>
</div>
</button>
{% endfor %}
</div>
</div>
<div>
<div class="list-title">Редактирование RDP</div>
<div class="field-help">Используйте поля host/port/user. Поле target собирается автоматически.</div>
<div class="form-grid">
<input id="r_id" type="hidden" />
<input id="r_name" placeholder="Название сервиса (что увидит user)" oninput="autogenSlug('r_name','r_slug')" />
<input id="r_slug" placeholder="Системный slug" />
<input id="r_host" placeholder="RDP host (например 192.168.1.60)" />
<input id="r_port" type="number" min="1" max="65535" placeholder="RDP порт, обычно 3389" />
<input id="r_user" placeholder="Логин (опционально)" />
<input id="r_pass" placeholder="Пароль (опционально)" type="password" />
<input id="r_domain" placeholder="Домен (опционально)" />
<select id="r_sec">
<option value="">auto</option>
<option value="nla">nla</option>
<option value="tls">tls</option>
<option value="rdp">rdp</option>
</select>
<input id="r_target" placeholder="Собранный target (авто)" />
<textarea id="r_comment" placeholder="Описание для пользователя"></textarea>
<input id="r_pool" type="number" min="0" placeholder="Количество заранее прогретых слотов" />
<select id="r_active"><option value="true">active</option><option value="false">inactive</option></select>
</div>
<div class="actions">
<button onclick="saveRdpService()">Save</button>
<button onclick="prewarmNow('r_id')">Prewarm now</button>
<button onclick="deleteService('r_id')">Delete</button>
<button onclick="clearRdpForm()">Clear</button>
</div>
<div class="health-box" id="r_health_box" style="display:none; margin-top:1rem;">
<div class="list-title">Container Health</div>
<div id="r_health_summary"></div>
<div class="actions" style="margin-top:0.5rem;">
<button onclick="refreshSelectedServiceStatus('rdp')">Refresh status</button>
</div>
<div class="container-table-wrap">
<table class="admin-table" id="r_health_table">
<thead>
<tr>
<th>name</th>
<th>state</th>
<th>status</th>
<th>image</th>
<th>labels</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<div class="icon-box" style="margin-top:1rem;">
<div class="list-title">Иконка сервиса</div>
<div class="icon-row">
<img id="r_icon_preview" class="service-icon-preview" src="/static/service-placeholder.svg" alt="icon" />
<div>
<input id="r_icon_file" type="file" accept="image/png,image/jpeg,image/webp" />
<div class="actions" style="margin-top:0.4rem;">
<button onclick="uploadServiceIcon('r')">Upload</button>
<button onclick="pasteServiceIcon('r')">Paste</button>
<button onclick="removeServiceIcon('r')">Remove icon</button>
</div>
</div>
</div>
</div>
<hr>
<div class="list-title">Добавить RDP</div>
<div class="field-help">Для большинства кейсов достаточно host + user + password.</div>
<div class="form-grid">
<input id="new_r_name" placeholder="Название сервиса" oninput="autogenSlug('new_r_name','new_r_slug')" />
<input id="new_r_slug" placeholder="Системный slug" />
<input id="new_r_host" placeholder="RDP host" />
<input id="new_r_port" type="number" min="1" max="65535" placeholder="RDP порт (3389)" />
<input id="new_r_user" placeholder="Логин (опционально)" />
<input id="new_r_pass" placeholder="Пароль (опционально)" type="password" />
<input id="new_r_domain" placeholder="Домен (опционально)" />
<select id="new_r_sec">
<option value="">auto</option>
<option value="nla">nla</option>
<option value="tls">tls</option>
<option value="rdp">rdp</option>
</select>
<input id="new_r_target" placeholder="Собранный target (авто)" />
<textarea id="new_r_comment" placeholder="Описание для пользователя"></textarea>
<input id="new_r_pool" type="number" min="0" value="1" placeholder="Количество прогретых слотов" />
<select id="new_r_active"><option value="true">active</option><option value="false">inactive</option></select>
</div>
<button onclick="createRdpService()">Add RDP</button>
</div>
</div>
</section>
<section id="tab-stats" class="panel admin-tab" style="display:none;">
<h3>Статистика открытий</h3>
<div class="admin-intro">
Здесь видно кто, когда и какой сервис открывал, а также агрегат по количеству запусков.
</div>
<div class="split">
<div>
<div class="list-title">Топ запусков (user x service)</div>
<div class="container-table-wrap" style="max-height:520px;">
<table class="admin-table">
<thead>
<tr>
<th>user</th>
<th>service</th>
<th>slug</th>
<th>opens</th>
</tr>
</thead>
<tbody>
{% for row in open_stats %}
<tr>
<td>{{ row.username }}</td>
<td>{{ row.service_name }}</td>
<td>{{ row.service_slug }}</td>
<td>{{ row.opens }}</td>
</tr>
{% else %}
<tr><td colspan="4">Нет данных</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div>
<div class="list-title">Последние открытия</div>
<div class="container-table-wrap" style="max-height:520px;">
<table class="admin-table">
<thead>
<tr>
<th>user</th>
<th>service</th>
<th>status</th>
<th>created</th>
<th>last_access</th>
<th>session_id</th>
</tr>
</thead>
<tbody>
{% for row in recent_sessions %}
<tr>
<td>{{ row.username }}</td>
<td>{{ row.service_name }} ({{ row.service_slug }})</td>
<td>{{ row.status }}</td>
<td>{{ row.created_at }}</td>
<td>{{ row.last_access_at }}</td>
<td>{{ row.id }}</td>
</tr>
{% else %}
<tr><td colspan="6">Нет данных</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
</section>
</main>
<script>
const csrf = "{{ csrf_token }}";
const aclMap = {{ acl | tojson }};
const placeholderIcon = '/static/service-placeholder.svg';
let activeTab = 'users';
function showTab(tab) {
activeTab = tab;
localStorage.setItem('admin_active_tab', tab);
document.querySelectorAll('.admin-tab').forEach(x => x.style.display = 'none');
document.getElementById(`tab-${tab}`).style.display = 'block';
document.querySelectorAll('.tab-btn').forEach(x => x.classList.remove('active-tab'));
document.querySelector(`.tab-btn[data-tab="${tab}"]`).classList.add('active-tab');
}
function filterList(inputId, itemsSelector) {
const needle = (document.getElementById(inputId)?.value || '').trim().toLowerCase();
document.querySelectorAll(itemsSelector).forEach((item) => {
const hay = (item.dataset.filter || item.textContent || '').toLowerCase();
item.style.display = hay.includes(needle) ? '' : 'none';
});
}
function markSelected(selector, attr, value) {
document.querySelectorAll(selector).forEach((el) => {
el.classList.toggle('selected-item', String(el.getAttribute(attr)) === String(value));
});
}
function slugifyRu(text) {
const map = {
'а':'a','б':'b','в':'v','г':'g','д':'d','е':'e','ё':'e','ж':'zh','з':'z','и':'i','й':'y','к':'k','л':'l','м':'m','н':'n','о':'o','п':'p','р':'r','с':'s','т':'t','у':'u','ф':'f','х':'h','ц':'ts','ч':'ch','ш':'sh','щ':'sch','ъ':'','ы':'y','ь':'','э':'e','ю':'yu','я':'ya'
};
const lower = (text || '').toLowerCase();
let out = '';
for (const ch of lower) out += map[ch] !== undefined ? map[ch] : ch;
return out.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').replace(/-{2,}/g, '-');
}
function autogenSlug(srcId, dstId) {
const src = document.getElementById(srcId);
const dst = document.getElementById(dstId);
if (!dst.value || !dst.dataset.touched) dst.value = slugifyRu(src.value);
}
function dateFromIso(iso) {
return (iso || '').slice(0, 10);
}
function expiryToApi(dateStr) {
if (!dateStr) return '';
return `${dateStr}T23:59:59+00:00`;
}
function daysLeft(expIso) {
const now = new Date();
const exp = new Date(expIso);
const diff = Math.ceil((exp - now) / (1000 * 60 * 60 * 24));
if (Number.isNaN(diff)) return 'дата некорректна';
if (diff < 0) return `просрочен ${Math.abs(diff)} дн`;
return `до деактивации: ${diff} дн`;
}
function renderUserDays() {
document.querySelectorAll('.user-days').forEach(el => {
el.textContent = daysLeft(el.dataset.exp || '');
});
}
async function api(url, method, body) {
const r = await fetch(url, {
method,
credentials: 'include',
headers: {'Content-Type': 'application/json', 'X-CSRF-Token': csrf},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!r.ok) {
const t = await r.text();
alert(`Request failed: ${r.status} ${t}`);
throw new Error(t);
}
return r.status === 204 ? {} : r.json();
}
async function apiForm(url, formData) {
const r = await fetch(url, {
method: 'POST',
credentials: 'include',
headers: {'X-CSRF-Token': csrf},
body: formData,
});
if (!r.ok) {
const t = await r.text();
alert(`Request failed: ${r.status} ${t}`);
throw new Error(t);
}
return r.json();
}
function selectUser(id, username, active, isAdmin, expiresIso) {
document.getElementById('u_id').value = id;
document.getElementById('u_name').value = username;
document.getElementById('u_exp').value = dateFromIso(expiresIso);
document.getElementById('u_pwd').value = '';
document.getElementById('u_active').value = String(active);
document.getElementById('u_admin').value = String(isAdmin);
markSelected('.user-item', 'data-user-id', id);
syncAclForSelectedUser();
}
async function createUser() {
const expDate = document.getElementById('new_u_exp').value;
if (!expDate) return alert('Выберите дату деактивации');
await api('/api/admin/users', 'POST', {
username: document.getElementById('new_u_name').value,
password: document.getElementById('new_u_pwd').value,
expires_at: expiryToApi(expDate),
active: document.getElementById('new_u_active').value === 'true',
is_admin: document.getElementById('new_u_admin').value === 'true',
});
location.reload();
}
async function saveUser() {
const id = document.getElementById('u_id').value;
if (!id) return alert('Выберите пользователя');
const expDate = document.getElementById('u_exp').value;
if (!expDate) return alert('Выберите дату деактивации');
const payload = {
username: document.getElementById('u_name').value,
expires_at: expiryToApi(expDate),
active: document.getElementById('u_active').value === 'true',
is_admin: document.getElementById('u_admin').value === 'true',
};
const pwd = document.getElementById('u_pwd').value.trim();
if (pwd) payload.password = pwd;
await api(`/api/admin/users/${id}`, 'PUT', payload);
location.reload();
}
async function deleteUser() {
const id = document.getElementById('u_id').value;
if (!id) return alert('Выберите пользователя');
if (!confirm('Удалить пользователя?')) return;
await api(`/api/admin/users/${id}`, 'DELETE', {});
location.reload();
}
function clearUserForm() {
['u_id','u_name','u_exp','u_pwd'].forEach(id => document.getElementById(id).value = '');
document.getElementById('u_active').value = 'true';
document.getElementById('u_admin').value = 'false';
document.querySelectorAll('.acl_service').forEach(x => x.checked = false);
document.querySelectorAll('.user-item').forEach((el) => el.classList.remove('selected-item'));
}
function syncAclForSelectedUser() {
const userId = parseInt(document.getElementById('u_id').value || '0', 10);
const allowed = new Set((aclMap[userId] || []));
document.querySelectorAll('.acl_service').forEach((box) => {
box.checked = allowed.has(parseInt(box.value, 10));
});
}
async function saveAclForSelectedUser() {
const userId = document.getElementById('u_id').value;
if (!userId) return alert('Сначала выберите пользователя');
const serviceIds = [...document.querySelectorAll('.acl_service:checked')].map(x => parseInt(x.value, 10));
await api(`/api/admin/users/${userId}/acl`, 'PUT', {service_ids: serviceIds});
location.reload();
}
function selectWebService(id, name, slug, target, comment, iconPath, active, pool) {
document.getElementById('w_id').value = id;
document.getElementById('w_name').value = name;
document.getElementById('w_slug').value = slug;
document.getElementById('w_target').value = target;
document.getElementById('w_comment').value = comment || '';
document.getElementById('w_active').value = String(active);
document.getElementById('w_pool').value = pool;
document.getElementById('w_icon_preview').src = iconPath || placeholderIcon;
document.getElementById('w_health_box').style.display = 'block';
markSelected('.web-item', 'data-service-id', id);
refreshSelectedServiceStatus('web');
}
async function createWebService() {
const slug = document.getElementById('new_w_slug').value || slugifyRu(document.getElementById('new_w_name').value);
await api('/api/admin/services', 'POST', {
name: document.getElementById('new_w_name').value,
slug,
type: 'WEB',
target: document.getElementById('new_w_target').value,
comment: document.getElementById('new_w_comment').value,
warm_pool_size: parseInt(document.getElementById('new_w_pool').value || '0', 10),
active: document.getElementById('new_w_active').value === 'true',
});
location.reload();
}
async function saveWebService() {
const id = document.getElementById('w_id').value;
if (!id) return alert('Выберите WEB сервис');
const slug = document.getElementById('w_slug').value || slugifyRu(document.getElementById('w_name').value);
await api(`/api/admin/services/${id}`, 'PUT', {
name: document.getElementById('w_name').value,
slug,
type: 'WEB',
target: document.getElementById('w_target').value,
comment: document.getElementById('w_comment').value,
warm_pool_size: parseInt(document.getElementById('w_pool').value || '0', 10),
active: document.getElementById('w_active').value === 'true',
});
location.reload();
}
function clearWebForm() {
['w_id','w_name','w_slug','w_target','w_comment','w_pool'].forEach(id => document.getElementById(id).value = '');
document.getElementById('w_active').value = 'true';
document.getElementById('w_icon_preview').src = placeholderIcon;
document.getElementById('w_health_box').style.display = 'none';
document.querySelectorAll('.web-item').forEach((el) => el.classList.remove('selected-item'));
}
function parseRdpTarget(target) {
const raw = (target || '').trim();
if (!raw) return {host: '', port: '3389', user: '', pass: '', domain: '', sec: ''};
let url;
try {
url = raw.includes('://') ? new URL(raw) : new URL(`rdp://${raw}`);
} catch (e) {
return {host: '', port: '3389', user: '', pass: '', domain: '', sec: ''};
}
const params = url.searchParams;
return {
host: url.hostname || '',
port: url.port || '3389',
user: decodeURIComponent(url.username || params.get('u') || params.get('user') || ''),
pass: decodeURIComponent(url.password || params.get('p') || params.get('password') || ''),
domain: params.get('domain') || params.get('d') || '',
sec: params.get('sec') || params.get('security') || '',
};
}
function buildRdpTarget(prefix) {
const host = (document.getElementById(`${prefix}_host`)?.value || '').trim();
const port = (document.getElementById(`${prefix}_port`)?.value || '').trim() || '3389';
const user = (document.getElementById(`${prefix}_user`)?.value || '').trim();
const pass = (document.getElementById(`${prefix}_pass`)?.value || '').trim();
const domain = (document.getElementById(`${prefix}_domain`)?.value || '').trim();
const sec = (document.getElementById(`${prefix}_sec`)?.value || '').trim();
const targetInput = document.getElementById(`${prefix}_target`);
if (!host) return (targetInput?.value || '').trim();
const creds = user ? `${encodeURIComponent(user)}${pass ? `:${encodeURIComponent(pass)}` : ''}@` : '';
const query = new URLSearchParams();
if (domain) query.set('domain', domain);
if (sec) query.set('sec', sec);
const q = query.toString();
const target = `rdp://${creds}${host}:${port}${q ? `?${q}` : ''}`;
if (targetInput) targetInput.value = target;
return target;
}
function selectRdpService(id, name, slug, target, comment, iconPath, active, pool) {
const cfg = parseRdpTarget(target);
document.getElementById('r_id').value = id;
document.getElementById('r_name').value = name;
document.getElementById('r_slug').value = slug;
document.getElementById('r_target').value = target;
document.getElementById('r_host').value = cfg.host;
document.getElementById('r_port').value = cfg.port;
document.getElementById('r_user').value = cfg.user;
document.getElementById('r_pass').value = cfg.pass;
document.getElementById('r_domain').value = cfg.domain;
document.getElementById('r_sec').value = cfg.sec;
document.getElementById('r_comment').value = comment || '';
document.getElementById('r_active').value = String(active);
document.getElementById('r_pool').value = pool;
document.getElementById('r_icon_preview').src = iconPath || placeholderIcon;
document.getElementById('r_health_box').style.display = 'block';
markSelected('.rdp-item', 'data-service-id', id);
refreshSelectedServiceStatus('rdp');
}
async function createRdpService() {
const slug = document.getElementById('new_r_slug').value || slugifyRu(document.getElementById('new_r_name').value);
const target = buildRdpTarget('new_r');
await api('/api/admin/services', 'POST', {
name: document.getElementById('new_r_name').value,
slug,
type: 'RDP',
target,
comment: document.getElementById('new_r_comment').value,
warm_pool_size: parseInt(document.getElementById('new_r_pool').value || '0', 10),
active: document.getElementById('new_r_active').value === 'true',
});
location.reload();
}
async function saveRdpService() {
const id = document.getElementById('r_id').value;
if (!id) return alert('Выберите RDP сервис');
const target = buildRdpTarget('r');
await api(`/api/admin/services/${id}`, 'PUT', {
name: document.getElementById('r_name').value,
slug: document.getElementById('r_slug').value,
type: 'RDP',
target,
comment: document.getElementById('r_comment').value,
warm_pool_size: parseInt(document.getElementById('r_pool').value || '0', 10),
active: document.getElementById('r_active').value === 'true',
});
location.reload();
}
function clearRdpForm() {
['r_id','r_name','r_slug','r_target','r_host','r_port','r_user','r_pass','r_domain','r_comment','r_pool'].forEach(id => document.getElementById(id).value = '');
document.getElementById('r_sec').value = '';
document.getElementById('r_active').value = 'true';
document.getElementById('r_icon_preview').src = placeholderIcon;
document.getElementById('r_health_box').style.display = 'none';
document.querySelectorAll('.rdp-item').forEach((el) => el.classList.remove('selected-item'));
}
async function deleteService(sourceId) {
const id = document.getElementById(sourceId).value;
if (!id) return alert('Выберите сервис');
if (!confirm('Удалить сервис?')) return;
await api(`/api/admin/services/${id}`, 'DELETE', {});
location.reload();
}
async function prewarmNow(sourceId) {
const id = document.getElementById(sourceId).value;
if (!id) return alert('Выберите сервис');
await api(`/api/admin/services/${id}/prewarm`, 'POST', {});
const kind = activeTab === 'rdp' ? 'rdp' : 'web';
await refreshSelectedServiceStatus(kind);
location.reload();
}
function renderStatusInto(prefix, data) {
const summary = document.getElementById(`${prefix}_health_summary`);
const tableBody = document.querySelector(`#${prefix}_health_table tbody`);
summary.innerHTML = `
<div><span class="status-dot status-${data.health}"></span> health: <b>${data.health}</b></div>
<div>running/desired: <b>${data.running}</b> / <b>${data.desired}</b>, total: ${data.total}</div>
<div>active sessions: <b>${data.active_sessions ?? 0}</b></div>
<div class="muted">updated: ${data.updated_at}</div>
`;
tableBody.innerHTML = '';
(data.containers || []).forEach((c) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${c.name}</td>
<td>${c.state}</td>
<td>${c.status}</td>
<td>${c.image || ''}</td>
<td>${c.labels_ok ? 'ok' : 'mismatch'}</td>
`;
tableBody.appendChild(tr);
});
}
function toggleHealthDetails(prefix) {
const wrap = document.getElementById(`${prefix}_health_table_wrap`);
const btn = document.getElementById(`${prefix}_health_toggle`);
if (!wrap || !btn) return;
const opened = wrap.style.display !== 'none';
wrap.style.display = opened ? 'none' : 'block';
btn.textContent = opened ? 'Показать детали контейнеров' : 'Скрыть детали контейнеров';
}
async function refreshSelectedServiceStatus(kind) {
const kindToPrefix = {web: 'w', rdp: 'r'};
const prefix = kindToPrefix[kind];
if (!prefix) return;
const serviceId = document.getElementById(`${prefix}_id`).value;
if (!serviceId) return;
const data = await api(`/api/admin/services/${serviceId}/containers/status`, 'GET');
renderStatusInto(prefix, data);
}
async function uploadServiceIcon(prefix) {
const serviceId = document.getElementById(`${prefix}_id`).value;
if (!serviceId) return alert('Сначала выберите сервис');
const input = document.getElementById(`${prefix}_icon_file`);
if (!input.files || !input.files[0]) return alert('Выберите файл');
const form = new FormData();
form.append('file', input.files[0]);
const data = await apiForm(`/api/admin/services/${serviceId}/icon`, form);
document.getElementById(`${prefix}_icon_preview`).src = data.icon_path || placeholderIcon;
input.value = '';
location.reload();
}
async function removeServiceIcon(prefix) {
const serviceId = document.getElementById(`${prefix}_id`).value;
if (!serviceId) return alert('Сначала выберите сервис');
await api(`/api/admin/services/${serviceId}/icon`, 'DELETE', {});
document.getElementById(`${prefix}_icon_preview`).src = placeholderIcon;
location.reload();
}
async function pasteServiceIcon(prefix) {
const input = document.getElementById(`${prefix}_icon_file`);
if (!navigator.clipboard || !navigator.clipboard.read) {
return alert('Clipboard API недоступен в этом браузере');
}
try {
const items = await navigator.clipboard.read();
for (const item of items) {
const imageType = item.types.find((t) => t.startsWith('image/'));
if (!imageType) continue;
const blob = await item.getType(imageType);
const ext = imageType.split('/')[1] || 'png';
const file = new File([blob], `clipboard.${ext}`, {type: imageType});
const dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
await uploadServiceIcon(prefix);
return;
}
alert('В буфере нет изображения');
} catch (e) {
alert(`Не удалось вставить изображение: ${e}`);
}
}
setInterval(() => {
if (activeTab === 'web' && document.getElementById('w_id').value) {
refreshSelectedServiceStatus('web').catch(() => {});
}
if (activeTab === 'rdp' && document.getElementById('r_id').value) {
refreshSelectedServiceStatus('rdp').catch(() => {});
}
}, 5000);
const hashTab = (window.location.hash || '').replace('#', '');
const savedTab = localStorage.getItem('admin_active_tab');
const initialTab = ['users', 'web', 'rdp', 'stats'].includes(hashTab)
? hashTab
: (['users', 'web', 'rdp', 'stats'].includes(savedTab || '') ? savedTab : 'users');
showTab(initialTab);
renderUserDays();
</script>
</body>
</html>

View File

@@ -0,0 +1,46 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>МОНТ - инфра полигон</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<header class="header">
<div style="display:flex; align-items:center; gap:0.6rem;">
<img src="/static/logo.png" alt="MONT" class="header-logo" />
<div>{{ user.username }}</div>
</div>
<div style="display:flex; gap:0.5rem;">
{% if user.is_admin %}
<a href="/admin" class="btn-link secondary">Администрирование</a>
{% endif %}
<form method="post" action="/logout">
<button type="submit">Logout</button>
</form>
</div>
</header>
<main class="admin-layout">
<section class="panel">
<div class="admin-intro">
Выберите нужный сервис. После клика откроется готовый браузер/сеанс с заранее заданным адресом.
</div>
</section>
<section class="grid">
{% for service in services %}
<a class="tile" href="/go/{{ service.slug }}">
<img class="tile-icon" src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
<h3>{{ service.name }}</h3>
<p>Открыть сервис</p>
{% if service.comment %}
<small>{{ service.comment }}</small>
{% endif %}
</a>
{% else %}
<div class="tile">Нет назначенных сервисов</div>
{% endfor %}
</section>
</main>
</body>
</html>

23
app/templates/login.html Normal file
View File

@@ -0,0 +1,23 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>МОНТ - инфра полигон</title>
<link rel="stylesheet" href="/static/style.css" />
</head>
<body>
<main class="center-box login-page">
<img src="/static/logo.png" alt="MONT" class="brand-logo brand-logo-fullscreen" />
<h1 class="login-title">МОНТ - инфра полигон</h1>
<form method="post" action="/login" class="panel">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<label>Login</label>
<input type="text" name="username" required />
<label>Password</label>
<input type="password" name="password" required />
<button type="submit">Sign in</button>
</form>
</main>
</body>
</html>