feat: redesign portal UX and stabilize web session runtime
This commit is contained in:
11
app/Dockerfile
Normal file
11
app/Dockerfile
Normal 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
1613
app/main.py
Normal file
File diff suppressed because it is too large
Load Diff
9
app/requirements.txt
Normal file
9
app/requirements.txt
Normal 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
BIN
app/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
0
app/static/service-icons/.gitkeep
Normal file
0
app/static/service-icons/.gitkeep
Normal file
BIN
app/static/service-icons/svc_4_20260306_134658.png
Normal file
BIN
app/static/service-icons/svc_4_20260306_134658.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
BIN
app/static/service-icons/svc_5_20260306_134448.png
Normal file
BIN
app/static/service-icons/svc_5_20260306_134448.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
BIN
app/static/service-icons/svc_6_20260306_134739.png
Normal file
BIN
app/static/service-icons/svc_6_20260306_134739.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
BIN
app/static/service-icons/svc_7_20260306_143024.png
Normal file
BIN
app/static/service-icons/svc_7_20260306_143024.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
7
app/static/service-placeholder.svg
Normal file
7
app/static/service-placeholder.svg
Normal 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
330
app/static/style.css
Normal 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
871
app/templates/admin.html
Normal 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>
|
||||
46
app/templates/dashboard.html
Normal file
46
app/templates/dashboard.html
Normal 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
23
app/templates/login.html
Normal 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>
|
||||
Reference in New Issue
Block a user