Files
Stend_mont/app/templates/dashboard.html
T
ruslan 419b495020 feat: RDP ACL exclusivity, mobile wall, nav buttons, resolution xrandr
- RDP сервис может быть назначен только одному пользователю в ACL
- Мобильная заглушка на dashboard при ширине < 1024px
- rdp-proxy: кнопки навигации, спиннер Ожидайте, реконнект
- session_wait_page: тёмная тема, CSS спиннер
- kiosk/universal-runtime manager.py: xrandr + cvt --newmode для resolution
- Dockerfiles: x11-xserver-utils, x11-utils
2026-04-27 18:49:06 +00:00

170 lines
7.9 KiB
HTML

<!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" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="alternate icon" type="image/png" href="/static/favicon.png" />
</head>
<body class="dashboard-page">
{% raw %}<style>
#mobile-wall{display:none;position:fixed;inset:0;z-index:9999;background:linear-gradient(135deg,#0d1b2a 0%,#1a2e45 60%,#0f2137 100%);flex-direction:column;align-items:center;justify-content:center;padding:2rem;text-align:center;font-family:sans-serif}
@media(max-width:1023px){#mobile-wall{display:flex}}
.mw-icon{font-size:4rem;margin-bottom:1.2rem;filter:drop-shadow(0 0 18px rgba(42,140,214,.5))}
.mw-title{font-size:1.55rem;font-weight:800;color:#fff;margin-bottom:.7rem;letter-spacing:.01em}
.mw-sub{font-size:1rem;color:#a0b8cc;max-width:340px;line-height:1.6;margin-bottom:2rem}
.mw-badge{display:inline-flex;align-items:center;gap:.55rem;background:rgba(42,140,214,.15);border:1px solid rgba(42,140,214,.4);border-radius:999px;padding:.55rem 1.1rem;color:#6bbfff;font-size:.88rem;font-weight:600;letter-spacing:.02em}
.mw-badge svg{width:18px;height:18px;flex-shrink:0}
</style>{% endraw %}
<div id="mobile-wall">
<div class="mw-icon">🖥️</div>
<div class="mw-title">Только для компьютера</div>
<div class="mw-sub">Инфраструктурный полигон МОНТ оптимизирован для работы на ПК.<br>Пожалуйста, откройте портал с настольного компьютера или ноутбука.</div>
<div class="mw-badge">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg>
Минимальная ширина экрана: 1024 px
</div>
</div>
<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">Выход</button>
</form>
</div>
</header>
<main class="admin-layout">
<section class="panel">
<div class="admin-intro">Добро пожаловать в инфраструктурную песочницу</div>
{% if session_notice %}
<div class="session-notice">{{ session_notice }}</div>
{% endif %}
<div class="rules-banner" id="rules-banner">
<div class="rules-banner-head">
<div class="rules-banner-title">Правила работы стенда</div>
</div>
<div class="rules-banner-grid">
<div class="rules-pill">Лимит: до 4 сервисов одновременно. При открытии нового сверх лимита самый старый закрывается автоматически.</div>
<div class="rules-pill">При бездействии более 5 минут сессия закрывается автоматически.</div>
<div class="rules-pill">Все сервисы работают в защищённом контуре с резервированием и бэкапами.</div>
<div class="rules-pill">Состояние сервисов возвращается к базовому каждую ночь в 00:00.</div>
</div>
<div class="rules-banner-actions">
<button type="button" class="rules-ack-btn" id="rules-ack-btn">Ознакомлен</button>
</div>
</div>
{% if categories %}
<div class="category-strip">
<a class="category-chip {% if not selected_category_slug %}active{% endif %}" href="/">Все сервисы</a>
{% for category in categories %}
<a class="category-chip {% if selected_category_slug == category.slug %}active{% endif %}" href="/?category={{ category.slug }}">{{ category.name }}</a>
{% endfor %}
</div>
{% endif %}
</section>
<section class="grid service-grid">
{% for service in services %}
{% set svc_cats = service_categories.get(service.id, []) %}
<a class="tile" href="/go/{{ service.slug }}">
<div class="tile-icon-box">
<img class="tile-icon" src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="icon" />
</div>
<h3>{{ service.name }}</h3>
<p>Открыть сервис</p>
{% if service.comment %}
<small class="tile-comment">{{ service_comment_html.get(service.id, '') }}</small>
{% endif %}
{% if svc_cats %}
<div class="service-categories">
{% for category in svc_cats %}
<span class="service-cat-badge">{{ category.name }}</span>
{% endfor %}
</div>
{% endif %}
</a>
{% else %}
<div class="tile">
{% if selected_category_slug %}
Нет сервисов в выбранной категории
{% else %}
Нет назначенных сервисов
{% endif %}
</div>
{% endfor %}
</section>
<footer class="made-by-wrap"><a class="made-by" href="mailto:rgalyaviev@mont.com">Made by Galyaviev</a></footer>
</main>
<script>
(function () {
const username = {{ user.username|tojson }};
const key = `rules_ack_${username}`;
const banner = document.getElementById('rules-banner');
const btn = document.getElementById('rules-ack-btn');
if (!banner || !btn) return;
if (localStorage.getItem(key) === '1') {
banner.style.display = 'none';
return;
}
btn.addEventListener('click', function () {
localStorage.setItem(key, '1');
banner.style.display = 'none';
});
})();
(function () {
function clamp(value, min, max) {
return Math.max(min, Math.min(max, value));
}
function currentScreenParams() {
const screenWidth =
window.screen && Number.isFinite(window.screen.width) && window.screen.width > 0
? window.screen.width
: null;
const screenHeight =
window.screen && Number.isFinite(window.screen.height) && window.screen.height > 0
? window.screen.height
: null;
const viewportWidth =
(window.visualViewport && window.visualViewport.width) ||
window.innerWidth ||
document.documentElement.clientWidth ||
1280;
const viewportHeight =
(window.visualViewport && window.visualViewport.height) ||
window.innerHeight ||
document.documentElement.clientHeight ||
720;
// Prefer stable screen dimensions; viewport is fallback.
const width = clamp(Math.round(screenWidth || viewportWidth), 320, 7680);
const height = clamp(Math.round(screenHeight || viewportHeight), 240, 4320);
const sp = new URLSearchParams();
sp.set('sw', String(width));
sp.set('sh', String(height));
return sp;
}
document.querySelectorAll('a.tile[href^="/go/"]').forEach(function (link) {
link.addEventListener('click', function () {
try {
const url = new URL(link.getAttribute('href'), window.location.origin);
const params = currentScreenParams();
url.search = params.toString();
link.setAttribute('href', url.pathname + '?' + url.searchParams.toString());
} catch (e) {}
}, { capture: true });
});
})();
</script>
</body>
</html>