feat: command center dashboard v1

This commit is contained in:
Maksim Grachev
2026-04-21 13:21:18 +03:00
parent d5967e5728
commit e9037604f8
+898
View File
@@ -0,0 +1,898 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>24mycloud</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f5f5f7;
--nav: #ffffff;
--card: #ffffff;
--card-hover: #fafafa;
--border: #e5e5ea;
--border-hover: #d1d1d6;
--text: #1d1d1f;
--text-2: #48484a;
--text-3: #8e8e93;
--accent: #5856d6;
--accent-light: #ededfc;
--green: #30b455;
--green-bg: #e8f8ee;
--orange: #e68a1a;
--orange-bg: #fef3e2;
--red: #e5383b;
--red-bg: #fde8e8;
--blue: #3a7bd5;
--blue-bg: #e8f0fb;
--purple: #8b5cf6;
--purple-bg: #f0ebfe;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.04);
--shadow: 0 1px 3px rgba(0,0,0,0.06), 0 2px 8px rgba(0,0,0,0.04);
--shadow-lg: 0 4px 16px rgba(0,0,0,0.08);
--r: 8px;
--r-lg: 12px;
}
html[data-theme="dark"] {
--bg: #0f0f13;
--nav: #18181e;
--card: #1e1e26;
--card-hover: #26262f;
--border: #2c2c36;
--border-hover: #3c3c48;
--text: #ececf0;
--text-2: #a0a0ac;
--text-3: #5c5c6a;
--accent: #7c7af7;
--accent-light: #252540;
--green: #3dd68c;
--green-bg: #152e20;
--orange: #f5a623;
--orange-bg: #2e2210;
--red: #f06c6e;
--red-bg: #2e1414;
--blue: #5a9cf5;
--blue-bg: #14233a;
--purple: #9d7ffa;
--purple-bg: #221a38;
--shadow-sm: 0 1px 2px rgba(0,0,0,0.2);
--shadow: 0 1px 3px rgba(0,0,0,0.3), 0 2px 8px rgba(0,0,0,0.2);
--shadow-lg: 0 4px 16px rgba(0,0,0,0.4);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
}
/* ===== AUTH ===== */
#authScreen {
position: fixed; inset: 0; z-index: 9999;
background: #0c0c14;
display: flex; align-items: center; justify-content: center;
overflow: hidden;
transition: opacity 0.5s, transform 0.5s;
}
#authScreen.hidden { opacity: 0; pointer-events: none; transform: scale(1.05); }
.auth-bg {
position: absolute; inset: 0;
background:
radial-gradient(ellipse 600px 400px at 30% 20%, rgba(88,86,214,0.12) 0%, transparent 70%),
radial-gradient(ellipse 500px 350px at 70% 80%, rgba(59,130,246,0.08) 0%, transparent 70%);
}
.auth-grid {
position: absolute; inset: 0;
background-image:
linear-gradient(rgba(255,255,255,0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.02) 1px, transparent 1px);
background-size: 60px 60px;
animation: gridMove 20s linear infinite;
}
@keyframes gridMove {
0% { transform: translate(0, 0); }
100% { transform: translate(60px, 60px); }
}
.auth-particles {
position: absolute; inset: 0; overflow: hidden;
}
.auth-particle {
position: absolute;
width: 2px; height: 2px;
background: rgba(99,102,241,0.4);
border-radius: 50%;
animation: float linear infinite;
}
@keyframes float {
0% { transform: translateY(100vh) scale(0); opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { transform: translateY(-10vh) scale(1); opacity: 0; }
}
.auth-box {
position: relative;
background: rgba(24,24,30,0.8);
backdrop-filter: blur(20px);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 16px;
padding: 48px 40px;
width: 380px;
text-align: center;
animation: authIn 0.6s ease-out;
box-shadow: 0 8px 32px rgba(0,0,0,0.4), 0 0 0 1px rgba(99,102,241,0.05);
}
@keyframes authIn {
0% { opacity: 0; transform: translateY(20px) scale(0.96); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
.auth-logo {
width: 56px; height: 56px;
background: linear-gradient(135deg, #5856d6, #818cf8);
border-radius: 14px;
display: inline-flex; align-items: center; justify-content: center;
color: #fff;
margin-bottom: 24px;
box-shadow: 0 4px 20px rgba(88,86,214,0.3);
animation: logoPulse 3s ease-in-out infinite;
}
@keyframes logoPulse {
0%, 100% { box-shadow: 0 4px 20px rgba(88,86,214,0.3); }
50% { box-shadow: 0 4px 30px rgba(88,86,214,0.5); }
}
.auth-title {
font-size: 22px; font-weight: 700; color: #ececf0;
margin-bottom: 4px; letter-spacing: -0.3px;
}
.auth-sub {
font-size: 13px; color: #5c5c6a;
margin-bottom: 28px;
}
.auth-field {
position: relative;
margin-bottom: 0;
}
.auth-input {
width: 100%; padding: 12px 16px 12px 42px;
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 10px; color: #ececf0;
font-size: 14px; font-family: inherit;
outline: none; transition: all 0.2s;
}
.auth-input::placeholder { color: #4a4a5a; }
.auth-input:focus { border-color: var(--accent); background: rgba(255,255,255,0.06); box-shadow: 0 0 0 3px rgba(88,86,214,0.15); }
.auth-input-icon {
position: absolute; left: 14px; top: 50%; transform: translateY(-50%);
color: #5c5c6a;
}
.auth-btn {
width: 100%; padding: 12px;
background: linear-gradient(135deg, #5856d6, #6e6ce8);
border: none;
border-radius: 10px; color: #fff;
font-size: 14px; font-weight: 600; font-family: inherit;
cursor: pointer; margin-top: 14px;
transition: all 0.2s;
position: relative; overflow: hidden;
}
.auth-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(88,86,214,0.4); }
.auth-btn:active { transform: translateY(0); }
.auth-btn-shine {
position: absolute; top: 0; left: -100%; width: 100%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.15), transparent);
animation: shine 3s infinite;
}
@keyframes shine {
0% { left: -100%; }
50%,100% { left: 100%; }
}
.auth-error {
color: #f06c6e; font-size: 12px;
margin-top: 12px; min-height: 18px;
}
.auth-error.shake { animation: shake 0.4s ease; }
@keyframes shake {
0%,100% { transform: translateX(0); }
25% { transform: translateX(-8px); }
75% { transform: translateX(8px); }
}
.auth-footer {
margin-top: 28px; font-size: 11px; color: #3a3a48;
display: flex; align-items: center; justify-content: center; gap: 6px;
}
/* ===== NAV ===== */
.nav {
width: 220px; min-height: 100vh;
background: var(--nav);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
position: fixed; left: 0; top: 0; bottom: 0;
z-index: 100;
}
.nav-head {
padding: 20px 16px;
border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 10px;
}
.nav-logo {
width: 32px; height: 32px;
background: linear-gradient(135deg, var(--accent), #818cf8);
border-radius: 8px;
display: flex; align-items: center; justify-content: center;
color: #fff;
}
.nav-name { font-size: 14px; font-weight: 700; }
.nav-sub { font-size: 11px; color: var(--text-3); }
.nav-group { padding: 14px 10px 6px; }
.nav-group-label {
font-size: 10px; font-weight: 600;
text-transform: uppercase; letter-spacing: 1.2px;
color: var(--text-3); padding: 0 6px; margin-bottom: 4px;
}
.nav-links { display: flex; flex-direction: column; gap: 1px; }
.nav-link {
display: flex; align-items: center; gap: 8px;
padding: 8px 10px; border-radius: var(--r);
font-size: 13px; font-weight: 500;
color: var(--text-2); cursor: pointer;
transition: all 0.12s; border: 1px solid transparent;
}
.nav-link:hover { background: var(--bg); color: var(--text); }
.nav-link.on { background: var(--accent-light); color: var(--accent); }
.nav-link svg { width: 16px; height: 16px; flex-shrink: 0; }
.nav-link .cnt {
margin-left: auto; font-size: 10px; font-weight: 600;
padding: 0 6px; border-radius: 8px;
background: var(--bg); color: var(--text-3);
line-height: 18px;
}
.nav-link.on .cnt { background: var(--accent); color: #fff; }
.nav-foot {
margin-top: auto; padding: 14px 16px;
border-top: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
}
.nav-live {
display: flex; align-items: center; gap: 6px;
font-size: 11px; color: var(--text-3);
}
.live-dot {
width: 6px; height: 6px; border-radius: 50%;
background: var(--green);
box-shadow: 0 0 6px var(--green);
animation: blink 2s infinite;
}
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:.3} }
.theme-btn {
width: 28px; height: 28px; border-radius: 6px;
background: var(--bg); border: 1px solid var(--border);
cursor: pointer; display: flex; align-items: center; justify-content: center;
font-size: 14px; transition: all 0.15s;
}
.theme-btn:hover { border-color: var(--border-hover); }
/* ===== MAIN ===== */
.main { margin-left: 220px; flex: 1; min-height: 100vh; }
.top {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 28px;
border-bottom: 1px solid var(--border);
background: var(--nav);
position: sticky; top: 0; z-index: 50;
}
.top h2 { font-size: 15px; font-weight: 700; }
.top-right { display: flex; align-items: center; gap: 10px; }
.top-btn {
padding: 5px 10px; border-radius: var(--r);
background: var(--bg); border: 1px solid var(--border);
color: var(--text-2); font-size: 12px; font-family: inherit;
cursor: pointer; font-weight: 500; transition: all 0.12s;
}
.top-btn:hover { border-color: var(--border-hover); color: var(--text); }
.top-clock {
font-size: 12px; color: var(--text-3);
font-family: 'JetBrains Mono', monospace;
}
.pg { display: none; padding: 24px 28px; }
.pg.on { display: block; }
/* ===== STATS ===== */
.stats { display: grid; grid-template-columns: repeat(4,1fr); gap: 14px; margin-bottom: 28px; }
.st {
background: var(--card); border: 1px solid var(--border);
border-radius: var(--r-lg); padding: 18px 20px;
transition: all 0.15s;
}
.st:hover { box-shadow: var(--shadow); transform: translateY(-1px); }
.st-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.st-icon {
width: 32px; height: 32px; border-radius: var(--r);
display: flex; align-items: center; justify-content: center; font-size: 15px;
}
.st-tag {
font-size: 10px; font-weight: 600; padding: 2px 7px; border-radius: 10px;
}
.st-val { font-size: 28px; font-weight: 700; letter-spacing: -1px; line-height: 1; }
.st-lbl { font-size: 12px; color: var(--text-3); margin-top: 3px; }
/* ===== TWO COL ===== */
.cols { display: grid; grid-template-columns: 1fr 340px; gap: 20px; }
.sec-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.sec-t { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-3); }
.sec-a { font-size: 12px; color: var(--accent); cursor: pointer; font-weight: 500; }
.sec-a:hover { text-decoration: underline; }
/* ===== COMMITS ===== */
.feed { display: flex; flex-direction: column; gap: 6px; }
.feed-day { font-size: 11px; font-weight: 600; color: var(--text-3); text-transform: uppercase; letter-spacing: .8px; padding: 10px 0 2px; }
.cm {
display: flex; align-items: flex-start; gap: 10px;
padding: 10px 14px; background: var(--card);
border: 1px solid var(--border); border-radius: var(--r);
transition: all 0.12s; cursor: default;
}
.cm:hover { border-color: var(--border-hover); background: var(--card-hover); }
.cm-dot {
width: 8px; height: 8px; border-radius: 50%;
margin-top: 5px; flex-shrink: 0;
}
.cm-body { flex: 1; min-width: 0; }
.cm-msg { font-size: 13px; font-weight: 500; line-height: 1.35; margin-bottom: 3px; word-break: break-word; }
.cm-meta { display: flex; gap: 8px; font-size: 11px; color: var(--text-3); align-items: center; }
.cm-repo { color: var(--accent); font-weight: 600; }
.cm-sha { font-family: 'JetBrains Mono', monospace; }
.cm-t { font-size: 12px; color: var(--text-3); font-family: 'JetBrains Mono', monospace; flex-shrink: 0; }
/* ===== SIDE PANELS ===== */
.side { display: flex; flex-direction: column; gap: 16px; }
.box {
background: var(--card); border: 1px solid var(--border);
border-radius: var(--r-lg); padding: 16px 18px;
}
.box-t { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-3); margin-bottom: 12px; }
.sv { display: flex; align-items: center; gap: 8px; padding: 7px 0; }
.sv-dot { width: 5px; height: 5px; border-radius: 50%; flex-shrink: 0; }
.sv-info { flex: 1; }
.sv-name { font-size: 13px; font-weight: 500; }
.sv-url { font-size: 10px; color: var(--text-3); }
.sv-st { font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 8px; }
.bar-wrap { margin-bottom: 8px; }
.bar-head { display: flex; justify-content: space-between; font-size: 11px; margin-bottom: 4px; }
.bar-head span:first-child { font-weight: 500; color: var(--text-2); }
.bar-head span:last-child { color: var(--text-3); }
.bar-track { height: 6px; background: var(--bg); border-radius: 3px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 3px; }
.ct { display: flex; justify-content: space-between; align-items: center; padding: 6px 0; font-size: 12px; border-bottom: 1px solid var(--border); }
.ct:last-child { border-bottom: none; }
.ct-n { font-weight: 500; }
.ct-d { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 8px; }
/* ===== PROJECTS ===== */
.pj-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 14px; }
.pj {
background: var(--card); border: 1px solid var(--border);
border-radius: var(--r-lg); padding: 22px;
transition: all 0.15s; cursor: pointer;
}
.pj:hover { border-color: var(--accent); box-shadow: var(--shadow-lg); transform: translateY(-2px); }
.pj-top { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.pj-name { font-size: 17px; font-weight: 700; letter-spacing: -0.2px; }
.pj-lang { font-size: 11px; font-weight: 600; padding: 2px 10px; border-radius: 12px; }
.pj-desc { font-size: 13px; color: var(--text-2); line-height: 1.45; margin-bottom: 14px; }
.pj-info { display: flex; gap: 14px; padding-top: 14px; border-top: 1px solid var(--border); font-size: 12px; color: var(--text-3); }
.pj-info b { color: var(--text-2); font-weight: 600; }
.pj-cms { margin-top: 14px; display: flex; flex-direction: column; gap: 4px; }
.pj-cm {
display: flex; align-items: center; gap: 8px;
padding: 6px 10px; background: var(--bg);
border-radius: var(--r); font-size: 12px;
}
.pj-cm-sha { font-family: 'JetBrains Mono', monospace; color: var(--accent); font-size: 11px; flex-shrink: 0; }
.pj-cm-msg { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-2); }
.pj-cm-t { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--text-3); flex-shrink: 0; }
/* ===== ISSUES ===== */
.iss-list { display: flex; flex-direction: column; gap: 6px; }
.iss {
display: flex; align-items: flex-start; gap: 10px;
padding: 14px 18px; background: var(--card);
border: 1px solid var(--border); border-radius: var(--r);
transition: all 0.12s;
}
.iss:hover { border-color: var(--border-hover); }
.iss-ico {
width: 18px; height: 18px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; margin-top: 2px; font-size: 10px; font-weight: 700;
}
.iss-body { flex: 1; }
.iss-title { font-size: 14px; font-weight: 500; margin-bottom: 3px; }
.iss-meta { font-size: 11px; color: var(--text-3); display: flex; gap: 8px; }
.iss-lbl { font-size: 10px; font-weight: 600; padding: 1px 7px; border-radius: 8px; flex-shrink: 0; }
.empty { text-align: center; padding: 36px; color: var(--text-3); font-size: 13px; }
@media (max-width: 1100px) { .cols { grid-template-columns: 1fr; } .stats { grid-template-columns: repeat(2,1fr); } }
@media (max-width: 700px) { .nav { display: none; } .main { margin-left: 0; } .pj-grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<!-- AUTH -->
<div id="authScreen">
<div class="auth-bg"></div>
<div class="auth-grid"></div>
<div class="auth-particles" id="authParticles"></div>
<div class="auth-box">
<div class="auth-logo">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/></svg>
</div>
<div class="auth-title">24mycloud</div>
<div class="auth-sub">Панель управления инфраструктурой</div>
<div class="auth-field">
<svg class="auth-input-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
<input class="auth-input" id="authPass" type="password" placeholder="Введите пароль" autofocus
onkeydown="if(event.key==='Enter')doAuth()">
</div>
<button class="auth-btn" onclick="doAuth()">
<span class="auth-btn-shine"></span>
Войти
</button>
<div class="auth-error" id="authErr"></div>
<div class="auth-footer">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
Защищённое соединение
</div>
</div>
</div>
<!-- NAV -->
<nav class="nav">
<div class="nav-head">
<div class="nav-logo"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 10h-1.26A8 8 0 1 0 9 20h9a5 5 0 0 0 0-10z"/></svg></div>
<div><div class="nav-name">mycloud</div><div class="nav-sub">панель управления</div></div>
</div>
<div class="nav-group">
<div class="nav-group-label">Разделы</div>
<div class="nav-links">
<div class="nav-link on" data-p="overview">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
Обзор
</div>
<div class="nav-link" data-p="projects">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>
Проекты <span class="cnt" id="nRC"></span>
</div>
<div class="nav-link" data-p="activity">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
Активность
</div>
<div class="nav-link" data-p="issues">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
Задачи <span class="cnt" id="nIC"></span>
</div>
</div>
</div>
<div class="nav-group">
<div class="nav-group-label">Сервисы</div>
<div class="nav-links" id="nSvc"></div>
</div>
<div class="nav-foot">
<div class="nav-live"><div class="live-dot"></div> Все системы ОК</div>
<button class="theme-btn" id="themeBtn" onclick="toggleTheme()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg></button>
</div>
</nav>
<!-- MAIN -->
<div class="main">
<div class="top">
<h2 id="pgT">Обзор</h2>
<div class="top-right">
<button class="top-btn" onclick="loadData()">Обновить</button>
<div class="top-clock" id="clk"></div>
</div>
</div>
<div class="pg on" id="pg-overview">
<div class="stats" id="stG"></div>
<div class="cols">
<div>
<div class="sec-head">
<div class="sec-t">Последние коммиты</div>
<div class="sec-a" onclick="go('activity')">Все</div>
</div>
<div class="feed" id="oFeed"></div>
</div>
<div class="side">
<div class="box"><div class="box-t">Сервисы</div><div id="sSvc"></div></div>
<div class="box"><div class="box-t">Хранилище — RAID1</div><div id="sStor"></div></div>
<div class="box"><div class="box-t">SSL сертификаты</div><div id="sCert"></div></div>
</div>
</div>
</div>
<div class="pg" id="pg-projects"><div class="pj-grid" id="pjG"></div></div>
<div class="pg" id="pg-activity"><div class="feed" id="aFeed"></div></div>
<div class="pg" id="pg-issues"><div class="iss-list" id="iList"></div></div>
</div>
<script>
// ===== CONFIG =====
const C = {
api: 'https://git.24mycloud.ru',
token: '157fcfc85bba2e9f6ce01c0327ecf15d7d10ab56',
pass: '5X4vTwEhhwUNT6ecmkgk9v2J',
refresh: 30000,
svcs: [
{ n:'Gitea', u:'https://git.24mycloud.ru', c:'var(--green)' },
{ n:'Immich', u:'https://photos.24mycloud.ru', c:'var(--blue)' },
{ n:'NPM', u:'http://192.168.0.97:81', c:'var(--orange)', lan:1 },
],
certs: [
{ d:'git.24mycloud.ru', e:'2026-07-19' },
{ d:'photos.24mycloud.ru', e:'2026-07-19' },
],
stor: { used:280, total:7475 },
};
// ===== MOCK =====
const MOCK = [
{ name:'CoffeeOs', full_name:'maksim/CoffeeOs', language:'TypeScript', private:true,
description:'POS-система Coffee Like — точки, маржинальность, KPI, аналитика продаж',
default_branch:'main',
commits: [
{sha:'5af055a92f',commit:{message:'chore: удалён устаревший GitHub Actions workflow',author:{name:'Maksim Grachev',date:ago(1)}}},
{sha:'1d675f3864',commit:{message:'fix(ui): убрана рамка accent у карточки маржи KPI',author:{name:'Maksim Grachev',date:ago(1.5)}}},
{sha:'be475a5acc',commit:{message:'feat(ui): улучшен дизайн KPI-карточек в блоке точной маржи',author:{name:'Maksim Grachev',date:ago(2)}}},
{sha:'f52904f94a',commit:{message:'test: health endpoint — источник деплоя для webhook Gitea',author:{name:'Maksim Grachev',date:ago(2.5)}}},
{sha:'da01c05214',commit:{message:'feat(ui): favicon заменён на лого Coffee Like',author:{name:'Maksim Grachev',date:ago(12)}}},
{sha:'76851ab6ef',commit:{message:'docs: лог сессии + active-work + changelog оптимизации 20.04',author:{name:'Maksim Grachev',date:ago(13)}}},
{sha:'3971d82df0',commit:{message:'feat(db): автообслуживание в ночной синхронизации + очистка индексов',author:{name:'Maksim Grachev',date:ago(14)}}},
],
issues: [
{number:12,title:'KPI: фильтр по периоду (неделя/месяц/квартал)',state:'open',created_at:ago(24),user:{login:'maksim'},labels:[{name:'улучшение',color:'84b6eb'}]},
{number:10,title:'Маржинальность неверно считается при скидке > 30%',state:'open',created_at:ago(48),user:{login:'maksim'},labels:[{name:'баг',color:'e11d48'}]},
{number:8,title:'Интеграция с iiko: синхронизация меню и стоп-листа',state:'open',created_at:ago(72),user:{login:'maksim'},labels:[{name:'фича',color:'22c55e'}]},
],
},
{ name:'Remington-CRM', full_name:'maksim/Remington-CRM', language:'Python', private:true,
description:'CRM + модерация Ozon/WB — фильтры отзывов, стоп-слова, автоответы, кэш',
default_branch:'main',
commits: [
{sha:'b0174270ab',commit:{message:'test: повторная проверка webhook автодеплоя',author:{name:'Vlad (Povelitel)',date:ago(0.5)}}},
{sha:'732541cbfc',commit:{message:'test: webhook автодеплой через Gitea',author:{name:'Vlad (Povelitel)',date:ago(0.8)}}},
{sha:'83c9a4a08f',commit:{message:'feat(ozon): стоп-слова + текстовый фильтр + exclude_words',author:{name:'Vlad (Povelitel)',date:ago(1.7)}}},
{sha:'9f43d61599',commit:{message:'fix(ozon): date picker с пресетами, исправлен fmtDateShort',author:{name:'Vlad (Povelitel)',date:ago(10)}}},
{sha:'33c763ed46',commit:{message:'fix(cache): сериализация Pydantic через model_dump перед кэшированием',author:{name:'Vlad (Povelitel)',date:ago(11)}}},
{sha:'4887635dc4',commit:{message:'feat(ozon): рейтинг, диапазон дат, фильтр matched-only',author:{name:'Vlad (Povelitel)',date:ago(12)}}},
],
issues: [
{number:5,title:'Ozon: экспорт отфильтрованных отзывов в Excel',state:'open',created_at:ago(12),user:{login:'maksim'},labels:[{name:'фича',color:'22c55e'}]},
{number:3,title:'Инвалидация кэша при обновлении стоп-слов',state:'open',created_at:ago(48),user:{login:'maksim'},labels:[{name:'баг',color:'e11d48'}]},
],
},
{ name:'BREVIO', full_name:'maksim/BREVIO', language:'TypeScript', private:true,
description:'AI-платформа кратких резюме встреч — транскрипция, суммаризация, action items',
default_branch:'main',
commits: [
{sha:'a1b2c3d4e5',commit:{message:'feat(ai): интеграция Whisper v3 для русской транскрипции',author:{name:'Maksim Grachev',date:ago(24)}}},
{sha:'f6g7h8i9j0',commit:{message:'fix(auth): race condition при обновлении токена',author:{name:'Maksim Grachev',date:ago(36)}}},
{sha:'k1l2m3n4o5',commit:{message:'refactor(api): генерация саммари вынесена в фоновую очередь',author:{name:'Maksim Grachev',date:ago(48)}}},
],
issues: [
{number:22,title:'Качество саммари: контекст из предыдущих встреч',state:'open',created_at:ago(24),user:{login:'maksim'},labels:[{name:'улучшение',color:'84b6eb'}]},
],
},
{ name:'NAS-Ugreen', full_name:'maksim/NAS-Ugreen', language:null, private:true,
description:'Конфигурации NAS UGREEN — Docker, Gitea, Immich, NPM, rclone, домен',
default_branch:'main',
commits: [
{sha:'p6q7r8s9t0',commit:{message:'docs: NPM + домен + обновлена сетевая схема',author:{name:'Maksim Grachev',date:ago(2)}}},
{sha:'u1v2w3x4y5',commit:{message:'feat: docker-compose NPM + SSL конфиг',author:{name:'Maksim Grachev',date:ago(3)}}},
],
issues: [],
},
{ name:'Coffee-Like-Auto', full_name:'maksim/Coffee-Like-Auto', language:'Python', private:true,
description:'Автоматизация Coffee Like — телеграм-боты, отчёты, интеграция 1С',
default_branch:'main',
commits: [
{sha:'z1a2b3c4d5',commit:{message:'feat(bot): ежедневный отчёт продаж с разбивкой по точкам',author:{name:'Vlad (Povelitel)',date:ago(72)}}},
{sha:'e6f7g8h9i0',commit:{message:'fix(1c): таймаут синхронизации большого каталога',author:{name:'Vlad (Povelitel)',date:ago(96)}}},
],
issues: [],
},
];
function ago(h) { return new Date(Date.now() - h*3600000).toISOString(); }
let D = { repos: [] };
// ===== PARTICLES =====
(function(){
const cont = document.getElementById('authParticles');
for(let i=0;i<30;i++){
const p = document.createElement('div');
p.className = 'auth-particle';
p.style.left = Math.random()*100+'%';
p.style.animationDuration = (6+Math.random()*10)+'s';
p.style.animationDelay = Math.random()*8+'s';
p.style.width = p.style.height = (1+Math.random()*2)+'px';
if(Math.random()>0.5) p.style.background = 'rgba(59,130,246,0.3)';
cont.appendChild(p);
}
})();
// ===== AUTH =====
function doAuth() {
const v = document.getElementById('authPass').value;
const err = document.getElementById('authErr');
if (v === C.pass) {
document.getElementById('authScreen').classList.add('hidden');
sessionStorage.setItem('authed','1');
loadData();
} else {
err.textContent = 'Неверный пароль';
err.classList.remove('shake');
void err.offsetWidth;
err.classList.add('shake');
document.getElementById('authPass').value = '';
document.getElementById('authPass').focus();
}
}
if (sessionStorage.getItem('authed') === '1') {
document.getElementById('authScreen').classList.add('hidden');
loadData();
}
// ===== THEME =====
function toggleTheme() {
const dark = document.documentElement.getAttribute('data-theme') === 'dark';
document.documentElement.setAttribute('data-theme', dark ? '' : 'dark');
document.getElementById('themeBtn').innerHTML = dark ? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>' : '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>';
localStorage.setItem('theme', dark ? 'light' : 'dark');
}
if (localStorage.getItem('theme') === 'dark') {
document.documentElement.setAttribute('data-theme', 'dark');
document.getElementById('themeBtn').innerHTML = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>';
}
// ===== NAV =====
document.querySelectorAll('.nav-link[data-p]').forEach(el => {
el.addEventListener('click', () => go(el.dataset.p));
});
function go(id) {
document.querySelectorAll('.nav-link[data-p]').forEach(n => n.classList.remove('on'));
document.querySelectorAll('.pg').forEach(p => p.classList.remove('on'));
document.querySelector(`[data-p="${id}"]`)?.classList.add('on');
document.getElementById(`pg-${id}`)?.classList.add('on');
const t = {overview:'Обзор',projects:'Проекты',activity:'Активность',issues:'Задачи'};
document.getElementById('pgT').textContent = t[id]||id;
}
// ===== CLOCK =====
setInterval(() => {
document.getElementById('clk').textContent =
new Date().toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit',second:'2-digit'});
}, 1000);
// ===== UTILS =====
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
function tAgo(d){const s=(Date.now()-new Date(d).getTime())/1000;if(s<60)return Math.floor(s)+'с';if(s<3600)return Math.floor(s/60)+'м';if(s<86400)return Math.floor(s/3600)+'ч';return Math.floor(s/86400)+'д';}
function hm(d){return new Date(d).toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'});}
function isT(d){return new Date(d).toDateString()===new Date().toDateString();}
function cType(m){const x=m.match(/^(\w+)[\(:]/);return x?x[1].toLowerCase():'other';}
function dotC(t){return{feat:'var(--green)',fix:'var(--orange)',test:'var(--blue)',docs:'var(--purple)',chore:'var(--text-3)',refactor:'var(--accent)'}[t]||'var(--text-3)';}
// ===== API =====
async function loadData() {
try {
const h = {Accept:'application/json'};
if(C.token) h.Authorization = `token ${C.token}`;
const r = await fetch(`${C.api}/api/v1/repos/search?limit=50`,{headers:h});
if(!r.ok) throw new Error(r.status);
const repos = (await r.json()).data || [];
D.repos = await Promise.all(repos.map(async rp => {
const [cm,is] = await Promise.all([
fetch(`${C.api}/api/v1/repos/${rp.full_name}/commits?limit=15&sha=${rp.default_branch}`,{headers:h}).then(r=>r.json()).catch(()=>[]),
fetch(`${C.api}/api/v1/repos/${rp.full_name}/issues?state=open&limit=50&type=issues`,{headers:h}).then(r=>r.json()).catch(()=>[]),
]);
return {...rp, commits:cm, issues:is};
}));
render();
} catch(e) {
D.repos = MOCK;
render();
}
}
// ===== RENDER =====
function render() {
rStats(); rFeed(); rProjects(); rActivity(); rIssues(); rSvcs(); rStor(); rCerts(); rNavSvc();
document.getElementById('nRC').textContent = D.repos.length;
const ti = D.repos.reduce((s,r)=>s+(r.issues?.length||0),0);
document.getElementById('nIC').textContent = ti||'0';
}
function rStats() {
let tc=0,lp=null,lpN='',ar=0;
D.repos.forEach(r=>{
tc+=r.commits.filter(c=>isT(c.commit.author.date)).length;
if(r.commits[0]){
const t=new Date(r.commits[0].commit.author.date).getTime();
if(!lp||t>lp){lp=t;lpN=r.name;}
if(Date.now()-t<86400000)ar++;
}
});
document.getElementById('stG').innerHTML = `
<div class="st"><div class="st-top"><div class="st-icon" style="background:var(--accent-light);color:var(--accent)"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></div></div><div class="st-val">${D.repos.length}</div><div class="st-lbl">Репозиториев</div></div>
<div class="st"><div class="st-top"><div class="st-icon" style="background:var(--green-bg);color:var(--green)"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg></div>${tc?'<span class="st-tag" style="background:var(--green-bg);color:var(--green)">сегодня</span>':''}</div><div class="st-val">${tc}</div><div class="st-lbl">Коммитов сегодня</div></div>
<div class="st"><div class="st-top"><div class="st-icon" style="background:var(--orange-bg);color:var(--orange)"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg></div></div><div class="st-val">${lp?tAgo(new Date(lp).toISOString()):'—'}</div><div class="st-lbl">Последний push · ${lpN}</div></div>
<div class="st"><div class="st-top"><div class="st-icon" style="background:var(--blue-bg);color:var(--blue)"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20V10"/><path d="M18 20V4"/><path d="M6 20v-4"/></svg></div></div><div class="st-val">${ar}</div><div class="st-lbl">Активных (24ч)</div></div>`;
}
function cmRow(c,rn,sr){
const m=c.commit.message.split('\n')[0],t=cType(m);
return `<div class="cm"><div class="cm-dot" style="background:${dotC(t)}"></div><div class="cm-body"><div class="cm-msg">${esc(m)}</div><div class="cm-meta">${sr?`<span class="cm-repo">${rn}</span>`:''} <span class="cm-sha">${c.sha.slice(0,7)}</span> <span>${c.commit.author.name}</span></div></div><div class="cm-t">${hm(c.commit.author.date)}</div></div>`;
}
function rFeed(){
const a=[];
D.repos.forEach(r=>r.commits.slice(0,6).forEach(c=>a.push({rn:r.name,c})));
a.sort((x,y)=>new Date(y.c.commit.author.date)-new Date(x.c.commit.author.date));
document.getElementById('oFeed').innerHTML=a.slice(0,10).map(({rn,c})=>cmRow(c,rn,true)).join('');
}
function rProjects(){
const s=[...D.repos].sort((a,b)=>{
const at=a.commits[0]?new Date(a.commits[0].commit.author.date):0;
const bt=b.commits[0]?new Date(b.commits[0].commit.author.date):0;
return bt-at;
});
const L={TypeScript:{bg:'var(--blue-bg)',c:'var(--blue)'},Python:{bg:'var(--green-bg)',c:'var(--green)'},JavaScript:{bg:'var(--orange-bg)',c:'var(--orange)'}};
document.getElementById('pjG').innerHTML=s.map(r=>{
const l=L[r.language]||{bg:'var(--bg)',c:'var(--text-3)'};
const tc=r.commits.filter(c=>isT(c.commit.author.date)).length;
const la=r.commits[0]?tAgo(r.commits[0].commit.author.date)+' назад':'нет активности';
return `<div class="pj" onclick="window.open('${C.api}/${r.full_name}','_blank')">
<div class="pj-top"><div class="pj-name">${esc(r.name)}</div><div class="pj-lang" style="background:${l.bg};color:${l.c}">${r.language||'docs'}</div></div>
<div class="pj-desc">${esc(r.description||'Нет описания')}</div>
<div class="pj-info"><span><b>${tc}</b> сегодня</span><span><b>${r.issues?.length||0}</b> задач</span><span style="margin-left:auto">${la}</span></div>
<div class="pj-cms">${r.commits.slice(0,3).map(c=>`<div class="pj-cm"><span class="pj-cm-sha">${c.sha.slice(0,7)}</span><span class="pj-cm-msg">${esc(c.commit.message.split('\n')[0])}</span><span class="pj-cm-t">${hm(c.commit.author.date)}</span></div>`).join('')}</div>
</div>`;
}).join('');
}
function rActivity(){
const a=[];
D.repos.forEach(r=>r.commits.forEach(c=>a.push({rn:r.name,c})));
a.sort((x,y)=>new Date(y.c.commit.author.date)-new Date(x.c.commit.author.date));
let h='',ld='';
a.slice(0,30).forEach(({rn,c})=>{
const d=new Date(c.commit.author.date).toLocaleDateString('ru-RU',{weekday:'long',day:'numeric',month:'long'});
if(d!==ld){h+=`<div class="feed-day">${d}</div>`;ld=d;}
h+=cmRow(c,rn,true);
});
document.getElementById('aFeed').innerHTML=h||'<div class="empty">Нет активности</div>';
}
function rIssues(){
const a=[];
D.repos.forEach(r=>(r.issues||[]).forEach(i=>a.push({rn:r.name,i})));
a.sort((x,y)=>new Date(y.i.created_at)-new Date(x.i.created_at));
if(!a.length){document.getElementById('iList').innerHTML='<div class="empty">Нет открытых задач</div>';return;}
document.getElementById('iList').innerHTML=a.map(({rn,i})=>`
<div class="iss">
<div class="iss-ico" style="background:var(--green-bg);color:var(--green);border:1px solid color-mix(in srgb, var(--green) 20%, transparent)">!</div>
<div class="iss-body"><div class="iss-title">${esc(i.title)}</div><div class="iss-meta"><span style="color:var(--accent);font-weight:600">${rn}</span><span>#${i.number}</span><span>${tAgo(i.created_at)} назад</span></div></div>
${(i.labels||[]).map(l=>`<span class="iss-lbl" style="background:${l.color?'#'+l.color+'18':'var(--bg)'};color:${l.color?'#'+l.color:'var(--text-3)'}">${esc(l.name)}</span>`).join('')}
</div>`).join('');
}
function rSvcs(){
document.getElementById('sSvc').innerHTML=C.svcs.map(s=>`<div class="sv"><div class="sv-dot" style="background:${s.c}"></div><div class="sv-info"><div class="sv-name">${s.n}</div><div class="sv-url">${s.u.replace(/https?:\/\//,'')}</div></div><span class="sv-st" style="background:var(--green-bg);color:var(--green)">${s.lan?'LAN':'онлайн'}</span></div>`).join('');
}
function rNavSvc(){
document.getElementById('nSvc').innerHTML=C.svcs.map(s=>`<div class="nav-link" onclick="window.open('${s.u}','_blank')"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/></svg>${s.n}</div>`).join('');
}
function rStor(){
const p=((C.stor.used/C.stor.total)*100).toFixed(1);
document.getElementById('sStor').innerHTML=`<div class="bar-wrap"><div class="bar-head"><span>${C.stor.used} GiB занято</span><span>${(C.stor.total/1024).toFixed(1)} TiB · ${p}%</span></div><div class="bar-track"><div class="bar-fill" style="width:${p}%;background:linear-gradient(90deg,var(--accent),var(--blue))"></div></div></div>`;
}
function rCerts(){
const n=Date.now();
document.getElementById('sCert').innerHTML=C.certs.map(c=>{
const d=Math.floor((new Date(c.e)-n)/86400000);
const s=d>30?'background:var(--green-bg);color:var(--green)':d>7?'background:var(--orange-bg);color:var(--orange)':'background:var(--red-bg);color:var(--red)';
return `<div class="ct"><span class="ct-n">${c.d}</span><span class="ct-d" style="${s}">${d}д</span></div>`;
}).join('');
}
</script>
</body>
</html>