Files
dashboard/index.html
T
2026-04-21 13:21:18 +03:00

899 lines
40 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.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>