Files
Stend_mont/app/templates/dashboard.html
T
ruslan ae630f9115 Pilots: separate admin tab, collapsible dashboard section
Admin: moved pilot management out of the RDP tab into its own
"Pilots" tab - dedicated list, create/edit form and slot table with
a per-slot user-assign dropdown (always on, no more is_pilot
checkbox toggling the RDP tab's slot table between two modes). The
RDP tab goes back to exactly its pre-pilots shape.

Dashboard: a granted pilot no longer sits inside the "Ваши сервисы"
grid - it gets its own <details> section above it, open by default
whenever the user has at least one pilot (and simply absent when
they have none, rather than showing empty and collapsed).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-02 06:51:18 +00:00

479 lines
29 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>MONT - инфрастуктурный полигон</title>
<link rel="stylesheet" href="/static/style.css?v=2" />
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon_32.png" />
<link rel="icon" type="image/png" sizes="192x192" href="/static/favicon_192.png" />
<link rel="apple-touch-icon" sizes="192x192" href="/static/favicon_192.png" />
<link rel="manifest" href="/static/manifest.json" />
<!-- Yandex.Metrika counter -->
<script type="text/javascript">
(function(m,e,t,r,i,k,a){
m[i]=m[i]||function(){(m[i].a=m[i].a||[]).push(arguments)};
m[i].l=1*new Date();
for (var j = 0; j < document.scripts.length; j++) {if (document.scripts[j].src === r) { return; }}
k=e.createElement(t),a=e.getElementsByTagName(t)[0],k.async=1,k.src=r,a.parentNode.insertBefore(k,a)
})(window, document,"script","https://mc.yandex.ru/metrika/tag.js?id=109119977", "ym");
ym(109119977, "init", {ssr:true, webvisor:true, clickmap:true, ecommerce:"dataLayer", referrer: document.referrer, url: location.href, accurateTrackBounce:true, trackLinks:true});
</script>
<noscript><div><img src="https://mc.yandex.ru/watch/109119977" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter -->
<style>
#mobile-wall{display:none;position:fixed;top:0;left:0;width:100vw;height:100vh;z-index:9999;background:linear-gradient(135deg,#0d1b2a 0%,#1a2e45 60%,#0f2137 100%);flex-direction:column;align-items:center;justify-content:center;padding:clamp(1rem,6vw,2.5rem);text-align:center;font-family:sans-serif;box-sizing:border-box;overflow:hidden}
@media(max-width:1023px){ #mobile-wall{display:flex} }
.mw-icon{font-size:clamp(2.2rem,12vw,3.5rem);margin-bottom:clamp(.5rem,2vw,1.2rem);filter:drop-shadow(0 0 18px rgba(42,140,214,.5))}
.mw-title{font-size:clamp(1rem,5.5vw,1.5rem);font-weight:800;color:#fff;margin-bottom:.6rem;letter-spacing:.01em;word-break:break-word;width:100%}
.mw-sub{font-size:clamp(.8rem,3.8vw,.95rem);color:#a0b8cc;width:100%;max-width:320px;line-height:1.6;margin-bottom:clamp(.8rem,4vw,2rem);word-break:break-word;overflow-wrap:break-word}
.mw-badge{display:inline-flex;align-items:center;gap:.45rem;background:rgba(42,140,214,.15);border:1px solid rgba(42,140,214,.4);border-radius:999px;padding:.45rem .9rem;color:#6bbfff;font-size:clamp(.7rem,3.2vw,.85rem);font-weight:600;max-width:88vw;flex-wrap:wrap;justify-content:center;word-break:break-word}
.mw-badge svg{width:16px;height:16px;flex-shrink:0}
.mw-footer{position:absolute;bottom:1.2rem;left:0;width:100%;text-align:center;font-size:clamp(.65rem,2.8vw,.78rem);color:rgba(160,184,204,.45);font-family:sans-serif}
:root{
--dv-bg:#eef1f5; --dv-panel:#fff; --dv-raised:#fff;
--dv-line:#d7dee6; --dv-line-soft:#e3e8ee;
--dv-fg:#16202c; --dv-fg-dim:#5c6b7c; --dv-fg-faint:#93a1b0;
--dv-accent:#c96c14; --dv-accent-dim:rgba(201,108,20,.10); --dv-accent-ink:#2a1400;
--dv-good:#1f9d63;
}
/* style.css has several older `.dashboard-page{background:...!important}`
sky-gradient rules left over from the previous design - beat them with
!important too, otherwise they win regardless of source order. */
.dashboard-page{background:var(--dv-bg) !important;color:var(--dv-fg);
font-family:"Ubuntu","IBM Plex Sans",system-ui,sans-serif;min-height:100vh;overflow-x:hidden}
.dashboard-page *{box-sizing:border-box;min-width:0}
.dashboard-page a{color:inherit;text-decoration:none}
.dv-shell{display:grid;grid-template-columns:248px minmax(0,1fr);min-height:100vh}
@media (max-width:980px){ .dv-shell{grid-template-columns:minmax(0,1fr)} }
.dv-rail{border-right:1px solid var(--dv-line);padding:26px 16px 26px 24px;position:sticky;top:0;
align-self:start;height:100vh;overflow:auto;display:flex;flex-direction:column;gap:22px}
@media (max-width:980px){ .dv-rail{position:static;height:auto;border-right:none;border-bottom:1px solid var(--dv-line);padding:20px 18px} }
.dv-rail-brand{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
.dv-rail-brand img{height:30px}
.dv-rail-kicker{font:700 11px/1 "Ubuntu Mono",monospace;letter-spacing:.16em;color:var(--dv-fg-faint);text-transform:uppercase;margin:0 0 8px 10px}
.dv-rail-nav{display:flex;flex-direction:column;gap:2px}
@media (max-width:980px){ .dv-rail-nav{flex-direction:row;flex-wrap:wrap;gap:6px} }
.dv-rail-link{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:6px;
font:500 14px/1.3 "Ubuntu",sans-serif;color:var(--dv-fg-dim);border:1px solid transparent}
.dv-rail-link:hover{background:var(--dv-panel);color:var(--dv-fg);border-color:var(--dv-line)}
.dv-rail-dot{width:6px;height:6px;border-radius:50%;background:var(--dv-fg-faint);flex-shrink:0}
.dv-rail-link:hover .dv-rail-dot{background:var(--dv-accent)}
.dv-rail-count{margin-left:auto;font:600 11px/1 "Ubuntu Mono",monospace;color:var(--dv-fg-faint)}
.dv-rail-link.active{background:var(--dv-accent-dim);border-color:var(--dv-accent);color:var(--dv-fg)}
.dv-rail-link.active .dv-rail-dot{background:var(--dv-accent)}
.dv-rail-link.active .dv-rail-count{color:var(--dv-accent)}
.dv-rail-foot{margin-top:auto;font:400 12px/1.6 "Ubuntu Mono",monospace;color:var(--dv-fg-faint)}
@media (max-width:980px){ .dv-rail-foot{display:none} }
.dv-main{padding:0 clamp(18px,3vw,48px) 70px;min-width:0}
.dv-topbar{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:20px 0;
border-bottom:1px solid var(--dv-line);flex-wrap:wrap}
.dv-who{display:flex;align-items:center;gap:10px}
.dv-who-avatar{width:32px;height:32px;border-radius:8px;background:var(--dv-raised);border:1px solid var(--dv-line);
display:flex;align-items:center;justify-content:center;font:700 13px/1 "Ubuntu Mono",monospace;color:var(--dv-accent)}
.dv-who-name{font:500 14.5px/1.2 "Ubuntu",sans-serif}
.dv-who-role{font:400 12px/1.2 "Ubuntu Mono",monospace;color:var(--dv-fg-faint)}
.dv-topbar-actions{display:flex;gap:8px}
.dv-btn{font:600 13px/1 "Ubuntu",sans-serif;padding:9px 16px;border-radius:7px;border:1px solid var(--dv-line);
background:var(--dv-panel);color:var(--dv-fg-dim);cursor:pointer}
.dv-btn:hover{color:var(--dv-fg);border-color:var(--dv-fg-faint)}
.dv-hero{padding:32px 0 30px;border-bottom:1px solid var(--dv-line)}
.dv-hero-eyebrow{font:700 12.5px/1 "Ubuntu Mono",monospace;letter-spacing:.16em;text-transform:uppercase;color:var(--dv-accent);margin:0 0 10px}
.dv-hero h1{font:800 clamp(28px,3.8vw,40px)/1.1 "Ubuntu",sans-serif;letter-spacing:-.01em;margin:0 0 4px;overflow-wrap:break-word}
.dv-filter-note{font:400 13.5px/1.4 "Ubuntu",sans-serif;color:var(--dv-fg-faint);margin:10px 0 0}
.dv-filter-note b{color:var(--dv-accent)}
.dv-filter-note a{margin-left:8px;text-decoration:underline;color:var(--dv-fg-faint)}
.dv-readouts{display:flex;flex-wrap:wrap;gap:0;border:1px solid var(--dv-line);border-radius:10px;overflow:hidden;width:fit-content;margin-top:16px}
.dv-readout{padding:12px 22px;border-right:1px solid var(--dv-line)}
.dv-readout:last-child{border-right:none}
.dv-readout-val{font:700 23px/1 "Ubuntu Mono",monospace;font-variant-numeric:tabular-nums}
.dv-readout-label{font:600 10.5px/1 "Ubuntu Mono",monospace;letter-spacing:.08em;text-transform:uppercase;color:var(--dv-fg-faint);margin-top:6px}
.dv-section{padding:36px 0 6px}
.dv-section-head{display:flex;align-items:baseline;gap:12px;margin-bottom:16px}
.dv-section-head h2{font:700 13.5px/1 "Ubuntu Mono",monospace;letter-spacing:.1em;text-transform:uppercase;margin:0}
.dv-section-head::after{content:"";flex:1;height:1px;background:var(--dv-line)}
.dv-section-count{font:600 12px/1 "Ubuntu Mono",monospace;color:var(--dv-fg-faint)}
.dv-pilots-section{padding:28px 0 6px}
.dv-pilots-summary{cursor:pointer;list-style:none;user-select:none}
.dv-pilots-summary::-webkit-details-marker{display:none}
.dv-pilots-summary::before{
content:"▸";display:inline-block;color:var(--dv-accent);font-size:11px;margin-right:2px;
transition:transform .15s;
}
.dv-pilots-section[open] .dv-pilots-summary::before{transform:rotate(90deg)}
.dv-pilots-toggle-hint{font:400 11px/1 "Ubuntu",sans-serif;color:var(--dv-fg-faint)}
.dv-pilots-section .dv-tile-grid{margin-top:16px}
.dv-empty{padding:18px 0;color:var(--dv-fg-faint);font:400 13px/1.5 "Ubuntu",sans-serif}
.dv-tile-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:14px}
.dv-tile{position:relative;background:var(--dv-panel);border:1px solid var(--dv-line);border-radius:12px;
padding:16px;display:flex;flex-direction:column;gap:10px;height:266px;
transition:transform .15s,border-color .15s}
.dv-tile:hover{border-color:var(--dv-accent);transform:translateY(-2px)}
.dv-tile-hit{position:absolute;inset:0;z-index:0;border-radius:12px}
.dv-tile-top{display:flex;align-items:center;gap:12px;position:relative;z-index:1;pointer-events:none}
.dv-tile-plate{flex:none;width:84px;height:84px;border-radius:14px;background:var(--dv-raised);border:1px solid var(--dv-line-soft);
display:flex;align-items:center;justify-content:center}
.dv-tile-plate img{width:60px;height:60px;object-fit:contain}
.dv-tile-name{font:600 15px/1.3 "Ubuntu",sans-serif;overflow-wrap:break-word}
.dv-tile-desc{
margin:0;font:400 13.5px/1.55 "Ubuntu",sans-serif;color:var(--dv-fg-dim);position:relative;z-index:1;
flex:1;min-height:0;overflow-y:auto;overflow-x:hidden;pointer-events:auto;padding-right:2px;
}
.dv-tile-desc p{margin:0}
.dv-tile-desc::-webkit-scrollbar{width:5px}
.dv-tile-desc::-webkit-scrollbar-thumb{background:var(--dv-line);border-radius:3px}
.dv-tile-desc::-webkit-scrollbar-thumb:hover{background:var(--dv-fg-faint)}
.dv-tile-enter{
position:relative;z-index:2;margin-top:auto;align-self:flex-start;padding:7px 14px;border-radius:7px;
border:1px solid var(--dv-line);background:transparent;color:var(--dv-fg-dim);font:600 12.5px/1 "Ubuntu",sans-serif;cursor:pointer;
}
.dv-tile:hover .dv-tile-enter{border-color:var(--dv-accent);color:var(--dv-accent)}
.dv-tile-led{position:absolute;top:14px;right:14px;width:6px;height:6px;border-radius:50%;background:var(--dv-good);box-shadow:0 0 6px var(--dv-good);z-index:1}
.dv-tile.locked{opacity:.6;filter:grayscale(.4)}
.dv-tile.locked:hover{border-color:var(--dv-line);transform:none}
.dv-tile.locked .dv-tile-plate img{filter:grayscale(1)}
.dv-tile-lock{position:absolute;top:14px;right:14px;font-size:13px;opacity:.7;z-index:1}
.dv-tile-request{
position:relative;z-index:2;margin-top:auto;align-self:flex-start;padding:7px 14px;border-radius:7px;
border:1px dashed var(--dv-fg-faint);background:transparent;color:var(--dv-fg-faint);font:600 12.5px/1 "Ubuntu",sans-serif;cursor:pointer;
}
.dv-tile-request:hover{border-color:var(--dv-accent);border-style:solid;color:var(--dv-accent)}
.dv-pilot-badge{
display:inline-block;font:700 10px/1 "Ubuntu Mono",monospace;letter-spacing:.04em;
color:#0a1929;background:var(--dv-accent);border-radius:4px;padding:2px 5px;vertical-align:middle;
}
.dv-tile-invite{
position:relative;z-index:2;margin-top:auto;align-self:flex-start;padding:7px 14px;border-radius:7px;
border:1px solid var(--dv-line);background:transparent;color:var(--dv-fg-faint);font:600 12.5px/1 "Ubuntu",sans-serif;
}
.dv-page-footer{margin-top:50px;padding-top:20px;border-top:1px solid var(--dv-line);display:flex;
justify-content:space-between;flex-wrap:wrap;gap:8px;font:400 12px/1 "Ubuntu Mono",monospace;color:var(--dv-fg-faint)}
/* --- request-more-access modal --- */
.req-modal-overlay{display:none;position:fixed;inset:0;z-index:9000;background:rgba(6,9,14,.72);
align-items:center;justify-content:center;padding:1rem}
.req-modal-overlay.open{display:flex}
.req-modal{background:var(--dv-panel);border:1px solid var(--dv-line);border-radius:14px;width:100%;max-width:420px;
box-shadow:0 24px 80px rgba(0,0,0,.45);padding:22px 22px 20px}
.req-modal h3{margin:0 0 4px;font:700 17px/1.3 "Ubuntu",sans-serif}
.req-modal .sub{margin:0 0 16px;font:400 12.5px/1.5 "Ubuntu",sans-serif;color:var(--dv-fg-faint)}
.req-list{display:flex;flex-direction:column;gap:8px;margin-bottom:16px;max-height:220px;overflow-y:auto}
.req-item{display:flex;align-items:center;gap:10px;padding:8px 10px;border:1px solid var(--dv-line);border-radius:8px;font:500 13px/1.3 "Ubuntu",sans-serif}
.req-item input{accent-color:var(--dv-accent);width:15px;height:15px}
.req-note{width:100%;box-sizing:border-box;background:var(--dv-raised);border:1px solid var(--dv-line);border-radius:8px;
padding:9px 11px;color:var(--dv-fg);font:400 13px/1.5 "Ubuntu",sans-serif;resize:vertical;min-height:56px;margin-bottom:16px}
.req-note::placeholder{color:var(--dv-fg-faint)}
.req-actions{display:flex;justify-content:flex-end;gap:10px}
.req-cancel{padding:9px 16px;border-radius:8px;border:1px solid var(--dv-line);background:transparent;color:var(--dv-fg-dim);font:600 12.5px/1 "Ubuntu",sans-serif;cursor:pointer}
.req-submit{padding:9px 18px;border-radius:8px;border:none;background:var(--dv-accent);color:var(--dv-accent-ink);font:700 12.5px/1 "Ubuntu",sans-serif;cursor:pointer}
.req-submit:disabled{opacity:.55;cursor:default}
.req-error{background:rgba(255,107,107,.12);border:1px solid rgba(255,107,107,.35);color:#ff6b6b;
border-radius:8px;padding:8px 11px;font:500 12.5px/1.4 "Ubuntu",sans-serif;margin-bottom:14px;display:none}
.req-success{text-align:center;padding:10px 0 4px}
.req-success .ico{font-size:30px;margin-bottom:10px;color:var(--dv-good)}
.req-success h4{margin:0 0 6px;font:700 16px/1.3 "Ubuntu",sans-serif}
.req-success p{margin:0;font:400 12.5px/1.5 "Ubuntu",sans-serif;color:var(--dv-fg-dim)}
</style>
</head>
<body class="dashboard-page">
<div id="mobile-wall">
<a href="https://4mont.ru"><img src="/static/logo.png?v=4" alt="MONT" style="position:absolute;top:1.2rem;left:50%;transform:translateX(-50%);height:clamp(4rem,16vw,6rem);opacity:.9"></a>
<div class="mw-icon">🖥️</div>
<div class="mw-title">Только для компьютера</div>
<div class="mw-sub">Инфраструктурный полигон MONT оптимизирован для работы на ПК.<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 class="mw-footer"><a href="mailto:ruslan@ipcom.su" style="color:inherit;text-decoration:none">Made by Galyaviev</a></div>
</div>
<div class="dv-shell">
<aside class="dv-rail">
<div class="dv-rail-brand"><a href="https://4mont.ru"><img src="/static/logo-color.png?v=1" alt="MONT" /></a></div>
<div>
<div class="dv-rail-kicker">Категории</div>
<nav class="dv-rail-nav">
<a class="dv-rail-link {% if not selected_category_slug %}active{% endif %}" href="/">
<span class="dv-rail-dot" style="{% if not selected_category_slug %}background:var(--dv-accent){% endif %}"></span>
Все сервисы<span class="dv-rail-count">{{ '%02d'|format(total_catalog_count) }}</span>
</a>
{% for category in categories %}
<a class="dv-rail-link {% if selected_category_slug == category.slug %}active{% endif %}" href="/?category={{ category.slug }}">
<span class="dv-rail-dot"></span>{{ category.name }}<span class="dv-rail-count">{{ '%02d'|format(category_counts.get(category.slug, 0)) }}</span>
</a>
{% endfor %}
</nav>
</div>
<div>
<div class="dv-rail-kicker">Скоро</div>
<nav class="dv-rail-nav">
<a class="dv-rail-link" href="/bundles">
<span class="dv-rail-dot"></span>Готовые связки<span class="dv-rail-count">NEW</span>
</a>
</nav>
</div>
<div class="dv-rail-foot">SESSION LIMIT: {{ max_active_services }}<br/>IDLE TIMEOUT: {{ idle_timeout_min }} MIN<br/>RESET: 00:00 MSK</div>
</aside>
<main class="dv-main">
<div class="dv-topbar">
<div class="dv-who">
<div class="dv-who-avatar">{{ ((user.first_name[0] if user.first_name else user.username[0]) + (user.last_name[0] if user.last_name else ''))|upper }}</div>
<div>
<div class="dv-who-name">{{ (user.first_name + ' ' + user.last_name)|trim or user.username }}</div>
<div class="dv-who-role">оператор стенда</div>
</div>
</div>
<div class="dv-topbar-actions">
{% if user.is_admin %}
<a class="dv-btn" href="/admin">Администрирование</a>
{% endif %}
<form method="post" action="/logout" style="margin:0">
<button type="submit" class="dv-btn">Выход</button>
</form>
</div>
</div>
<div class="dv-hero">
<p class="dv-hero-eyebrow">MONT · Испытательный контур</p>
<h1>Инфраструктурный полигон</h1>
{% if session_notice %}<div class="dv-filter-note" style="color:#ff9d3d">{{ session_notice }}</div>{% endif %}
{% if selected_category_slug %}
{% set current_cat = categories|selectattr('slug','equalto',selected_category_slug)|list|first %}
<p class="dv-filter-note">Показана категория «<b>{{ current_cat.name if current_cat else selected_category_slug }}</b>»<a href="/">сбросить</a></p>
{% endif %}
<div class="dv-readouts">
<div class="dv-readout"><div class="dv-readout-val">{{ '%02d'|format((services|length) + (pilot_services|length)) }}</div><div class="dv-readout-label">Доступно</div></div>
<div class="dv-readout"><div class="dv-readout-val">{{ '%02d'|format(categories|length) }}</div><div class="dv-readout-label">Категорий</div></div>
</div>
</div>
{% if pilot_services %}
<details class="dv-pilots-section" open>
<summary class="dv-section-head dv-pilots-summary">
<h2>Пилоты</h2><span class="dv-section-count">{{ '%02d'|format(pilot_services|length) }}</span>
<span class="dv-pilots-toggle-hint">свернуть/развернуть</span>
</summary>
<div class="dv-tile-grid">
{% for service in pilot_services %}
<div class="dv-tile">
<a class="dv-tile-hit go-link" href="/go/{{ service.slug }}" aria-label="Открыть {{ service.name }}"></a>
<div class="dv-tile-top">
<div class="dv-tile-plate"><img src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="" /></div>
<div class="dv-tile-name">{{ service.name }} <span class="dv-pilot-badge">ПИЛОТ</span></div>
</div>
<div class="dv-tile-desc">{{ service_comment_html.get(service.id, '') }}</div>
<a class="dv-tile-enter go-link" href="/go/{{ service.slug }}">Войти →</a>
<span class="dv-tile-led"></span>
</div>
{% endfor %}
</div>
</details>
{% endif %}
<section class="dv-section">
<div class="dv-section-head"><h2>Ваши сервисы</h2><span class="dv-section-count">{{ '%02d'|format(services|length) }}</span></div>
{% if services %}
<div class="dv-tile-grid">
{% for service in services %}
<div class="dv-tile">
<a class="dv-tile-hit go-link" href="/go/{{ service.slug }}" aria-label="Открыть {{ service.name }}"></a>
<div class="dv-tile-top">
<div class="dv-tile-plate"><img src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="" /></div>
<div class="dv-tile-name">{{ service.name }}</div>
</div>
<div class="dv-tile-desc">{{ service_comment_html.get(service.id, '') }}</div>
<a class="dv-tile-enter go-link" href="/go/{{ service.slug }}">Войти →</a>
<span class="dv-tile-led"></span>
</div>
{% endfor %}
</div>
{% else %}
<div class="dv-empty">{% if selected_category_slug %}В этой категории у вас пока нет доступных сервисов.{% else %}Вам пока не назначены сервисы — обратитесь к вашему менеджеру MONT.{% endif %}</div>
{% endif %}
</section>
{% if locked_services %}
<section class="dv-section">
<div class="dv-section-head"><h2>Доступно по запросу</h2><span class="dv-section-count">{{ '%02d'|format(locked_services|length) }}</span></div>
<div class="dv-tile-grid">
{% for service in locked_services %}
{% if service.id in pilot_ids %}
<div class="dv-tile locked pilot-locked">
<div class="dv-tile-top">
<div class="dv-tile-plate"><img src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="" /></div>
<div class="dv-tile-name">{{ service.name }}</div>
</div>
<div class="dv-tile-desc">{{ service_comment_html.get(service.id, '') }}</div>
<span class="dv-tile-invite" title="Доступ выдаёт администратор полигона">По приглашению</span>
</div>
{% else %}
<div class="dv-tile locked">
<div class="dv-tile-top">
<div class="dv-tile-plate"><img src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="" /></div>
<div class="dv-tile-name">{{ service.name }}</div>
</div>
<div class="dv-tile-desc">{{ service_comment_html.get(service.id, '') }}</div>
<button type="button" class="dv-tile-request" data-product="{{ service.name }}">Запросить доступ</button>
<span class="dv-tile-lock" title="Нет доступа">🔒</span>
</div>
{% endif %}
{% endfor %}
</div>
</section>
{% endif %}
<footer class="dv-page-footer"><span>MONT PROVING GROUND</span><a href="mailto:ruslan@ipcom.su">Made by Galyaviev</a></footer>
</main>
</div>
<div class="req-modal-overlay" id="req-overlay">
<div class="req-modal" id="req-modal-body">
<h3>Запросить доступ</h3>
<p class="sub">Заявка уйдёт администратору полигона. После одобрения продукт появится у вас автоматически — заново логиниться не нужно.</p>
<div class="req-error" id="req-error"></div>
<div class="req-list" id="req-list"></div>
<textarea class="req-note" id="req-note" placeholder="Комментарий (необязательно) — например, для какого проекта нужен доступ"></textarea>
<div class="req-actions">
<button type="button" class="req-cancel" id="req-cancel">Отмена</button>
<button type="button" class="req-submit" id="req-submit">Отправить заявку</button>
</div>
</div>
</div>
<script>
(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;
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.go-link[href^="/go/"]').forEach(function (link) {
link.addEventListener('click', function (e) {
e.preventDefault();
let href = link.getAttribute('href');
try {
const url = new URL(href, window.location.origin);
const params = currentScreenParams();
url.search = params.toString();
href = url.pathname + '?' + url.searchParams.toString();
} catch (e) {}
window.location.href = href;
}, { capture: true });
});
})();
(function () {
const csrf = {{ csrf_token|tojson }};
const overlay = document.getElementById('req-overlay');
const listEl = document.getElementById('req-list');
const noteEl = document.getElementById('req-note');
const errEl = document.getElementById('req-error');
const modalBody = document.getElementById('req-modal-body');
function allLockedTiles() {
// Pilots (.pilot-locked) are invite-only and must never end up in the
// self-service "request access" checklist, even via another product's
// request button pulling in every locked tile on the page.
return Array.prototype.slice.call(document.querySelectorAll('.dv-tile.locked:not(.pilot-locked)'));
}
function openRequestModal(preselect) {
errEl.style.display = 'none';
noteEl.value = '';
listEl.innerHTML = '';
allLockedTiles().forEach(function (t) {
const name = t.querySelector('.dv-tile-name').textContent;
const id = 'rq-' + name.replace(/[^a-zA-Z0-9]+/g, '-');
const row = document.createElement('label');
row.className = 'req-item';
row.innerHTML = '<input type="checkbox" id="' + id + '" value="' + name.replace(/"/g, '&quot;') + '" ' +
(name === preselect ? 'checked' : '') + ' /><span>' + name + '</span>';
listEl.appendChild(row);
});
overlay.classList.add('open');
}
function closeModal() { overlay.classList.remove('open'); }
document.querySelectorAll('.dv-tile-request').forEach(function (btn) {
btn.addEventListener('click', function () { openRequestModal(btn.dataset.product); });
});
document.getElementById('req-cancel').addEventListener('click', closeModal);
overlay.addEventListener('click', function (e) { if (e.target === overlay) closeModal(); });
document.addEventListener('keydown', function (e) { if (e.key === 'Escape') closeModal(); });
async function submitRequest() {
const checked = Array.prototype.slice.call(document.querySelectorAll('#req-list input[type=checkbox]:checked'));
const products = checked.map(function (c) { return c.value; });
if (!products.length) {
errEl.textContent = 'Отметьте хотя бы один продукт';
errEl.style.display = 'block';
return;
}
const submitBtn = document.getElementById('req-submit');
submitBtn.disabled = true;
submitBtn.textContent = 'Отправка...';
errEl.style.display = 'none';
try {
const res = await fetch('/api/request-more-access', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
body: JSON.stringify({ products: products, note: noteEl.value.trim() }),
});
if (!res.ok) {
const d = await res.json().catch(function () { return {}; });
throw new Error(d.detail || 'Ошибка отправки');
}
modalBody.innerHTML =
'<div class="req-success">' +
'<div class="ico">✓</div><h4>Заявка отправлена</h4>' +
'<p>Администратор получит уведомление и одобрит доступ — продукт появится у вас автоматически, без повторного входа.</p>' +
'</div><div class="req-actions" style="margin-top:16px">' +
'<button type="button" class="req-cancel" id="req-close-success">Закрыть</button></div>';
document.getElementById('req-close-success').addEventListener('click', closeModal);
} catch (e) {
errEl.textContent = e.message || 'Ошибка отправки, попробуйте позже';
errEl.style.display = 'block';
submitBtn.disabled = false;
submitBtn.textContent = 'Отправить заявку';
}
}
document.getElementById('req-submit').addEventListener('click', submitRequest);
})();
</script>
</body>
</html>