Bundles feature WIP + fix request-access products cap (20 -> 200)

- app/main.py: raise products_raw[:20] cap to [:200] in /api/request-access
  and /api/request-more-access - was silently truncating access requests
  with more than 20 selected products
- accumulated bundles/product-page WIP (config, models, runtime, templates)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 13:58:09 +00:00
parent f5c53f01bf
commit 40de4d5a0f
12 changed files with 2407 additions and 457 deletions
+5
View File
@@ -45,3 +45,8 @@ SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM_EMAIL = os.getenv("SMTP_FROM_EMAIL", "stand@4mont.ru") SMTP_FROM_EMAIL = os.getenv("SMTP_FROM_EMAIL", "stand@4mont.ru")
SMTP_FROM_NAME = os.getenv("SMTP_FROM_NAME", "\u0418\u043d\u0444\u0440\u0430\u0441\u0442\u0443\u043a\u0442\u0443\u0440\u043d\u044b\u0439 \u043f\u043e\u043b\u0438\u0433\u043e\u043d MONT") SMTP_FROM_NAME = os.getenv("SMTP_FROM_NAME", "\u0418\u043d\u0444\u0440\u0430\u0441\u0442\u0443\u043a\u0442\u0443\u0440\u043d\u044b\u0439 \u043f\u043e\u043b\u0438\u0433\u043e\u043d MONT")
PORTAL_URL = os.getenv("PORTAL_URL", "https://stend.4mont.ru") PORTAL_URL = os.getenv("PORTAL_URL", "https://stend.4mont.ru")
# Second approval channel: email notification with click-to-confirm links,
# sent alongside the Telegram message when a new access request comes in.
ADMIN_NOTIFY_EMAIL = os.getenv("ADMIN_NOTIFY_EMAIL", "RGalyaviev@mont.ru")
SIGNING_KEY = os.getenv("SIGNING_KEY", "")
+795 -79
View File
File diff suppressed because one or more lines are too long
+7
View File
@@ -50,6 +50,7 @@ class Service(Base):
svc_password: Mapped[str] = mapped_column(String(256), default="") svc_password: Mapped[str] = mapped_column(String(256), default="")
svc_cred_hint: Mapped[str] = mapped_column(Text, default="") svc_cred_hint: Mapped[str] = mapped_column(Text, default="")
icon_path: Mapped[str] = mapped_column(Text, default="") icon_path: Mapped[str] = mapped_column(Text, default="")
seo_description: Mapped[str] = mapped_column(Text, default="")
active: Mapped[bool] = mapped_column(Boolean, default=True) active: Mapped[bool] = mapped_column(Boolean, default=True)
warm_pool_size: Mapped[int] = mapped_column(Integer, default=0) warm_pool_size: Mapped[int] = mapped_column(Integer, default=0)
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc)) created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
@@ -130,3 +131,9 @@ class PendingAccessRequest(Base):
portal_url: Mapped[str] = mapped_column(String(256), default="") portal_url: Mapped[str] = mapped_column(String(256), default="")
status: Mapped[str] = mapped_column(String(16), default="pending") status: Mapped[str] = mapped_column(String(16), default="pending")
created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc)) created_at: Mapped[dt.datetime] = mapped_column(DateTime(timezone=True), default=lambda: dt.datetime.now(dt.timezone.utc))
# Telegram delivery tracking: the notification is sent once when the
# request is created; if that attempt fails (tel.4mont.ru down/timeout),
# a background retry loop resends telegram_message every 5 minutes until
# it succeeds, without recomputing geo/IP each time.
telegram_notified: Mapped[bool] = mapped_column(Boolean, default=False)
telegram_message: Mapped[str] = mapped_column(Text, default="")
+3
View File
@@ -772,7 +772,10 @@ def ensure_schema_compatibility() -> None:
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_password VARCHAR(256) NOT NULL DEFAULT ''")) conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_password VARCHAR(256) NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_cred_hint TEXT NOT NULL DEFAULT ''")) conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS svc_cred_hint TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS icon_path TEXT NOT NULL DEFAULT ''")) conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS icon_path TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE services ADD COLUMN IF NOT EXISTS seo_description TEXT NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE pending_access_requests ADD COLUMN IF NOT EXISTS portal_url VARCHAR(256) NOT NULL DEFAULT ''")) conn.execute(text("ALTER TABLE pending_access_requests ADD COLUMN IF NOT EXISTS portal_url VARCHAR(256) NOT NULL DEFAULT ''"))
conn.execute(text("ALTER TABLE pending_access_requests ADD COLUMN IF NOT EXISTS telegram_notified BOOLEAN NOT NULL DEFAULT false"))
conn.execute(text("ALTER TABLE pending_access_requests ADD COLUMN IF NOT EXISTS telegram_message TEXT NOT NULL DEFAULT ''"))
conn.execute( conn.execute(
text( text(
""" """
Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

+172 -1
View File
@@ -23,8 +23,179 @@
</script> </script>
<noscript><div><img src="https://mc.yandex.ru/watch/109119977" style="position:absolute; left:-9999px;" alt="" /></div></noscript> <noscript><div><img src="https://mc.yandex.ru/watch/109119977" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter --> <!-- /Yandex.Metrika counter -->
<style>
:root{
--av-bg:#eef1f5; --av-panel:#fff; --av-raised:#fff;
--av-line:#d7dee6; --av-line-soft:#e3e8ee;
--av-fg:#16202c; --av-fg-dim:#5c6b7c; --av-fg-faint:#93a1b0;
--av-accent:#c96c14; --av-accent-dim:rgba(201,108,20,.10); --av-accent-ink:#2a1400;
--av-good:#1f9d63; --av-warn:#b5680a; --av-bad:#d3453f;
}
body.admin-page-v2{
background:var(--av-bg) !important; color:var(--av-fg);
font-family:"Ubuntu","IBM Plex Sans",system-ui,sans-serif;
}
body.admin-page-v2 *{ box-sizing:border-box; min-width:0; }
/* header */
body.admin-page-v2 .header{
background:var(--av-panel) !important; border-bottom:1px solid var(--av-line);
backdrop-filter:none;
}
body.admin-page-v2 .header-logo{ height:28px; }
body.admin-page-v2 .header > div > div{
font:600 13px/1 "Ubuntu Mono",monospace; color:var(--av-fg-dim); letter-spacing:.02em;
}
body.admin-page-v2 .btn-link.secondary{
background:transparent !important; border:1px solid var(--av-line); color:var(--av-fg-dim) !important;
font:600 13px/1 "Ubuntu",sans-serif; padding:9px 16px; border-radius:7px;
}
body.admin-page-v2 .btn-link.secondary:hover{ border-color:var(--av-accent); color:var(--av-fg) !important; }
/* layout / panels */
body.admin-page-v2 .admin-layout{ max-width:1500px; }
body.admin-page-v2 .panel{
background:var(--av-panel) !important; border:1px solid var(--av-line); box-shadow:none;
border-radius:12px;
}
body.admin-page-v2 .admin-intro{
background:var(--av-accent-dim) !important; border:1px solid var(--av-line);
border-left:3px solid var(--av-accent); color:var(--av-fg-dim) !important;
font:500 13px/1.55 "Ubuntu",sans-serif; letter-spacing:normal;
}
body.admin-page-v2 .admin-intro b{ color:var(--av-fg); }
/* tabs */
body.admin-page-v2 .tab-row{ gap:6px; padding-bottom:2px; }
body.admin-page-v2 .tab-btn{
background:transparent !important; color:var(--av-fg-dim) !important;
border:1px solid var(--av-line); border-radius:8px;
font:600 13px/1 "Ubuntu",sans-serif; padding:9px 16px;
}
body.admin-page-v2 .tab-btn:hover{ border-color:var(--av-fg-faint); color:var(--av-fg) !important; }
body.admin-page-v2 .tab-btn.active-tab{
background:var(--av-accent-dim) !important; border-color:var(--av-accent);
color:var(--av-fg) !important;
}
/* split / lists */
body.admin-page-v2 .split{ grid-template-columns:minmax(280px,340px) minmax(0,1fr); }
@media (max-width:900px){ body.admin-page-v2 .split{ grid-template-columns:minmax(0,1fr); } }
body.admin-page-v2 .list-title{
font:700 11px/1 "Ubuntu Mono",monospace; letter-spacing:.1em; text-transform:uppercase;
color:var(--av-fg-faint);
}
body.admin-page-v2 .list-box{
background:var(--av-raised) !important; border:1px solid var(--av-line); border-radius:10px;
}
body.admin-page-v2 .list-search{
background:var(--av-panel) !important; border:1px solid var(--av-line) !important;
color:var(--av-fg); border-radius:8px;
}
body.admin-page-v2 .list-search::placeholder{ color:var(--av-fg-faint); }
body.admin-page-v2 .list-item{
background:var(--av-panel) !important; border:1px solid var(--av-line) !important;
color:var(--av-fg) !important; border-radius:8px; transition:border-color .12s;
}
body.admin-page-v2 .list-item:hover{ border-color:var(--av-fg-faint) !important; }
body.admin-page-v2 .list-item.selected-item{
border-color:var(--av-accent) !important; background:var(--av-accent-dim) !important;
box-shadow:none !important;
}
body.admin-page-v2 .list-item small{ color:var(--av-fg-faint); }
body.admin-page-v2 .user-days{ color:var(--av-fg-faint) !important; }
/* forms */
body.admin-page-v2 input,
body.admin-page-v2 button,
body.admin-page-v2 textarea,
body.admin-page-v2 select{
background:var(--av-panel); border:1px solid var(--av-line); color:var(--av-fg);
border-radius:8px; font:400 13.5px/1.4 "Ubuntu",sans-serif;
}
body.admin-page-v2 input::placeholder,
body.admin-page-v2 textarea::placeholder{ color:var(--av-fg-faint); }
body.admin-page-v2 input:focus,
body.admin-page-v2 textarea:focus,
body.admin-page-v2 select:focus{ outline:none; border-color:var(--av-accent); }
body.admin-page-v2 input[readonly]{ opacity:.6; cursor:default; }
body.admin-page-v2 button{
background:var(--av-accent); color:var(--av-accent-ink); border:none;
font-weight:700; cursor:pointer; padding:8px 16px;
}
body.admin-page-v2 button:hover{ filter:brightness(1.06); }
body.admin-page-v2 .field-col > span{
font:600 11.5px/1 "Ubuntu",sans-serif; color:var(--av-fg-faint);
}
body.admin-page-v2 .field-help{ color:var(--av-fg-faint); }
body.admin-page-v2 .actions{ flex-wrap:wrap; }
/* acl / category grids */
body.admin-page-v2 .acl-grid label,
body.admin-page-v2 .compact-grid label{
display:flex; align-items:center; gap:8px; padding:6px 8px;
border:1px solid var(--av-line); border-radius:7px; background:var(--av-panel);
font:400 13px/1.3 "Ubuntu",sans-serif; color:var(--av-fg-dim);
}
body.admin-page-v2 .acl-grid input[type=checkbox],
body.admin-page-v2 .compact-grid input[type=checkbox]{ accent-color:var(--av-accent); }
body.admin-page-v2 .acl-owner{ color:var(--av-accent) !important; }
/* tables */
body.admin-page-v2 .admin-table{ color:var(--av-fg-dim); }
body.admin-page-v2 .admin-table th{
color:var(--av-fg-faint); font:700 10.5px/1 "Ubuntu Mono",monospace; letter-spacing:.06em;
text-transform:uppercase; border-bottom:1px solid var(--av-line) !important;
}
body.admin-page-v2 .admin-table td{ border-bottom:1px solid var(--av-line) !important; }
body.admin-page-v2 .container-table-wrap{
background:var(--av-panel) !important; border:1px solid var(--av-line) !important;
border-radius:10px;
}
/* summary strip */
body.admin-page-v2 .summary-card{
background:var(--av-panel) !important; border:1px solid var(--av-line);
border-radius:10px;
}
body.admin-page-v2 .summary-label{
color:var(--av-fg-faint) !important; font:600 10.5px/1 "Ubuntu Mono",monospace;
letter-spacing:.06em; text-transform:uppercase;
}
body.admin-page-v2 .summary-value{
color:var(--av-fg) !important; font:700 19px/1 "Ubuntu Mono",monospace; margin-top:6px;
font-variant-numeric:tabular-nums;
}
/* health / icon boxes */
body.admin-page-v2 .health-box,
body.admin-page-v2 .icon-box{
background:var(--av-raised) !important; border:1px solid var(--av-line) !important;
border-radius:10px;
}
body.admin-page-v2 .service-thumb,
body.admin-page-v2 .service-icon-preview{
background:var(--av-panel) !important; border:1px solid var(--av-line-soft) !important;
border-radius:9px;
}
body.admin-page-v2 .status-ok{ background:var(--av-good) !important; }
body.admin-page-v2 .status-degraded{ background:var(--av-warn) !important; }
body.admin-page-v2 .status-down{ background:var(--av-bad) !important; }
/* categories tab rows */
body.admin-page-v2 .category-item-row small{ color:var(--av-fg-faint); }
body.admin-page-v2 .category-item-row button{
background:transparent; border:1px solid var(--av-bad); color:var(--av-bad);
font-weight:600; padding:6px 12px;
}
body.admin-page-v2 .category-item-row button:hover{ background:rgba(255,107,107,.12); }
body.admin-page-v2 .muted{ color:var(--av-fg-faint) !important; }
body.admin-page-v2 hr{ border:none; border-top:1px solid var(--av-line); margin:1.2rem 0; }
</style>
</head> </head>
<body> <body class="admin-page-v2">
<header class="header"> <header class="header">
<div style="display:flex; align-items:center; gap:0.6rem;"> <div style="display:flex; align-items:center; gap:0.6rem;">
<a href="https://4mont.ru"><img src="/static/logo.png?v=4" alt="MONT" class="header-logo" /></a> <a href="https://4mont.ru"><img src="/static/logo.png?v=4" alt="MONT" class="header-logo" /></a>
+207
View File
@@ -0,0 +1,207 @@
<!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;
}
.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 14px;overflow-wrap:break-word}
.dv-hero-lede{font:400 15px/1.6 "Ubuntu",sans-serif;color:var(--dv-fg-dim);max-width:70ch;margin:0}
.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-scn-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:14px}
.dv-scn-card{position:relative;background:var(--dv-panel);border:1px solid var(--dv-line);border-radius:12px;
padding:18px 18px 16px;opacity:.85}
.dv-scn-title{font:600 15px/1.35 "Ubuntu",sans-serif;margin:0 0 8px;padding-right:70px}
.dv-scn-desc{font:400 13.5px/1.55 "Ubuntu",sans-serif;color:var(--dv-fg-dim);margin:0}
.dv-scn-badge{position:absolute;top:16px;right:16px;font:700 10px/1 "Ubuntu Mono",monospace;letter-spacing:.06em;
text-transform:uppercase;color:var(--dv-accent);background:var(--dv-accent-dim);border:1px solid var(--dv-accent);
border-radius:999px;padding:4px 9px}
.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)}
</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" href="/">
<span class="dv-rail-dot"></span>Все сервисы
</a>
</nav>
</div>
<div>
<div class="dv-rail-kicker">Скоро</div>
<nav class="dv-rail-nav">
<a class="dv-rail-link active" 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>
<p class="dv-hero-lede">Заказчику редко нужен один продукт в вакууме — обычно решение должно встроиться в существующую или новую инфраструктуру. Мы собираем на полигоне связку из нескольких продуктов, имитирующую реальный сценарий внедрения, чтобы показать не «продукт», а готовое решение задачи. Раздел готовится — ниже сценарии, которые уже прорабатываются.</p>
</div>
<section class="dv-section">
<div class="dv-section-head"><h2>В подготовке</h2></div>
<div class="dv-scn-grid">
<div class="dv-scn-card">
<span class="dv-scn-badge">Готовится</span>
<div class="dv-scn-title">Миграция домена</div>
<div class="dv-scn-desc">ALDpro в связке с рабочими станциями на Astra Linux, РЕД ОС и Альт Рабочая станция — вход, групповые политики и совместная работа каталога с разными ОС на местах.</div>
</div>
<div class="dv-scn-card">
<span class="dv-scn-badge">Готовится</span>
<div class="dv-scn-title">VDI-инфраструктура</div>
<div class="dv-scn-desc">Termidesk поверх Ред Виртуализация — типовой сценарий виртуальных рабочих столов, от сервера виртуализации до подключения пользователя.</div>
</div>
<div class="dv-scn-card">
<span class="dv-scn-badge">Готовится</span>
<div class="dv-scn-title">Резервное копирование виртуальной инфраструктуры</div>
<div class="dv-scn-desc">RuBackup или Vinchin в связке с Альт Виртуализация (PVE) — бэкап и восстановление виртуальных машин на живом кластере.</div>
</div>
<div class="dv-scn-card">
<span class="dv-scn-badge">Готовится</span>
<div class="dv-scn-title">Импортозамещение рабочего места целиком</div>
<div class="dv-scn-desc">ОС (Astra Linux / РЕД ОС / Альт Рабочая станция) + офисный пакет (АльтерОфис / P7-КС2024) + почта (CommuniGate Pro / RuPost) — полный стек рабочего места сотрудника.</div>
</div>
<div class="dv-scn-card">
<span class="dv-scn-badge">Готовится</span>
<div class="dv-scn-title">Контейнеризированное приложение с базой данных</div>
<div class="dv-scn-desc">Deckhouse Platform (Kubernetes) + Postgres Pro или Tantor — развёртывание приложения с реальной СУБД под капотом.</div>
</div>
<div class="dv-scn-card">
<span class="dv-scn-badge">Готовится</span>
<div class="dv-scn-title">Наблюдаемость виртуальной инфраструктуры</div>
<div class="dv-scn-desc">Astra Мониторинг поверх Ред Виртуализация или Альт Виртуализация (PVE) — контроль состояния кластера и виртуальных машин.</div>
</div>
</div>
</section>
<footer class="dv-page-footer"><span>MONT PROVING GROUND</span><a href="mailto:ruslan@ipcom.su">Made by Galyaviev</a></footer>
</main>
</div>
</body>
</html>
+332 -132
View File
@@ -23,9 +23,7 @@
</script> </script>
<noscript><div><img src="https://mc.yandex.ru/watch/109119977" style="position:absolute; left:-9999px;" alt="" /></div></noscript> <noscript><div><img src="https://mc.yandex.ru/watch/109119977" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter --> <!-- /Yandex.Metrika counter -->
</head> <style>
<body class="dashboard-page">
{% raw %}<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} #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} } @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-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))}
@@ -34,7 +32,144 @@
.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{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-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} .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}
</style>{% endraw %}
: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-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-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"> <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> <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-icon">🖥️</div>
@@ -47,142 +182,144 @@
<div class="mw-footer"><a href="mailto:ruslan@ipcom.su" style="color:inherit;text-decoration:none">Made by Galyaviev</a></div> <div class="mw-footer"><a href="mailto:ruslan@ipcom.su" style="color:inherit;text-decoration:none">Made by Galyaviev</a></div>
</div> </div>
<header class="header"> <div class="dv-shell">
<div class="header-left"> <aside class="dv-rail">
<div class="user-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 class="dv-rail-brand"><a href="https://4mont.ru"><img src="/static/logo-color.png?v=1" alt="MONT" /></a></div>
<span class="header-username">{{ (user.first_name + ' ' + user.last_name)|trim or user.username }}</span> <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="header-right"> <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 %} {% if user.is_admin %}
<a href="/admin" class="header-btn">Администрирование</a> <a class="dv-btn" href="/admin">Администрирование</a>
{% endif %} {% endif %}
<form method="post" action="/logout"> <form method="post" action="/logout" style="margin:0">
<button type="submit" class="header-btn header-btn-logout">Выход</button> <button type="submit" class="dv-btn">Выход</button>
</form> </form>
</div> </div>
</header>
<div class="page-logo-wrap">
<a href="https://4mont.ru"><img src="/static/logo.png?v=4" alt="MONT" class="page-logo" /></a>
</div> </div>
<main class="admin-layout">
<section class="panel">
<div class="admin-intro">Инфраструктурный полигон MONT</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, []) %}
<div class="tile-wrap">
<a class="tile-link" href="/go/{{ service.slug }}" aria-label="{{ service.name }}"></a>
<div class="tile">
<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>
<div class="tile-info-area">
{% if service.comment %}
<div class="tile-comment">{{ service_comment_html.get(service.id, '') }}</div>
{% endif %}
</div>
{% if svc_cats %}
<div class="service-categories">
{% for category in svc_cats %}
<span class="service-cat-badge">{{ category.name }}</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% else %}
<div class="tile">
{% if selected_category_slug %}
Нет сервисов в выбранной категории
{% else %}
Нет назначенных сервисов
{% endif %}
</div>
{% endfor %}
</section>
<footer class="made-by-wrap"><a class="made-by" href="mailto:ruslan@ipcom.su">Made by Galyaviev</a></footer>
</main>
<style>
#loading-overlay{display:none;position:fixed;inset:0;z-index:8888;background:rgba(10,18,28,.88);
backdrop-filter:blur(4px);flex-direction:column;align-items:center;justify-content:center;gap:1.2rem}
#loading-overlay .lo-spinner{width:52px;height:52px;border:4px solid rgba(220,232,245,.15);
border-top-color:#2a8cd6;border-radius:50%;animation:lo-spin .85s linear infinite}
#loading-overlay .lo-text{color:#a0b8cc;font:600 1rem sans-serif}
@keyframes lo-spin{to{transform:rotate(360deg)}}
</style>
<div id="loading-overlay">
<div class="lo-spinner"></div>
<div class="lo-text">Ожидайте...</div>
</div>
<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';
});
})();
<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) }}</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>
<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 %}
<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>
{% 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 () {
function clamp(value, min, max) { function clamp(value, min, max) {
return Math.max(min, Math.min(max, value)); return Math.max(min, Math.min(max, value));
} }
function currentScreenParams() { function currentScreenParams() {
const screenWidth = const screenWidth =
window.screen && Number.isFinite(window.screen.width) && window.screen.width > 0 window.screen && Number.isFinite(window.screen.width) && window.screen.width > 0
? window.screen.width ? window.screen.width : null;
: null;
const screenHeight = const screenHeight =
window.screen && Number.isFinite(window.screen.height) && window.screen.height > 0 window.screen && Number.isFinite(window.screen.height) && window.screen.height > 0
? window.screen.height ? window.screen.height : null;
: null;
const viewportWidth = const viewportWidth =
(window.visualViewport && window.visualViewport.width) || (window.visualViewport && window.visualViewport.width) ||
window.innerWidth || window.innerWidth || document.documentElement.clientWidth || 1280;
document.documentElement.clientWidth ||
1280;
const viewportHeight = const viewportHeight =
(window.visualViewport && window.visualViewport.height) || (window.visualViewport && window.visualViewport.height) ||
window.innerHeight || window.innerHeight || document.documentElement.clientHeight || 720;
document.documentElement.clientHeight ||
720;
// Prefer stable screen dimensions; viewport is fallback.
const width = clamp(Math.round(screenWidth || viewportWidth), 320, 7680); const width = clamp(Math.round(screenWidth || viewportWidth), 320, 7680);
const height = clamp(Math.round(screenHeight || viewportHeight), 240, 4320); const height = clamp(Math.round(screenHeight || viewportHeight), 240, 4320);
const sp = new URLSearchParams(); const sp = new URLSearchParams();
@@ -191,9 +328,7 @@
return sp; return sp;
} }
const loadingOverlay = document.getElementById('loading-overlay'); document.querySelectorAll('a.go-link[href^="/go/"]').forEach(function (link) {
document.querySelectorAll('a.tile[href^="/go/"]').forEach(function (link) {
link.addEventListener('click', function (e) { link.addEventListener('click', function (e) {
e.preventDefault(); e.preventDefault();
let href = link.getAttribute('href'); let href = link.getAttribute('href');
@@ -203,20 +338,85 @@
url.search = params.toString(); url.search = params.toString();
href = url.pathname + '?' + url.searchParams.toString(); href = url.pathname + '?' + url.searchParams.toString();
} catch (e) {} } catch (e) {}
if (loadingOverlay) loadingOverlay.style.display = 'flex';
requestAnimationFrame(function () {
requestAnimationFrame(function () {
window.location.href = href; window.location.href = href;
});
});
}, { capture: true }); }, { capture: true });
}); });
window.addEventListener('pageshow', function (e) {
if (loadingOverlay) loadingOverlay.style.display = 'none';
});
})(); })();
(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() {
return Array.prototype.slice.call(document.querySelectorAll('.dv-tile.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> </script>
</body> </body>
</html> </html>
+235 -215
View File
@@ -41,10 +41,6 @@
<link rel="icon" type="image/png" sizes="192x192" href="/static/favicon_192.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="apple-touch-icon" sizes="192x192" href="/static/favicon_192.png" />
<link rel="manifest" href="/static/manifest.json" /> <link rel="manifest" href="/static/manifest.json" />
<style>
body { background: #070f1c; overflow: hidden; height: 100vh; }
@media (max-width: 820px) { body { overflow: auto; height: auto; } }
</style>
<!-- Yandex.Metrika counter --> <!-- Yandex.Metrika counter -->
<script type="text/javascript"> <script type="text/javascript">
(function(m,e,t,r,i,k,a){ (function(m,e,t,r,i,k,a){
@@ -57,60 +53,251 @@
</script> </script>
<noscript><div><img src="https://mc.yandex.ru/watch/109119977" style="position:absolute; left:-9999px;" alt="" /></div></noscript> <noscript><div><img src="https://mc.yandex.ru/watch/109119977" style="position:absolute; left:-9999px;" alt="" /></div></noscript>
<!-- /Yandex.Metrika counter --> <!-- /Yandex.Metrika counter -->
<style>
:root{
--lg2-bg:#eef1f5; --lg2-panel:#ffffff; --lg2-raised:#ffffff;
--lg2-line:#d7dee6; --lg2-line-soft:#e3e8ee;
--lg2-fg:#16202c; --lg2-fg-dim:#5c6b7c; --lg2-fg-faint:#93a1b0;
--lg2-accent:#c96c14; --lg2-accent-dim:rgba(201,108,20,.10); --lg2-accent-ink:#2a1400;
--lg2-bad:#d3453f; --lg2-bad-dim:rgba(211,69,63,.08);
--lg2-info:#1f6fb2; --lg2-info-dim:rgba(31,111,178,.08);
--lg2-good:#1f9d63;
}
html{ background: var(--lg2-bg); }
body.login-page{
margin:0;min-height:100vh;background:var(--lg2-bg);color:var(--lg2-fg);
font-family:"Ubuntu","IBM Plex Sans",system-ui,sans-serif;overflow-x:hidden;
}
.lg2-wrap{display:grid;grid-template-columns:1.15fr 420px;min-height:100vh}
@media (max-width:980px){ .lg2-wrap{grid-template-columns:minmax(0,1fr)} }
.lg2-pitch{
position:relative;min-width:0;padding:56px clamp(28px,5vw,72px) 48px;border-right:1px solid var(--lg2-line);
background-image:linear-gradient(var(--lg2-line-soft) 1px, transparent 1px),linear-gradient(90deg, var(--lg2-line-soft) 1px, transparent 1px);
background-size:34px 34px;display:flex;flex-direction:column;
}
@media (max-width:980px){ .lg2-pitch{border-right:none;border-bottom:1px solid var(--lg2-line)} }
.lg2-brand{display:flex;flex-wrap:wrap;align-items:center;gap:12px 16px;margin-bottom:40px}
.lg2-brand img{height:59px;display:block}
.lg2-brand-mark{font:700 11px/1 "Ubuntu Mono",monospace;letter-spacing:.14em;color:var(--lg2-fg-faint)}
@media (max-width:520px){
.lg2-brand{flex-direction:column;align-items:flex-start;gap:10px}
.lg2-brand img{height:40px}
}
.lg2-eyebrow{font:700 11px/1 "Ubuntu Mono",monospace;letter-spacing:.18em;text-transform:uppercase;color:var(--lg2-accent);margin:0 0 16px}
.lg2-pitch h1{font:800 clamp(30px,3.6vw,46px)/1.06 "Ubuntu",sans-serif;letter-spacing:-.01em;margin:0 0 18px;text-wrap:balance;max-width:16ch;color:var(--lg2-fg);overflow-wrap:break-word;hyphens:auto}
.lg2-pitch, .lg2-pitch *{ overflow-wrap: break-word; }
.lg2-pitch p.lead{font:400 15px/1.65 "Ubuntu",sans-serif;color:var(--lg2-fg-dim);max-width:52ch;margin:0 0 32px}
.lg2-feat-row{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:1px;background:var(--lg2-line);border:1px solid var(--lg2-line);border-radius:10px;overflow:hidden;margin-bottom:40px}
@media (max-width:720px){ .lg2-feat-row{grid-template-columns:repeat(2,minmax(0,1fr))} }
@media (max-width:420px){ .lg2-feat-row{grid-template-columns:minmax(0,1fr)} }
.lg2-feat{position:relative;min-width:0;background:var(--lg2-panel);padding:18px 16px 16px;border-top:2px solid var(--lg2-accent)}
.lg2-feat-idx{font:700 10px/1 "Ubuntu Mono",monospace;letter-spacing:.08em;color:var(--lg2-fg-faint);display:block;margin-bottom:12px}
.lg2-feat-icon{width:26px;height:26px;margin-bottom:14px;color:var(--lg2-accent);display:block}
.lg2-feat-icon svg{width:100%;height:100%;display:block}
.lg2-feat-label{font:600 13px/1.35 "Ubuntu",sans-serif;color:var(--lg2-fg);margin-bottom:6px}
.lg2-feat-desc{font:400 11.5px/1.55 "Ubuntu",sans-serif;color:var(--lg2-fg-dim)}
.lg2-distrib-btn{
align-self:flex-start;margin:0 0 44px;padding:11px 22px;border:1px solid var(--lg2-line);border-radius:9px;
background:transparent;color:var(--lg2-fg-dim);font:600 12.5px/1 "Ubuntu",sans-serif;letter-spacing:.02em;cursor:pointer;
}
.lg2-distrib-btn:hover{border-color:var(--lg2-accent);color:var(--lg2-fg)}
.lg2-wall-head{display:flex;align-items:baseline;gap:10px;margin-bottom:14px}
.lg2-wall-head h2{font:700 11px/1 "Ubuntu Mono",monospace;letter-spacing:.14em;text-transform:uppercase;color:var(--lg2-fg-dim);margin:0}
.lg2-wall-head::after{content:"";flex:1;height:1px;background:var(--lg2-line)}
.lg2-wall-count{font:600 11px/1 "Ubuntu Mono",monospace;color:var(--lg2-fg-faint)}
.lg2-logo-wall{display:grid;grid-template-columns:repeat(auto-fill,minmax(84px,1fr));gap:10px;max-width:760px}
.lg2-wall-tile{aspect-ratio:1;background:var(--lg2-panel);border:1px solid var(--lg2-line);border-radius:9px;display:flex;align-items:center;justify-content:center;padding:8px}
a.lg2-wall-tile{text-decoration:none;cursor:pointer}
.lg2-wall-tile img{width:100%;height:100%;object-fit:contain;filter:grayscale(1) opacity(.75)}
.lg2-wall-tile:hover img{filter:none}
.lg2-wall-tile:hover{border-color:var(--lg2-accent)}
.lg2-pitch-footer{margin-top:auto;padding-top:36px;font:400 11px/1 "Ubuntu Mono",monospace;color:var(--lg2-fg-faint)}
.lg2-pitch-footer a{color:inherit;text-decoration:none}
.lg2-pitch-footer a:hover{color:var(--lg2-fg-dim)}
.lg2-auth{display:flex;flex-direction:column;justify-content:center;min-width:0;padding:48px 40px;background:var(--lg2-bg);
position:sticky;top:0;height:100vh;box-sizing:border-box}
@media (max-width:980px){ .lg2-auth{position:static;height:auto} }
.lg2-auth-card{width:100%;max-width:320px;margin:0 auto}
.lg2-auth-title{font:700 22px/1.2 "Ubuntu",sans-serif;letter-spacing:-.01em;margin:0 0 4px;color:var(--lg2-fg)}
.lg2-auth-sub{font:400 12px/1.4 "Ubuntu Mono",monospace;color:var(--lg2-fg-faint);letter-spacing:.02em;margin:0 0 18px;text-transform:uppercase}
.lg2-prompt{font:400 12.5px/1 "Ubuntu Mono",monospace;color:var(--lg2-fg-dim);margin:0 0 24px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.lg2-prompt-user{color:var(--lg2-good)}
.lg2-prompt-path{color:var(--lg2-accent)}
.lg2-caret{display:inline-block;width:7px;height:13px;background:var(--lg2-accent);vertical-align:-2px;margin-left:3px;animation:lg2-blink 1.1s step-end infinite}
@keyframes lg2-blink{50%{opacity:0}}
@media (prefers-reduced-motion: reduce){ .lg2-caret{animation:none} }
.lg2-notice, .lg2-error{
border-radius:9px;padding:10px 13px;font:500 12.5px/1.5 "Ubuntu",sans-serif;margin-bottom:18px;
}
.lg2-notice{background:var(--lg2-info-dim);border:1px solid rgba(127,199,255,.35);color:var(--lg2-info)}
.lg2-error{background:var(--lg2-bad-dim);border:1px solid rgba(255,107,107,.35);color:var(--lg2-bad)}
.lg2-field{display:flex;flex-direction:column;gap:7px;margin-bottom:16px}
.lg2-field label{font:700 10.5px/1 "Ubuntu Mono",monospace;letter-spacing:.09em;text-transform:uppercase;color:var(--lg2-fg-faint)}
.lg2-field input{background:var(--lg2-panel);border:1px solid var(--lg2-line);border-radius:9px;padding:11px 13px;color:var(--lg2-fg);font:400 14px/1 "Ubuntu",sans-serif;outline:none;width:100%;box-sizing:border-box}
.lg2-field input::placeholder{color:var(--lg2-fg-faint)}
.lg2-field input:focus{border-color:var(--lg2-accent)}
.lg2-submit{width:100%;margin-top:6px;padding:12px 16px;border:none;border-radius:9px;cursor:pointer;background:var(--lg2-accent);color:var(--lg2-accent-ink);font:700 13.5px/1 "Ubuntu",sans-serif;letter-spacing:.02em}
.lg2-submit:hover{filter:brightness(1.06)}
.lg2-request-btn{width:100%;margin-top:12px;padding:10px 16px;border-radius:9px;border:1px solid var(--lg2-line);background:transparent;color:var(--lg2-fg-dim);font:600 12.5px/1 "Ubuntu",sans-serif;cursor:pointer}
.lg2-request-btn:hover{border-color:var(--lg2-accent);color:var(--lg2-fg)}
.lg2-auth-foot{margin-top:40px;text-align:center}
.lg2-auth-foot a{color:var(--lg2-fg-faint);font-size:11px;text-decoration:none}
.lg2-auth-foot a:hover{color:var(--lg2-fg-dim)}
/* Re-skin the shared access-modal component to match the new panel palette
(it's used by "Запросить доступ" here and on the dashboard alike, so we
override on top of style.css instead of editing it there). */
.access-modal-overlay{ background: rgba(6,9,14,.82) !important; }
.access-modal{
background: var(--lg2-panel) !important; border: 1px solid var(--lg2-line) !important;
box-shadow: 0 24px 80px rgba(0,0,0,.45) !important;
}
.access-modal-title{ color: var(--lg2-fg) !important; font-family:"Ubuntu",sans-serif; }
.access-modal-sub{ color: var(--lg2-fg-faint) !important; }
.access-field label{ color: var(--lg2-fg-dim) !important; font-family:"Ubuntu",sans-serif; }
.access-field .req{ color: var(--lg2-accent) !important; }
.access-field input[type=text],
.access-field input[type=email],
.access-field input[type=tel],
.access-textarea{
background: var(--lg2-raised) !important; border: 1px solid var(--lg2-line) !important;
color: var(--lg2-fg) !important; font-family:"Ubuntu",sans-serif;
}
.access-field input[type=text]::placeholder,
.access-field input[type=email]::placeholder,
.access-field input[type=tel]::placeholder,
.access-textarea::placeholder{ color: var(--lg2-fg-faint) !important; }
.access-field input[type=text]:focus,
.access-field input[type=email]:focus,
.access-field input[type=tel]:focus,
.access-textarea:focus{
border-color: var(--lg2-accent) !important; box-shadow: 0 0 0 3px var(--lg2-accent-dim) !important;
}
.access-products-wrap{ background: var(--lg2-raised) !important; border: 1px solid var(--lg2-line) !important; }
.access-products-loading{ color: var(--lg2-fg-faint) !important; }
.access-products-cat{ color: var(--lg2-fg-faint) !important; }
.access-product-item{ color: var(--lg2-fg-dim) !important; font-family:"Ubuntu",sans-serif; }
.access-product-item:hover span{ color: var(--lg2-fg) !important; }
.access-product-item input[type=checkbox]{ accent-color: var(--lg2-accent); }
.access-modal-error{
background: var(--lg2-bad-dim) !important; border: 1px solid rgba(255,107,107,.35) !important;
color: var(--lg2-bad) !important;
}
.access-btn-cancel{
background: transparent !important; border: 1px solid var(--lg2-line) !important; color: var(--lg2-fg-dim) !important;
font-family:"Ubuntu",sans-serif;
}
.access-btn-cancel:hover{ border-color: var(--lg2-fg-faint) !important; color: var(--lg2-fg) !important; background: transparent !important; }
.access-btn-submit{
background: var(--lg2-accent) !important; color: var(--lg2-accent-ink) !important; box-shadow: none !important;
font-family:"Ubuntu",sans-serif; font-weight: 700 !important;
}
.access-btn-submit:hover:not(:disabled){ opacity: 1 !important; filter: brightness(1.06); }
.access-consent-label span{ color: var(--lg2-fg-dim) !important; font-family:"Ubuntu",sans-serif; }
.access-consent-link{ color: var(--lg2-accent) !important; }
.am-success-title{ color: var(--lg2-fg) !important; }
.am-success-sub{ color: var(--lg2-fg-dim) !important; }
.am-success-icon{ color: var(--lg2-good) !important; }
@media (max-width:820px){
.lg2-pitch{padding:40px 22px 34px}
.lg2-auth{padding:36px 22px}
}
</style>
</head> </head>
<body> <body class="login-page">
<div class="login-wrap"> <div class="lg2-wrap">
<aside class="login-left"> <aside class="lg2-pitch">
<div class="login-left-glow login-left-glow-top"></div> <div class="lg2-brand">
<div class="login-left-glow login-left-glow-bottom"></div> <a href="#" onclick="window.open(location.hostname==='stand.mont.ru'?'https://www.mont.ru':'https://4mont.ru','_blank');return false;">
<div class="login-left-inner"> <img src="/static/logo-color.png?v=1" alt="MONT" />
<a href="#" onclick="window.open(location.hostname==='stand.mont.ru'?'https://www.mont.ru':'https://4mont.ru','_blank');return false;"><img src="/static/logo.png?v=4" alt="MONT" class="login-corner-logo" /></a> </a>
<h1 class="login-left-title">Инфраструктурный<br>полигон MONT</h1> <span class="lg2-brand-mark">Инфраструктурный полигон</span>
<p class="login-left-desc">Платформа для демонстрации и пилотного тестирования российского ПО. Партнеры MONT и их заказчики получают браузерный доступ к рабочим стендам с отечественными ОС, платформами виртуализации, СРК и другими решениями — без установки и настройки.</p>
<ul class="login-features">
<li class="login-feature">
<span class="login-feature-icon">🖥</span>
<span>Доступ к рабочим столам ОС</span>
</li>
<li class="login-feature">
<span class="login-feature-icon">🌐</span>
<span>Веб-интерфейсы сервисов</span>
</li>
<li class="login-feature">
<span class="login-feature-icon">⚡</span>
<span>Доступ в один клик</span>
</li>
<li class="login-feature">
<span class="login-feature-icon">🔒</span>
<span>Защищённый контур</span>
</li>
</ul>
<button type="button" class="login-distrib-btn" onclick="window.open(location.hostname==='stand.mont.ru'?'https://maps.mont.ru':'https://maps.4mont.ru','_blank')">Продукты нашей дистрибуции</button>
</div> </div>
<p class="lg2-eyebrow">Демо и пилоты российского ПО</p>
<h1>Полигон импортонезависимой инфраструктуры</h1>
<p class="lead">Платформа для демонстрации и пилотного тестирования российского ПО. Партнёры MONT
и их заказчики получают браузерный доступ к рабочим стендам с отечественными ОС, платформами
виртуализации, СРК и другими решениями — без установки и настройки.</p>
<div class="lg2-feat-row">
<div class="lg2-feat">
<span class="lg2-feat-idx">01</span>
<span class="lg2-feat-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="12" rx="1.5"/><path d="M8 20h8M12 16v4"/></svg></span>
<div class="lg2-feat-label">Доступ к рабочим столам ОС</div>
<div class="lg2-feat-desc">Полный рабочий стол Astra, RED OS, Uncom и других ОС прямо в браузере, без RDP-клиента.</div>
</div>
<div class="lg2-feat">
<span class="lg2-feat-idx">02</span>
<span class="lg2-feat-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3c2.5 2.6 3.8 5.7 3.8 9s-1.3 6.4-3.8 9c-2.5-2.6-3.8-5.7-3.8-9s1.3-6.4 3.8-9z"/></svg></span>
<div class="lg2-feat-label">Веб-интерфейсы сервисов</div>
<div class="lg2-feat-desc">Админ-панели и порталы продуктов открываются как обычная вкладка браузера.</div>
</div>
<div class="lg2-feat">
<span class="lg2-feat-idx">03</span>
<span class="lg2-feat-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z"/></svg></span>
<div class="lg2-feat-label">Доступ в один клик</div>
<div class="lg2-feat-desc">Стенд поднимается автоматически — ничего разворачивать и настраивать не нужно.</div>
</div>
<div class="lg2-feat">
<span class="lg2-feat-idx">04</span>
<span class="lg2-feat-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="11" width="16" height="9" rx="1.5"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg></span>
<div class="lg2-feat-label">Защищённый контур</div>
<div class="lg2-feat-desc">Изолированная сеть с резервированием и регулярными бэкапами всех стендов.</div>
</div>
</div>
<button type="button" class="lg2-distrib-btn" onclick="window.open(location.hostname==='stand.mont.ru'?'https://maps.mont.ru':'https://maps.4mont.ru','_blank')">Продукты нашей дистрибуции</button>
{% set wall_services = public_services|rejectattr('slug', 'equalto', 'communigate-pro-user')|list %}
{% if wall_services %}
<div class="lg2-wall-head" id="products"><h2>Что на полигоне</h2><span class="lg2-wall-count">{{ wall_services|length }} продуктов</span></div>
<div class="lg2-logo-wall">
{% for service in wall_services %}
{% set wall_name = 'CommuniGate Pro' if service.slug == 'communigate-pro-admin' else service.name %}
<a class="lg2-wall-tile" href="/product/{{ service.slug }}" title="{{ wall_name }} — подробнее">
<img src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="{{ wall_name }}" />
</a>
{% endfor %}
</div>
{% endif %}
<div class="lg2-pitch-footer">MONT PROVING GROUND</div>
</aside> </aside>
<main class="login-right">
<div class="login-right-inner"> <main class="lg2-auth">
<div class="login-form-title">Вход в систему</div> <div class="lg2-auth-card">
<div class="login-form-subtitle">Инфраструктурный полигон</div> <div class="lg2-auth-title">Вход в систему</div>
{% if session_notice %}<div class="session-notice lp-session-notice">{{ session_notice }}</div>{% endif %} <div class="lg2-auth-sub">Инфраструктурный полигон</div>
{% if login_error %}<div class="auth-error lp-auth-error">{{ login_error }}</div>{% endif %} <div class="lg2-prompt"><span class="lg2-prompt-user">mont@polygon</span><span class="lg2-prompt-path">:~/access</span>$ ./login.sh<span class="lg2-caret"></span></div>
<form method="post" action="/login" class="login-form"> {% if session_notice %}<div class="lg2-notice">{{ session_notice }}</div>{% endif %}
{% if login_error %}<div class="lg2-error">{{ login_error }}</div>{% endif %}
<form method="post" action="/login">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}" /> <input type="hidden" name="csrf_token" value="{{ csrf_token }}" />
<div class="login-field"> <div class="lg2-field">
<label>Логин</label> <label>Логин</label>
<input type="text" name="username" placeholder="Введите логин" required autocomplete="username" /> <input type="text" name="username" placeholder="Введите логин" required autocomplete="username" />
</div> </div>
<div class="login-field"> <div class="lg2-field">
<label>Пароль</label> <label>Пароль</label>
<input type="password" name="password" placeholder="Введите пароль" required autocomplete="current-password" /> <input type="password" name="password" placeholder="Введите пароль" required autocomplete="current-password" />
</div> </div>
<button type="submit" class="login-submit">Войти</button> <button type="submit" class="lg2-submit">Войти</button>
</form> </form>
<button type="button" class="login-request-btn" id="btn-request-access" data-open-access-modal="1">Запросить доступ</button> <button type="button" class="lg2-request-btn" id="btn-request-access" data-open-access-modal="1">Запросить доступ</button>
<div class="lg2-auth-foot"><a href="mailto:ruslan@ipcom.su">Made by Galyaviev</a></div>
</div> </div>
<footer class="login-footer">
<a href="mailto:ruslan@ipcom.su" class="login-footer-link">Made by Galyaviev</a>
</footer>
</main> </main>
</div> </div>
@@ -356,179 +543,12 @@
})(); })();
</script> </script>
<!-- Contact Ruslan Modal -->
<div id="contact-modal" class="access-modal-overlay" style="display:none" aria-modal="true" role="dialog">
<div class="access-modal">
<div class="access-modal-header">
<div class="access-modal-title">Связаться с Русланом</div>
</div>
<div class="access-modal-body" id="cm-body">
<div class="access-field">
<label>Ваше имя <span class="req">*</span></label>
<input id="cm-name" type="text" placeholder="Иван Иванов" />
</div>
<div class="access-field">
<label>Email <span class="req">*</span></label>
<input id="cm-email" type="email" placeholder="ivan@company.ru" />
</div>
<div class="access-field">
<label>Телефон <span class="req">*</span></label>
<input id="cm-phone" type="tel" placeholder="+7 (999) 000-00-00" />
</div>
<div class="access-field">
<label>Сообщение <span class="req">*</span></label>
<textarea id="cm-text" class="access-textarea" placeholder="Ваш вопрос или предложение..." rows="4"></textarea>
</div>
<div class="access-consent-field">
<label class="access-consent-label">
<input type="checkbox" id="cm-consent" />
<span>Согласен на <a href="/privacy" target="_blank" class="access-consent-link">обработку персональных данных</a></span>
</label>
</div>
<div id="cm-error" class="access-modal-error" style="display:none"></div>
</div>
<div class="access-modal-footer" id="cm-footer">
<button type="button" class="access-btn-cancel" id="cm-cancel">Отмена</button>
<button type="button" class="access-btn-submit" id="cm-submit">Отправить</button>
</div>
</div>
</div>
<script>
(function() {
const overlay = document.getElementById('contact-modal');
const btnCancel = document.getElementById('cm-cancel');
const btnSubmit = document.getElementById('cm-submit');
const errEl = document.getElementById('cm-error');
function resetContactForm() {
if (!document.getElementById('cm-name')) {
document.getElementById('cm-body').innerHTML = `
<div class="access-field"><label>Ваше имя <span class="req">*</span></label><input id="cm-name" type="text" placeholder="Иван Иванов" /></div>
<div class="access-field"><label>Email <span class="req">*</span></label><input id="cm-email" type="email" placeholder="ivan@company.ru" /></div>
<div class="access-field"><label>Телефон <span class="req">*</span></label><input id="cm-phone" type="tel" placeholder="+7 (999) 000-00-00" /></div>
<div class="access-field"><label>Сообщение <span class="req">*</span></label><textarea id="cm-text" class="access-textarea" placeholder="Ваш вопрос или предложение..." rows="4"></textarea></div>
<div class="access-consent-field"><label class="access-consent-label"><input type="checkbox" id="cm-consent" /><span>Согласен на <a href="/privacy" target="_blank" class="access-consent-link">обработку персональных данных</a></span></label></div>
<div id="cm-error" class="access-modal-error" style="display:none"></div>`;
document.getElementById('cm-footer').innerHTML = `<button type="button" class="access-btn-cancel" id="cm-cancel">Отмена</button><button type="button" class="access-btn-submit" id="cm-submit">Отправить</button>`;
document.getElementById('cm-cancel').addEventListener('click', closeContact);
document.getElementById('cm-submit').addEventListener('click', submitContact);
document.querySelectorAll('#cm-name,#cm-email,#cm-phone,#cm-text').forEach(function(el){ el.addEventListener('input', function(){ el.classList.remove('am-invalid'); }); });
} else {
['cm-name','cm-email','cm-phone','cm-text'].forEach(function(id){ var el=document.getElementById(id); if(el){el.value='';el.classList.remove('am-invalid');} });
var err=document.getElementById('cm-error'); if(err) err.style.display='none';
var btn=document.getElementById('cm-submit'); if(btn){btn.disabled=false;btn.textContent='Отправить';}
}
}
function openContact() {
resetContactForm();
overlay.style.display = 'flex';
document.body.style.overflow = 'hidden';
}
function closeContact() {
overlay.style.display = 'none';
document.body.style.overflow = '';
errEl.style.display = 'none';
document.querySelectorAll('#contact-modal .am-invalid').forEach(el => el.classList.remove('am-invalid'));
}
window._closeContactModal = closeContact;
async function submitContact() {
const nameEl = document.getElementById('cm-name');
const emailEl = document.getElementById('cm-email');
const phoneEl = document.getElementById('cm-phone');
const textEl = document.getElementById('cm-text');
const submitBtn = document.getElementById('cm-submit');
const errorEl = document.getElementById('cm-error');
const name = nameEl ? nameEl.value.trim() : '';
const email = emailEl ? emailEl.value.trim() : '';
const phone = phoneEl ? phoneEl.value.trim() : '';
const text = textEl ? textEl.value.trim() : '';
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const phoneRe = /^[\+\d][\d\s\-\(\)]{6,18}$/;
const consentEl = document.getElementById('cm-consent');
const fields = [
{ el: nameEl, check: () => !!name, msg: 'Введите имя' },
{ el: emailEl, check: () => emailRe.test(email), msg: 'Введите корректный email' },
{ el: phoneEl, check: () => phoneRe.test(phone), msg: 'Введите корректный номер телефона' },
{ el: textEl, check: () => !!text, msg: 'Введите сообщение' },
];
const errors = [];
fields.forEach(f => {
if (f.el && !f.check()) { f.el.classList.add('am-invalid'); errors.push(f.msg); }
else if (f.el) f.el.classList.remove('am-invalid');
});
if (!consentEl || !consentEl.checked) {
errors.push('Необходимо согласие на обработку персональных данных');
const cf = document.querySelector('#contact-modal .access-consent-field');
if (cf) cf.classList.add('am-invalid-consent');
} else {
const cf = document.querySelector('#contact-modal .access-consent-field');
if (cf) cf.classList.remove('am-invalid-consent');
}
if (errors.length) {
if (errorEl) { errorEl.textContent = errors.join(' • '); errorEl.style.display = 'block'; }
return;
}
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Отправка...'; }
if (errorEl) errorEl.style.display = 'none';
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, email, phone, text}),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d.detail || 'Ошибка отправки');
}
document.getElementById('cm-body').innerHTML =
'<div class="am-success-msg">' +
'<div class="am-success-icon">✓</div>' +
'<div class="am-success-title">Отправлено</div>' +
'<div class="am-success-sub">Постараюсь ответить в ближайшее время</div>' +
'</div>';
document.getElementById('cm-footer').innerHTML =
'<button type="button" class="access-btn-cancel" onclick="window._closeContactModal()">Закрыть</button>';
} catch(e) {
const eb = document.getElementById('cm-error');
if (eb) { eb.textContent = e.message || 'Ошибка отправки, попробуйте позже'; eb.style.display = 'block'; }
const sb = document.getElementById('cm-submit');
if (sb) { sb.disabled = false; sb.textContent = 'Отправить'; }
}
}
document.querySelectorAll('#cm-name,#cm-email,#cm-phone,#cm-text').forEach(el => {
el.addEventListener('input', () => el.classList.remove('am-invalid'));
});
document.getElementById('btn-contact-ruslan').addEventListener('click', function(e) {
e.preventDefault();
openContact();
});
btnCancel.addEventListener('click', closeContact);
btnSubmit.addEventListener('click', submitContact);
overlay.addEventListener('click', function(e) { if (e.target === overlay) closeContact(); });
document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && overlay.style.display !== 'none') closeContact(); });
})();
</script>
<!-- Cookie Banner --> <!-- Cookie Banner -->
<div id="cookie-banner" style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:99999;background:rgba(7,15,28,0.97);border-top:1px solid rgba(255,255,255,0.1);backdrop-filter:blur(8px);padding:14px 24px;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;"> <div id="cookie-banner" style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:99999;background:rgba(7,15,28,0.97);border-top:1px solid rgba(255,255,255,0.1);backdrop-filter:blur(8px);padding:14px 24px;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;">
<p style="margin:0;font-size:0.85rem;color:#c8d8ea;line-height:1.5;flex:1;min-width:200px;"> <p style="margin:0;font-size:0.85rem;color:#c8d8ea;line-height:1.5;flex:1;min-width:200px;">
Мы используем файлы cookie, чтобы сделать работу с сайтом удобнее. Нажмите «Принять», чтобы согласиться с использованием файлов cookie в соответствии с <a href="https://www.mont.ru/ru-ru/confidential" target="_blank" style="color:#5b9bd5;text-decoration:underline;">Политикой конфиденциальности</a>. Мы используем файлы cookie, чтобы сделать работу с сайтом удобнее. Нажмите «Принять», чтобы согласиться с использованием файлов cookie в соответствии с <a href="https://www.mont.ru/ru-ru/confidential" target="_blank" style="color:#ff9d3d;text-decoration:underline;">Политикой конфиденциальности</a>.
</p> </p>
<button onclick="document.getElementById('cookie-banner').style.display='none';localStorage.setItem('cookie_accepted','1');" style="flex-shrink:0;padding:8px 22px;background:linear-gradient(135deg,#1a5db5,#2d8cf0);color:#fff;border:none;border-radius:8px;font-size:0.88rem;font-weight:600;cursor:pointer;white-space:nowrap;">Принять</button> <button onclick="document.getElementById('cookie-banner').style.display='none';localStorage.setItem('cookie_accepted','1');" style="flex-shrink:0;padding:8px 22px;background:linear-gradient(135deg,#ffb15c,#e88420);color:#1a0f00;border:none;border-radius:8px;font-size:0.88rem;font-weight:700;cursor:pointer;white-space:nowrap;">Принять</button>
</div> </div>
<script> <script>
if (!localStorage.getItem('cookie_accepted')) { if (!localStorage.getItem('cookie_accepted')) {
+600
View File
@@ -0,0 +1,600 @@
{% set _scheme = request.headers.get('x-forwarded-proto', request.url.scheme) %}
{% set base_url = _scheme + '://' + request.url.netloc + '/' %}
{% set cat_names = categories|map(attribute='name')|list %}
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{ service.name }} — тестовый доступ | Инфраструктурный полигон MONT</title>
<meta name="description" content="{{ meta_description }}" />
<meta name="robots" content="index, follow" />
<link rel="canonical" href="{{ canonical_url }}" />
<meta property="og:type" content="product" />
<meta property="og:url" content="{{ canonical_url }}" />
<meta property="og:title" content="{{ service.name }} — Инфраструктурный полигон MONT" />
<meta property="og:description" content="{{ meta_description }}" />
<meta property="og:image" content="{{ base_url[:-1] }}{{ service.icon_path or '/static/service-placeholder.svg' }}" />
<meta property="og:locale" content="ru_RU" />
<meta property="og:site_name" content="Полигон MONT" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="{{ service.name }}" />
<meta name="twitter:description" content="{{ meta_description }}" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": {{ service.name|tojson }},
"url": {{ canonical_url|tojson }},
"description": {{ meta_description|tojson }},
"applicationCategory": {{ (cat_names[0] if cat_names else "BusinessApplication")|tojson }},
"offers": {
"@type": "Offer",
"price": "0",
"priceCurrency": "RUB",
"description": "Бесплатный тестовый доступ на инфраструктурном полигоне MONT"
},
"provider": {
"@type": "Organization",
"name": "MONT",
"url": "https://www.mont.ru/"
}
}
</script>
<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>
:root{
--lg2-bg:#0a0e14; --lg2-panel:#12181f; --lg2-raised:#1b232d;
--lg2-line:#232b36; --lg2-line-soft:#1a212a;
--lg2-fg:#e7ecf2; --lg2-fg-dim:#8391a3; --lg2-fg-faint:#56626f;
--lg2-accent:#ff9d3d; --lg2-accent-dim:rgba(255,157,61,.14); --lg2-accent-ink:#180d02;
--lg2-bad:#ff6b6b; --lg2-bad-dim:rgba(255,107,107,.12);
--lg2-info:#7fc7ff; --lg2-info-dim:rgba(127,199,255,.10);
--lg2-good:#5fd68a;
}
:root[data-theme="light"]{
--lg2-bg:#eef1f5; --lg2-panel:#ffffff; --lg2-raised:#ffffff;
--lg2-line:#d7dee6; --lg2-line-soft:#e3e8ee;
--lg2-fg:#16202c; --lg2-fg-dim:#5c6b7c; --lg2-fg-faint:#93a1b0;
--lg2-accent:#c96c14; --lg2-accent-dim:rgba(201,108,20,.10); --lg2-accent-ink:#2a1400;
--lg2-bad:#d3453f; --lg2-bad-dim:rgba(211,69,63,.08);
--lg2-info:#1f6fb2; --lg2-info-dim:rgba(31,111,178,.08);
--lg2-good:#1f9d63;
}
@media (prefers-color-scheme:light){
:root:not([data-theme="dark"]){
--lg2-bg:#eef1f5; --lg2-panel:#ffffff; --lg2-raised:#ffffff;
--lg2-line:#d7dee6; --lg2-line-soft:#e3e8ee;
--lg2-fg:#16202c; --lg2-fg-dim:#5c6b7c; --lg2-fg-faint:#93a1b0;
--lg2-accent:#c96c14; --lg2-accent-dim:rgba(201,108,20,.10); --lg2-accent-ink:#2a1400;
--lg2-bad:#d3453f; --lg2-bad-dim:rgba(211,69,63,.08);
--lg2-info:#1f6fb2; --lg2-info-dim:rgba(31,111,178,.08);
--lg2-good:#1f9d63;
}
}
*{ box-sizing:border-box; }
html{ background:var(--lg2-bg); }
body.product-page{
margin:0; min-height:100vh; background:var(--lg2-bg); color:var(--lg2-fg);
font-family:"Ubuntu","IBM Plex Sans",system-ui,sans-serif; overflow-x:hidden;
}
a{ color:inherit; }
.pg-header{
display:flex; align-items:center; justify-content:space-between; gap:16px;
padding:18px clamp(20px,4vw,40px); border-bottom:1px solid var(--lg2-line); background:var(--lg2-panel);
flex-wrap:wrap;
}
.pg-brand{ display:flex; align-items:center; gap:10px; text-decoration:none; }
.pg-brand img{ height:28px; display:block; }
.pg-brand span{ font:700 12px/1 "Ubuntu Mono",monospace; color:var(--lg2-fg-dim); letter-spacing:.06em; text-transform:uppercase; }
.pg-header-actions{ display:flex; gap:10px; }
.pg-btn{
display:inline-flex; align-items:center; gap:8px; border-radius:8px; text-decoration:none; cursor:pointer;
font:600 13px/1 "Ubuntu",sans-serif; padding:10px 18px; border:1px solid var(--lg2-line); white-space:nowrap;
}
.pg-btn-ghost{ background:transparent; color:var(--lg2-fg-dim); }
.pg-btn-ghost:hover{ border-color:var(--lg2-fg-faint); color:var(--lg2-fg); }
.pg-btn-accent{ background:var(--lg2-accent); color:var(--lg2-accent-ink); border-color:var(--lg2-accent); font-weight:700; }
.pg-btn-accent:hover{ filter:brightness(1.06); }
.pg-crumbs{
max-width:1180px; margin:22px auto 0; padding:0 clamp(20px,4vw,40px);
font:600 11px/1 "Ubuntu Mono",monospace; letter-spacing:.08em; text-transform:uppercase;
color:var(--lg2-fg-faint); display:flex; gap:8px; align-items:center; flex-wrap:wrap;
}
.pg-crumbs a{ text-decoration:none; color:var(--lg2-fg-faint); }
.pg-crumbs a:hover{ color:var(--lg2-accent); }
.pg-crumbs .sep{ opacity:.5; }
.pg-crumbs .current{ color:var(--lg2-fg-dim); }
.pg-hero{
max-width:1180px; margin:20px auto 0; padding:0 clamp(20px,4vw,40px);
display:grid; grid-template-columns:112px minmax(0,1fr); gap:26px; align-items:start;
}
@media (max-width:640px){ .pg-hero{ grid-template-columns:1fr; } }
.pg-plate{
width:112px; height:112px; border-radius:18px; background:var(--lg2-raised);
border:1px solid var(--lg2-line); display:flex; align-items:center; justify-content:center; flex:none;
}
.pg-plate img{ width:74px; height:74px; object-fit:contain; }
.pg-eyebrow{
font:700 11px/1 "Ubuntu Mono",monospace; letter-spacing:.1em; text-transform:uppercase;
color:var(--lg2-accent); margin:0 0 10px; display:flex; align-items:center; gap:8px; flex-wrap:wrap;
}
.pg-eyebrow::before{ content:""; width:6px; height:6px; border-radius:50%; background:var(--lg2-good); box-shadow:0 0 0 3px var(--lg2-accent-dim); flex:none; }
.pg-hero h1{ font:800 clamp(24px,3.4vw,36px)/1.15 "Ubuntu",sans-serif; margin:0 0 12px; text-wrap:balance; overflow-wrap:break-word; }
.pg-lede{ font:400 16px/1.6 "Ubuntu",sans-serif; color:var(--lg2-fg-dim); max-width:64ch; margin:0 0 20px; }
.pg-actions{ display:flex; gap:12px; flex-wrap:wrap; align-items:center; }
.pg-note{ font:400 12.5px/1.4 "Ubuntu",sans-serif; color:var(--lg2-fg-faint); }
.pg-prompt{
margin-top:22px; background:var(--lg2-panel); border:1px solid var(--lg2-line); border-radius:9px;
padding:12px 16px; font:400 13px/1.5 "Ubuntu Mono",monospace; color:var(--lg2-fg-dim); max-width:560px;
overflow-wrap:break-word;
}
.pg-prompt .p1{ color:var(--lg2-accent); }
.pg-prompt .p2{ color:var(--lg2-fg-faint); }
.pg-cursor{ display:inline-block; width:7px; height:14px; background:var(--lg2-accent); margin-left:2px; vertical-align:-2px; animation:pg-blink 1.1s step-end infinite; }
@keyframes pg-blink{ 50%{ opacity:0; } }
@media (prefers-reduced-motion: reduce){ .pg-cursor{ animation:none; } }
.pg-layout{
max-width:1180px; margin:44px auto 0; padding:0 clamp(20px,4vw,40px) 60px;
display:grid; grid-template-columns:minmax(0,1fr) 280px; gap:28px;
}
@media (max-width:900px){ .pg-layout{ grid-template-columns:minmax(0,1fr); } }
.pg-card{
background:var(--lg2-panel); border:1px solid var(--lg2-line); border-radius:12px;
padding:26px 28px; margin-bottom:20px;
}
.pg-card > h2:first-child{ margin-top:0; }
.pg-desc h2{
font:700 13px/1 "Ubuntu Mono",monospace; letter-spacing:.08em; text-transform:uppercase;
color:var(--lg2-fg-faint); margin:28px 0 14px; padding-top:20px; border-top:1px solid var(--lg2-line-soft);
}
.pg-desc h2:first-child{ margin-top:0; padding-top:0; border-top:none; }
.pg-desc p{ margin:0 0 14px; color:var(--lg2-fg-dim); font-size:15px; line-height:1.65; }
.pg-desc p:last-child{ margin-bottom:0; }
.pg-desc strong{ color:var(--lg2-fg); }
.pg-desc ul{ list-style:none; margin:0 0 14px; padding:0; display:grid; gap:9px; }
.pg-desc ul li{ display:flex; gap:10px; align-items:flex-start; font-size:14.5px; color:var(--lg2-fg-dim); line-height:1.55; }
.pg-desc ul li::before{
content:""; flex:none; width:7px; height:7px; border-radius:2px; background:var(--lg2-accent);
margin-top:7px; transform:rotate(45deg);
}
.pg-desc hr{ border:none; border-top:1px solid var(--lg2-line-soft); margin:22px 0; }
.pg-side{
background:var(--lg2-panel); border:1px solid var(--lg2-line); border-radius:12px;
padding:22px; position:sticky; top:20px; align-self:start;
}
.pg-side h3{
font:700 11px/1 "Ubuntu Mono",monospace; letter-spacing:.08em; text-transform:uppercase;
color:var(--lg2-fg-faint); margin:0 0 14px;
}
.pg-side-row{ display:flex; justify-content:space-between; gap:10px; padding:9px 0; border-bottom:1px solid var(--lg2-line-soft); font-size:13.5px; }
.pg-side-row:last-of-type{ border-bottom:none; }
.pg-side-row span:first-child{ color:var(--lg2-fg-faint); }
.pg-side-row span:last-child{ color:var(--lg2-fg); font-weight:600; text-align:right; }
.pg-side-cta{ width:100%; margin-top:16px; justify-content:center; }
.pg-catalog{ max-width:1180px; margin:0 auto 60px; padding:0 clamp(20px,4vw,40px); }
.pg-catalog-head{ display:flex; align-items:baseline; justify-content:space-between; margin-bottom:16px; flex-wrap:wrap; gap:8px; }
.pg-catalog-head h2{ font:700 18px/1 "Ubuntu",sans-serif; margin:0; color:var(--lg2-fg); }
.pg-catalog-head a{ font:600 13px/1 "Ubuntu",sans-serif; color:var(--lg2-accent); text-decoration:none; }
.pg-catalog-grid{ display:grid; grid-template-columns:repeat(auto-fill,minmax(210px,1fr)); gap:10px; }
.pg-chip{
display:flex; align-items:center; gap:10px; background:var(--lg2-panel); border:1px solid var(--lg2-line);
border-radius:9px; padding:10px 12px; text-decoration:none; color:var(--lg2-fg-dim);
font:600 13.5px/1.25 "Ubuntu",sans-serif; transition:border-color .12s;
}
.pg-chip:hover{ border-color:var(--lg2-accent); color:var(--lg2-fg); }
.pg-chip img{ width:22px; height:22px; object-fit:contain; flex:none; }
.pg-footer{
border-top:1px solid var(--lg2-line); padding:20px clamp(20px,4vw,40px); text-align:center;
font:400 12.5px/1 "Ubuntu",sans-serif; color:var(--lg2-fg-faint);
}
.pg-footer a{ color:var(--lg2-fg-faint); text-decoration:none; }
/* Re-skin the shared access-modal component (same as login.html). */
.access-modal-overlay{ background: rgba(6,9,14,.82) !important; }
.access-modal{
background: var(--lg2-panel) !important; border: 1px solid var(--lg2-line) !important;
box-shadow: 0 24px 80px rgba(0,0,0,.45) !important;
}
.access-modal-title{ color: var(--lg2-fg) !important; font-family:"Ubuntu",sans-serif; }
.access-modal-sub{ color: var(--lg2-fg-faint) !important; }
.access-field label{ color: var(--lg2-fg-dim) !important; font-family:"Ubuntu",sans-serif; }
.access-field .req{ color: var(--lg2-accent) !important; }
.access-field input[type=text],
.access-field input[type=email],
.access-field input[type=tel],
.access-textarea{
background: var(--lg2-raised) !important; border: 1px solid var(--lg2-line) !important;
color: var(--lg2-fg) !important; font-family:"Ubuntu",sans-serif;
}
.access-field input[type=text]::placeholder,
.access-field input[type=email]::placeholder,
.access-field input[type=tel]::placeholder,
.access-textarea::placeholder{ color: var(--lg2-fg-faint) !important; }
.access-field input[type=text]:focus,
.access-field input[type=email]:focus,
.access-field input[type=tel]:focus,
.access-textarea:focus{
border-color: var(--lg2-accent) !important; box-shadow: 0 0 0 3px var(--lg2-accent-dim) !important;
}
.access-products-wrap{ background: var(--lg2-raised) !important; border: 1px solid var(--lg2-line) !important; }
.access-products-loading{ color: var(--lg2-fg-faint) !important; }
.access-products-cat{ color: var(--lg2-fg-faint) !important; }
.access-product-item{ color: var(--lg2-fg-dim) !important; font-family:"Ubuntu",sans-serif; }
.access-product-item:hover span{ color: var(--lg2-fg) !important; }
.access-product-item input[type=checkbox]{ accent-color: var(--lg2-accent); }
.access-modal-error{
background: var(--lg2-bad-dim) !important; border: 1px solid rgba(255,107,107,.35) !important;
color: var(--lg2-bad) !important;
}
.access-btn-cancel{
background: transparent !important; border: 1px solid var(--lg2-line) !important; color: var(--lg2-fg-dim) !important;
font-family:"Ubuntu",sans-serif;
}
.access-btn-cancel:hover{ border-color: var(--lg2-fg-faint) !important; color: var(--lg2-fg) !important; background: transparent !important; }
.access-btn-submit{
background: var(--lg2-accent) !important; color: var(--lg2-accent-ink) !important; box-shadow: none !important;
font-family:"Ubuntu",sans-serif; font-weight: 700 !important;
}
.access-btn-submit:hover:not(:disabled){ opacity: 1 !important; filter: brightness(1.06); }
.access-consent-label span{ color: var(--lg2-fg-dim) !important; font-family:"Ubuntu",sans-serif; }
.access-consent-link{ color: var(--lg2-accent) !important; }
.am-success-title{ color: var(--lg2-fg) !important; }
.am-success-sub{ color: var(--lg2-fg-dim) !important; }
.am-success-icon{ color: var(--lg2-good) !important; }
</style>
</head>
<body class="product-page">
<header class="pg-header">
<a class="pg-brand" href="/">
<img src="/static/logo-color.png" alt="MONT" />
<span>Инфраструктурный полигон</span>
</a>
<div class="pg-header-actions">
<a class="pg-btn pg-btn-ghost" href="/#products">Все продукты</a>
<a class="pg-btn pg-btn-accent" href="/">Войти в полигон</a>
</div>
</header>
<nav class="pg-crumbs">
<a href="/">Полигон</a><span class="sep">/</span>
<span class="current">{{ service.name }}</span>
</nav>
<div class="pg-hero">
<div class="pg-plate">
<img src="{{ service.icon_path or '/static/service-placeholder.svg' }}" alt="{{ service.name }}" />
</div>
<div>
<div class="pg-eyebrow">Доступен на полигоне{% if cat_names %} · {{ cat_names|join(', ') }}{% endif %}</div>
<h1>{{ service.name }}</h1>
<p class="pg-lede">{{ meta_description }}</p>
<div class="pg-actions">
<button class="pg-btn pg-btn-accent" type="button" data-open-access-modal="1" data-preselect="{{ service.name }}">Запросить тестовый доступ →</button>
<span class="pg-note">Доступ откроется в течение рабочего дня, бесплатно</span>
</div>
<div class="pg-prompt">
<span class="p2">mont@polygon:~/catalog$</span> <span class="p1">./access.sh --product {{ service.slug }}</span><span class="pg-cursor"></span>
</div>
</div>
</div>
<div class="pg-layout">
<div>
<div class="pg-card pg-desc">
{% if description_html %}
{{ description_html }}
{% else %}
<p>{{ service.name }} доступен для тестирования на инфраструктурном полигоне MONT — оставьте заявку, и мы откроем доступ к рабочему стенду.</p>
{% endif %}
</div>
</div>
<aside>
<div class="pg-side">
<h3>Карточка доступа</h3>
{% if cat_names %}
<div class="pg-side-row"><span>Категория</span><span>{{ cat_names|join(', ') }}</span></div>
{% endif %}
<div class="pg-side-row"><span>Тип сессии</span><span>{{ service.type.value }}</span></div>
<button class="pg-btn pg-btn-accent pg-side-cta" type="button" data-open-access-modal="1" data-preselect="{{ service.name }}">Запросить доступ</button>
</div>
</aside>
</div>
{% if other_services %}
<div class="pg-catalog">
<div class="pg-catalog-head">
<h2>Другие продукты полигона</h2>
<a href="/#products">Смотреть весь каталог →</a>
</div>
<div class="pg-catalog-grid">
{% for svc in other_services %}
<a class="pg-chip" href="/product/{{ svc.slug }}">
<img src="{{ svc.icon_path or '/static/service-placeholder.svg' }}" alt="" />
{{ svc.name }}
</a>
{% endfor %}
</div>
</div>
{% endif %}
<footer class="pg-footer">
<a href="mailto:ruslan@ipcom.su">Made by Galyaviev</a>
</footer>
<div id="access-modal" class="access-modal-overlay" style="display:none" aria-modal="true" role="dialog">
<div class="access-modal">
<div class="access-modal-header">
<div class="access-modal-title">Запрос на доступ</div>
</div>
<div class="access-modal-body">
<div class="access-field">
<label>Имя и фамилия <span class="req">*</span></label>
<input id="am-name" type="text" placeholder="Иван Иванов" />
</div>
<div class="access-field">
<label>Название компании <span class="req">*</span></label>
<input id="am-company" type="text" placeholder="ООО Компания" />
</div>
<div class="access-field">
<label>Email <span class="req">*</span></label>
<input id="am-email" type="email" placeholder="ivan@company.ru" />
</div>
<div class="access-field">
<label>Телефон <span class="req">*</span></label>
<input id="am-phone" type="tel" placeholder="+7 (999) 000-00-00" />
</div>
<div class="access-field">
<label>Ваш менеджер в MONT</label>
<input id="am-manager" type="text" placeholder="Если известно — укажите имя" />
</div>
<div class="access-field">
<label style="display:flex;align-items:center;justify-content:space-between">
Интересующие продукты
<label id="am-select-all-wrap" style="display:none;font-weight:400;font-size:13px;cursor:pointer;gap:6px;align-items:center">
<input type="checkbox" id="am-select-all" style="cursor:pointer" />
<span>Выбрать все</span>
</label>
</label>
<div id="am-products" class="access-products-wrap">
<div class="access-products-loading">Загрузка...</div>
</div>
</div>
<div class="access-consent-field">
<label class="access-consent-label">
<input type="checkbox" id="am-consent" />
<span>Согласен на <a href="/privacy" target="_blank" class="access-consent-link">обработку персональных данных</a></span>
</label>
</div>
<div id="am-error" class="access-modal-error" style="display:none"></div>
</div>
<div class="access-modal-footer">
<button type="button" class="access-btn-cancel" id="am-cancel">Отмена</button>
<button type="button" class="access-btn-submit" id="am-submit">Запросить доступ</button>
</div>
</div>
</div>
<script>
(function() {
const overlay = document.getElementById('access-modal');
const btnCancel = document.getElementById('am-cancel');
const btnSubmit = document.getElementById('am-submit');
const errEl = document.getElementById('am-error');
let productsLoaded = false;
let pendingPreselect = '';
function resetAccessForm() {
if (!document.getElementById('am-name')) {
document.querySelector('.access-modal-body').innerHTML = `
<div class="access-field"><label>Имя и фамилия <span class="req">*</span></label><input id="am-name" type="text" placeholder="Иван Иванов" /></div>
<div class="access-field"><label>Название компании <span class="req">*</span></label><input id="am-company" type="text" placeholder="ООО Компания" /></div>
<div class="access-field"><label>Email <span class="req">*</span></label><input id="am-email" type="email" placeholder="ivan@company.ru" /></div>
<div class="access-field"><label>Телефон <span class="req">*</span></label><input id="am-phone" type="tel" placeholder="+7 (999) 000-00-00" /></div>
<div class="access-field"><label>Ваш менеджер в MONT</label><input id="am-manager" type="text" placeholder="Если известно — укажите имя" /></div>
<div class="access-field"><label>Интересующие продукты</label><div id="am-products" class="access-products-wrap"><div class="access-products-loading">Загрузка...</div></div></div>
<div class="access-consent-field"><label class="access-consent-label"><input type="checkbox" id="am-consent" /><span>Согласен на <a href="/privacy" target="_blank" class="access-consent-link">обработку персональных данных</a></span></label></div>
<div id="am-error" class="access-modal-error" style="display:none"></div>`;
document.querySelector('.access-modal-footer').innerHTML = `<button type="button" class="access-btn-cancel" id="am-cancel">Отмена</button><button type="button" class="access-btn-submit" id="am-submit">Запросить доступ</button>`;
document.getElementById('am-cancel').addEventListener('click', closeModal);
document.getElementById('am-submit').addEventListener('click', submitForm);
document.querySelectorAll('#am-name,#am-company,#am-email,#am-phone').forEach(function(el){ el.addEventListener('input', function(){ el.classList.remove('am-invalid'); }); });
productsLoaded = false;
} else {
['am-name','am-company','am-email','am-phone','am-manager'].forEach(function(id){ var el=document.getElementById(id); if(el){el.value='';el.classList.remove('am-invalid');} });
document.querySelectorAll('#am-products input[type=checkbox]').forEach(function(cb){ cb.checked=false; });
var saBox = document.getElementById('am-select-all'); if (saBox) saBox.checked = false;
var err=document.getElementById('am-error'); if(err) err.style.display='none';
var btn=document.getElementById('am-submit'); if(btn){btn.disabled=false;btn.textContent='Запросить доступ';}
}
}
function openModal(preselect) {
pendingPreselect = preselect || '';
resetAccessForm();
overlay.style.display = 'flex';
document.body.style.overflow = 'hidden';
if (!productsLoaded) loadProducts();
else applyPreselect();
}
function applyPreselect() {
if (!pendingPreselect) return;
const cb = document.querySelector('#am-products input[type=checkbox][value="' + pendingPreselect.replace(/"/g,'\\"') + '"]');
if (cb) cb.checked = true;
}
function closeModal() {
overlay.style.display = 'none';
document.body.style.overflow = '';
errEl.style.display = 'none';
document.querySelectorAll('.am-invalid').forEach(el => el.classList.remove('am-invalid'));
}
window._closeAccessModal = function() { closeModal(); productsLoaded = false; };
async function loadProducts() {
const wrap = document.getElementById('am-products');
try {
const res = await fetch('/api/public/services-by-category');
const data = await res.json();
wrap.innerHTML = '';
for (const [cat, svcs] of Object.entries(data)) {
const group = document.createElement('div');
group.className = 'access-products-group';
group.innerHTML = '<div class="access-products-cat">' + cat + '</div>';
for (const svc of svcs) {
const lbl = document.createElement('label');
lbl.className = 'access-product-item';
lbl.innerHTML = '<input type="checkbox" value="' + svc.name.replace(/"/g, '&quot;') + '" /><span>' + svc.name + '</span>';
group.appendChild(lbl);
}
wrap.appendChild(group);
}
productsLoaded = true;
applyPreselect();
const saWrap = document.getElementById('am-select-all-wrap');
if (saWrap) saWrap.style.display = 'flex';
const saBox = document.getElementById('am-select-all');
if (saBox) {
saBox.addEventListener('change', function() {
document.querySelectorAll('#am-products input[type=checkbox]').forEach(function(cb){ cb.checked = saBox.checked; });
});
wrap.addEventListener('change', function(e) {
if (e.target === saBox) return;
const all = [...document.querySelectorAll('#am-products input[type=checkbox]')];
saBox.checked = all.length > 0 && all.every(function(cb){ return cb.checked; });
});
}
} catch(e) {
wrap.innerHTML = '<div class="access-products-loading">Не удалось загрузить список</div>';
}
}
async function submitForm() {
const nameEl = document.getElementById('am-name');
const companyEl = document.getElementById('am-company');
const emailEl = document.getElementById('am-email');
const phoneEl = document.getElementById('am-phone');
const managerEl = document.getElementById('am-manager');
const submitBtn = document.getElementById('am-submit');
const errorEl = document.getElementById('am-error');
const name = nameEl ? nameEl.value.trim() : '';
const company = companyEl ? companyEl.value.trim() : '';
const email = emailEl ? emailEl.value.trim() : '';
const phone = phoneEl ? phoneEl.value.trim() : '';
const manager = managerEl ? managerEl.value.trim() : '';
const checked = [...document.querySelectorAll('#am-products input[type=checkbox]:checked')];
const products = checked.map(c => c.value);
const emailRe = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const phoneRe = /^[\+\d][\d\s\-\(\)]{6,18}$/;
const consentEl = document.getElementById('am-consent');
const fields = [
{ el: nameEl, check: () => !!name, msg: 'Введите имя и фамилию' },
{ el: companyEl, check: () => !!company, msg: 'Введите название компании' },
{ el: emailEl, check: () => emailRe.test(email), msg: 'Введите корректный email' },
{ el: phoneEl, check: () => phoneRe.test(phone), msg: 'Введите корректный номер телефона' },
];
const errors = [];
fields.forEach(f => {
if (f.el && !f.check()) { f.el.classList.add('am-invalid'); errors.push(f.msg); }
else if (f.el) f.el.classList.remove('am-invalid');
});
if (!consentEl || !consentEl.checked) {
errors.push('Необходимо согласие на обработку персональных данных');
const cf = document.querySelector('.access-consent-field');
if (cf) cf.classList.add('am-invalid-consent');
} else {
const cf = document.querySelector('.access-consent-field');
if (cf) cf.classList.remove('am-invalid-consent');
}
if (errors.length) {
if (errorEl) { errorEl.textContent = errors.join(' • '); errorEl.style.display = 'block'; }
return;
}
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Отправка...'; }
if (errorEl) errorEl.style.display = 'none';
try {
const res = await fetch('/api/request-access', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({name, company, email, phone, manager, products}),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
throw new Error(d.detail || 'Ошибка отправки');
}
const body = document.querySelector('#access-modal .access-modal-body');
const footer = document.querySelector('#access-modal .access-modal-footer');
body.innerHTML = '<div class="am-success-msg">' +
'<div class="am-success-icon">✓</div>' +
'<div class="am-success-title">Запрос отправлен</div>' +
'<div class="am-success-sub">После утверждения доступы придут на электронную почту <strong>' + email + '</strong></div>' +
'</div>';
footer.innerHTML = '<button type="button" class="access-btn-cancel" onclick="window._closeAccessModal()">Закрыть</button>';
} catch(e) {
const eb = document.getElementById('am-error');
if (eb) { eb.textContent = e.message || 'Ошибка отправки, попробуйте позже'; eb.style.display = 'block'; }
const sb = document.getElementById('am-submit');
if (sb) { sb.disabled = false; sb.textContent = 'Запросить доступ'; }
}
}
document.querySelectorAll('#am-name,#am-company,#am-email,#am-phone').forEach(el => {
el.addEventListener('input', () => el.classList.remove('am-invalid'));
});
document.querySelectorAll('[data-open-access-modal]').forEach(el => {
el.addEventListener('click', function(e) {
e.preventDefault();
openModal(el.getAttribute('data-preselect') || '');
});
});
btnCancel.addEventListener('click', closeModal);
btnSubmit.addEventListener('click', submitForm);
overlay.addEventListener('click', function(e) {
if (e.target === overlay) closeModal();
});
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeModal();
});
})();
</script>
</body>
</html>
+21
View File
@@ -1,6 +1,7 @@
import datetime as dt import datetime as dt
import json import json
import logging import logging
import re
import contextvars import contextvars
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
@@ -86,6 +87,26 @@ def format_service_comment(raw_text: str) -> Markup:
return Markup(_md(raw)) return Markup(_md(raw))
def format_seo_description(raw_text: str) -> Markup:
"""Render a service's long-form SEO description (public /product/{slug}
page) to HTML. Unlike format_service_comment() (short dashboard card
text), this source text may carry its own leading '# Product Name'
title line - the page template already renders an <h1> with the name,
so a duplicate <h1> here would hurt SEO. We drop that leading title
line if present, and downgrade any other single-# headings (section
headers like '# Возможности') to ## so the page never emits more than
one <h1>."""
raw = (raw_text or "").replace("\r\n", "\n").replace("\r", "\n").strip()
if not raw:
return Markup("")
lines = raw.split("\n")
if lines and re.match(r"^#\s+", lines[0]):
lines = lines[1:]
body = "\n".join(lines).strip()
body = re.sub(r"(?m)^#\s+", "## ", body)
return Markup(_md(body))
def parse_rdp_target(target: str) -> dict: def parse_rdp_target(target: str) -> dict:
raw = (target or "").strip() raw = (target or "").strip()
if not raw: if not raw:
+2 -2
View File
@@ -180,8 +180,8 @@ cat > /opt/portal/index.html <<HTML
const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1"; const enableHeartbeat = "${ENABLE_HEARTBEAT}" === "1";
const sid = new URLSearchParams(location.search).get('sid'); const sid = new URLSearchParams(location.search).get('sid');
const TOUCH_PATH = sid ? `/api/sessions/${sid}/touch` : '${TOUCH_PATH}'; const TOUCH_PATH = sid ? \`/api/sessions/\${sid}/touch\` : '${TOUCH_PATH}';
const CLOSE_PATH = sid ? `/api/sessions/${sid}/close` : TOUCH_PATH.replace(/\/touch$/, '/close'); const CLOSE_PATH = sid ? \`/api/sessions/\${sid}/close\` : TOUCH_PATH.replace(/\/touch$/, '/close');
const SESSION_CLOSED_URL = '/?session_closed=idle'; const SESSION_CLOSED_URL = '/?session_closed=idle';
function goSessionClosed(reason) { function goSessionClosed(reason) {