741 lines
39 KiB
HTML
741 lines
39 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>ACG Tracker</title>
|
|
<style>
|
|
:root {
|
|
--bg: #0d1117; --surface: #161b22; --raised: #1c2128; --border: #30363d;
|
|
--text: #e6edf3; --muted: #8b949e; --accent: #58a6ff;
|
|
--red: #f85149; --orange: #d29922; --green: #3fb950; --purple: #bc8cff;
|
|
}
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif; background: var(--bg); color: var(--text); line-height: 1.5; }
|
|
a { color: var(--accent); text-decoration: none; }
|
|
a:hover { text-decoration: underline; }
|
|
|
|
/* --- Login --- */
|
|
#login-screen { display: flex; align-items: center; justify-content: center; height: 100vh; }
|
|
.login-box { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 32px; width: 380px; text-align: center; }
|
|
.login-box h1 { font-size: 22px; margin-bottom: 6px; }
|
|
.login-box p { color: var(--muted); font-size: 13px; margin-bottom: 20px; }
|
|
.login-box input { width: 100%; padding: 10px 14px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 14px; margin-bottom: 12px; }
|
|
.login-box input:focus { outline: none; border-color: var(--accent); }
|
|
.login-box .login-btn { width: 100%; padding: 10px; background: var(--accent); color: #000; border: none; border-radius: 6px; font-weight: 600; font-size: 14px; cursor: pointer; }
|
|
.login-error { color: var(--red); font-size: 13px; margin-top: 8px; display: none; }
|
|
|
|
/* --- App shell --- */
|
|
#app { display: none; }
|
|
.topbar { background: var(--surface); border-bottom: 1px solid var(--border); padding: 10px 20px; display: flex; align-items: center; justify-content: space-between; }
|
|
.topbar h1 { font-size: 17px; font-weight: 600; }
|
|
.topbar-right { display: flex; align-items: center; gap: 10px; font-size: 13px; }
|
|
.topbar-right .user { color: var(--muted); }
|
|
.topbar-right .logout { color: var(--red); cursor: pointer; }
|
|
|
|
/* --- Sidebar + main layout --- */
|
|
.layout { display: flex; height: calc(100vh - 45px); }
|
|
.sidebar { width: 220px; background: var(--surface); border-right: 1px solid var(--border); padding: 12px 0; flex-shrink: 0; overflow-y: auto; }
|
|
.sidebar-section { padding: 8px 16px 4px; font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.8px; font-weight: 600; }
|
|
.sidebar-item { padding: 6px 16px; font-size: 13px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; border-left: 3px solid transparent; }
|
|
.sidebar-item:hover { background: var(--raised); }
|
|
.sidebar-item.active { border-left-color: var(--accent); background: rgba(88,166,255,0.08); color: var(--accent); }
|
|
.sidebar-item .cnt { font-size: 11px; background: var(--raised); border-radius: 8px; padding: 1px 7px; color: var(--muted); min-width: 20px; text-align: center; }
|
|
.sidebar-item.active .cnt { color: var(--accent); }
|
|
.sidebar-divider { height: 1px; background: var(--border); margin: 8px 16px; }
|
|
|
|
.main { flex: 1; overflow-y: auto; display: flex; flex-direction: column; }
|
|
|
|
/* --- Toolbar --- */
|
|
.toolbar { padding: 10px 20px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; border-bottom: 1px solid var(--border); background: var(--bg); flex-shrink: 0; }
|
|
.search-box { flex: 1; min-width: 200px; padding: 7px 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 13px; }
|
|
.search-box:focus { outline: none; border-color: var(--accent); }
|
|
.btn { background: var(--raised); border: 1px solid var(--border); color: var(--text); padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; white-space: nowrap; }
|
|
.btn:hover { border-color: var(--accent); }
|
|
.btn.active { border-color: var(--accent); background: rgba(88,166,255,0.1); color: var(--accent); }
|
|
.btn-primary { background: var(--accent); color: #000; border-color: var(--accent); font-weight: 600; }
|
|
.btn-success { background: var(--green); color: #000; border-color: var(--green); font-weight: 600; }
|
|
.btn-danger { color: var(--red); border-color: var(--red); background: transparent; }
|
|
select.btn { appearance: auto; }
|
|
|
|
/* --- Stats --- */
|
|
.stats { padding: 6px 20px; display: flex; gap: 16px; font-size: 12px; color: var(--muted); border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
|
.stats strong { color: var(--text); }
|
|
|
|
/* --- Board --- */
|
|
.board { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; padding: 12px 20px; flex: 1; overflow-y: auto; align-content: start; }
|
|
.col { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; display: flex; flex-direction: column; max-height: calc(100vh - 180px); }
|
|
.col-head { padding: 8px 12px; border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; font-weight: 600; font-size: 12px; flex-shrink: 0; text-transform: uppercase; letter-spacing: 0.5px; color: var(--muted); }
|
|
.col-head .cnt { font-size: 11px; background: var(--raised); border-radius: 8px; padding: 1px 7px; }
|
|
.col-body { padding: 6px; overflow-y: auto; flex: 1; }
|
|
|
|
.card { background: var(--raised); border: 1px solid var(--border); border-radius: 6px; padding: 10px; margin-bottom: 6px; cursor: pointer; transition: border-color 0.12s; }
|
|
.card:hover { border-color: var(--accent); }
|
|
.card-header { display: flex; justify-content: space-between; align-items: start; margin-bottom: 4px; }
|
|
.card-repo { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.4px; }
|
|
.card-id { font-size: 10px; color: var(--muted); }
|
|
.card-title { font-size: 13px; font-weight: 500; margin-bottom: 6px; line-height: 1.3; }
|
|
.card-labels { display: flex; flex-wrap: wrap; gap: 3px; margin-bottom: 4px; }
|
|
.tag { font-size: 10px; padding: 1px 7px; border-radius: 10px; font-weight: 500; line-height: 1.5; }
|
|
.card-foot { display: flex; justify-content: space-between; font-size: 10px; color: var(--muted); }
|
|
.pdot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 3px; vertical-align: middle; }
|
|
.pdot-P1 { background: var(--red); } .pdot-P2 { background: var(--orange); } .pdot-P3 { background: #fbca04; } .pdot-P4 { background: var(--green); }
|
|
|
|
/* --- List --- */
|
|
.list-wrap { flex: 1; overflow-y: auto; padding: 0 20px 20px; }
|
|
.ltable { width: 100%; border-collapse: collapse; }
|
|
.ltable th { text-align: left; padding: 8px 8px; border-bottom: 2px solid var(--border); font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--muted); cursor: pointer; user-select: none; white-space: nowrap; }
|
|
.ltable th:hover { color: var(--text); }
|
|
.ltable td { padding: 7px 8px; border-bottom: 1px solid var(--border); font-size: 13px; vertical-align: middle; }
|
|
.ltable tr { cursor: pointer; }
|
|
.ltable tr:hover { background: var(--surface); }
|
|
.sbadge { font-size: 11px; padding: 2px 8px; border-radius: 10px; font-weight: 500; white-space: nowrap; }
|
|
.s-open { background: rgba(63,185,80,0.15); color: var(--green); }
|
|
.s-in-progress { background: rgba(88,166,255,0.15); color: var(--accent); }
|
|
.s-blocked { background: rgba(248,81,73,0.15); color: var(--red); }
|
|
.s-done { background: rgba(139,148,158,0.15); color: var(--muted); }
|
|
|
|
/* --- Detail modal --- */
|
|
.overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.55); z-index: 100; align-items: flex-start; justify-content: center; padding-top: 40px; }
|
|
.overlay.open { display: flex; }
|
|
.modal { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; width: 720px; max-width: 94vw; max-height: 85vh; overflow-y: auto; padding: 24px; position: relative; }
|
|
.modal h2 { font-size: 18px; margin-bottom: 4px; padding-right: 30px; }
|
|
.modal .m-repo { font-size: 12px; color: var(--muted); margin-bottom: 12px; }
|
|
.modal .m-body { font-size: 14px; line-height: 1.7; color: var(--muted); white-space: pre-wrap; word-break: break-word; }
|
|
.modal .m-labels { margin: 8px 0 12px; display: flex; flex-wrap: wrap; gap: 4px; }
|
|
.modal .m-meta { font-size: 12px; color: var(--muted); margin-top: 12px; }
|
|
.modal .m-actions { margin-top: 12px; display: flex; gap: 6px; flex-wrap: wrap; }
|
|
.modal .m-timeline { margin-top: 16px; border-top: 1px solid var(--border); padding-top: 14px; }
|
|
.modal .m-timeline h3 { font-size: 13px; margin-bottom: 8px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
|
|
.comment { border-left: 2px solid var(--border); padding: 6px 12px; margin-bottom: 10px; font-size: 13px; }
|
|
.comment .c-author { font-weight: 600; color: var(--text); }
|
|
.comment .c-date { color: var(--muted); font-size: 11px; margin-left: 6px; }
|
|
.comment p { margin-top: 4px; white-space: pre-wrap; word-break: break-word; }
|
|
.xbtn { position: absolute; top: 12px; right: 14px; background: none; border: none; color: var(--muted); font-size: 22px; cursor: pointer; line-height: 1; }
|
|
.xbtn:hover { color: var(--text); }
|
|
.add-comment { margin-top: 14px; border-top: 1px solid var(--border); padding-top: 10px; }
|
|
.add-comment textarea { width: 100%; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 13px; font-family: inherit; min-height: 60px; resize: vertical; margin-bottom: 8px; }
|
|
.add-comment textarea:focus { outline: none; border-color: var(--accent); }
|
|
|
|
/* --- New issue form --- */
|
|
.fg { margin-bottom: 12px; }
|
|
.fg label { display: block; font-size: 12px; color: var(--muted); margin-bottom: 3px; font-weight: 600; }
|
|
.fg input, .fg select, .fg textarea { width: 100%; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font-size: 14px; font-family: inherit; }
|
|
.fg textarea { min-height: 120px; resize: vertical; }
|
|
.fg input:focus, .fg select:focus, .fg textarea:focus { outline: none; border-color: var(--accent); }
|
|
.frow { display: flex; gap: 10px; }
|
|
.frow .fg { flex: 1; }
|
|
.factions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; }
|
|
|
|
/* --- Activity feed --- */
|
|
.activity { padding: 16px 20px; }
|
|
.act-item { display: flex; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; cursor: pointer; }
|
|
.act-item:hover { background: var(--raised); margin: 0 -8px; padding: 8px; border-radius: 4px; }
|
|
.act-dot { width: 8px; height: 8px; border-radius: 50%; margin-top: 6px; flex-shrink: 0; }
|
|
.act-dot.opened { background: var(--green); }
|
|
.act-dot.closed { background: var(--purple); }
|
|
.act-dot.commented { background: var(--accent); }
|
|
.act-dot.labeled { background: var(--orange); }
|
|
.act-body { flex: 1; }
|
|
.act-body .act-title { font-weight: 500; }
|
|
.act-body .act-sub { font-size: 11px; color: var(--muted); margin-top: 2px; }
|
|
|
|
.empty-state { text-align: center; padding: 60px 20px; color: var(--muted); }
|
|
.empty-state h3 { font-size: 16px; margin-bottom: 6px; color: var(--text); }
|
|
|
|
@media (max-width: 1100px) { .board { grid-template-columns: 1fr 1fr; } .sidebar { width: 180px; } }
|
|
@media (max-width: 700px) { .board { grid-template-columns: 1fr; } .sidebar { display: none; } }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div id="login-screen">
|
|
<div class="login-box">
|
|
<h1>ACG Tracker</h1>
|
|
<p>Bug tracking, project status, and skill work across all Guru repos</p>
|
|
<input type="text" id="login-user" placeholder="Gitea username" autocomplete="username">
|
|
<input type="password" id="login-pass" placeholder="Password" autocomplete="current-password">
|
|
<button class="login-btn" onclick="doLogin()">Sign In</button>
|
|
<div class="login-error" id="login-error"></div>
|
|
<p style="margin-top:14px;font-size:11px;color:var(--muted)">
|
|
Mike: azcomputerguru · Howard: howard
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="app">
|
|
<div class="topbar">
|
|
<h1>ACG Tracker</h1>
|
|
<div class="topbar-right">
|
|
<span class="user" id="user-display"></span>
|
|
<span class="logout" onclick="doLogout()">sign out</span>
|
|
</div>
|
|
</div>
|
|
<div class="layout">
|
|
<div class="sidebar" id="sidebar"></div>
|
|
<div class="main" id="main-content"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Detail modal -->
|
|
<div class="overlay" id="detail-overlay" onclick="if(event.target===this)closeDetail()">
|
|
<div class="modal" id="detail-modal"></div>
|
|
</div>
|
|
|
|
<!-- New issue modal -->
|
|
<div class="overlay" id="new-overlay" onclick="if(event.target===this)closeNew()">
|
|
<div class="modal" id="new-modal"></div>
|
|
</div>
|
|
|
|
<script>
|
|
const GITEA_URL = '/gitea';
|
|
const API = GITEA_URL + '/api/v1';
|
|
const OWNER = 'azcomputerguru';
|
|
|
|
// --- Config: repos and sections ---
|
|
const SECTIONS = [
|
|
{ id: 'rmm-bugs', label: 'RMM Bugs', repos: ['gururmm','guru-rmm'], filter: i => getType(i)==='bug' },
|
|
{ id: 'rmm-features', label: 'RMM Features', repos: ['gururmm','guru-rmm'], filter: i => getType(i)==='feature' },
|
|
{ id: 'rmm-security', label: 'RMM Security', repos: ['gururmm','guru-rmm'], filter: i => getType(i)==='security' },
|
|
{ id: 'gc-all', label: 'GuruConnect', repos: ['guru-connect'], filter: () => true },
|
|
{ id: 'ct-bugs', label: 'Harness / Skill Bugs', repos: ['claudetools'], filter: i => getType(i)==='bug' || getType(i)==='security' },
|
|
{ id: 'ct-skills', label: 'Skill Work', repos: ['claudetools'], filter: i => getType(i)==='skill' },
|
|
{ id: 'ct-projects', label: 'Projects', repos: ['claudetools'], filter: i => (getType(i)==='project' || getType(i)==='feature') && !isClientProject(i) },
|
|
];
|
|
// Client project sections — built dynamically from labels
|
|
// Client sections are built dynamically from labels — only clients with issues appear in sidebar
|
|
const CLIENT_SECTIONS = [];
|
|
const ALL_REPOS = ['gururmm','guru-rmm','guru-connect','claudetools'];
|
|
const REPO_NAMES = { 'gururmm':'GuruRMM', 'guru-rmm':'GuruRMM', 'guru-connect':'GuruConnect', 'claudetools':'ClaudeTools' };
|
|
|
|
let allIssues = [], labelCache = {}, currentSection = 'all', currentView = 'board';
|
|
let searchText = '', showClosed = false, sortField = 'updated', sortDir = -1;
|
|
|
|
// --- Auth ---
|
|
function getAuth() { return localStorage.getItem('acg_tk') || ''; }
|
|
function getUser() { return localStorage.getItem('acg_user') || ''; }
|
|
function headers() { const h = {'Accept':'application/json'}; const t=getAuth(); if(t) h['Authorization']='Basic '+t; return h; }
|
|
|
|
async function doLogin() {
|
|
const user = document.getElementById('login-user').value.trim();
|
|
const pass = document.getElementById('login-pass').value;
|
|
const err = document.getElementById('login-error');
|
|
if (!user||!pass) { err.textContent='Enter username and password'; err.style.display='block'; return; }
|
|
const basic = btoa(user+':'+pass);
|
|
try {
|
|
const r = await fetch(API+'/user', { headers: {'Authorization':'Basic '+basic, 'Accept':'application/json'} });
|
|
if (!r.ok) { err.textContent='Invalid credentials'; err.style.display='block'; return; }
|
|
const d = await r.json();
|
|
localStorage.setItem('acg_tk', basic);
|
|
localStorage.setItem('acg_user', d.full_name || d.login || user);
|
|
boot();
|
|
} catch(e) { err.textContent='Connection failed'; err.style.display='block'; }
|
|
}
|
|
function doLogout() { localStorage.removeItem('acg_tk'); localStorage.removeItem('acg_user'); location.reload(); }
|
|
function boot() {
|
|
document.getElementById('login-screen').style.display='none';
|
|
document.getElementById('app').style.display='block';
|
|
document.getElementById('user-display').textContent=getUser();
|
|
loadLabels(); loadIssues();
|
|
}
|
|
|
|
async function loadLabels() {
|
|
const fetches = ALL_REPOS.map(async repo => {
|
|
try {
|
|
const r = await fetch(`${API}/repos/${OWNER}/${repo}/labels?limit=50`, {headers:headers()});
|
|
if(!r.ok) return;
|
|
const labels = await r.json();
|
|
labelCache[repo] = {}; for(const l of labels) labelCache[repo][l.name]=l.id;
|
|
} catch(e){}
|
|
});
|
|
await Promise.all(fetches);
|
|
}
|
|
|
|
async function loadIssues() {
|
|
allIssues = [];
|
|
const states = showClosed ? ['open','closed'] : ['open'];
|
|
const fetches = ALL_REPOS.map(async repo => {
|
|
for (const state of states) {
|
|
let page=1;
|
|
while(true) {
|
|
try {
|
|
const r = await fetch(`${API}/repos/${OWNER}/${repo}/issues?state=${state}&type=issues&limit=50&page=${page}`, {headers:headers()});
|
|
if(!r.ok) break;
|
|
const issues = await r.json();
|
|
if(!issues.length) break;
|
|
for(const i of issues) { i._repo=repo; i._rname=REPO_NAMES[repo]||repo; }
|
|
allIssues.push(...issues);
|
|
page++;
|
|
if(issues.length<50) break;
|
|
} catch(e) { break; }
|
|
}
|
|
}
|
|
});
|
|
await Promise.all(fetches);
|
|
renderSidebar(); renderMain();
|
|
}
|
|
|
|
// --- Helpers ---
|
|
function getStatus(i) {
|
|
const l = i.labels.map(x=>x.name);
|
|
if(l.includes('fixed')||l.includes('verified')||i.state==='closed') return 'done';
|
|
if(l.includes('blocked')||l.includes('dropped')) return 'blocked';
|
|
if(l.includes('in-progress')) return 'in-progress';
|
|
return 'open';
|
|
}
|
|
function getPriority(i) {
|
|
for(const p of ['P1-critical','P2-high','P3-normal','P4-low']) if(i.labels.some(l=>l.name===p)) return p.split('-')[0];
|
|
return null;
|
|
}
|
|
function getType(i) {
|
|
for(const t of ['bug','feature','skill','project','security']) if(i.labels.some(l=>l.name===t)) return t;
|
|
return null;
|
|
}
|
|
function isClientProject(i) { return i.labels.some(l => l.name === 'client-project'); }
|
|
function getAssignee(i) {
|
|
const a = i.labels.find(l => l.name.startsWith('assigned:'));
|
|
return a ? a.name.split(':')[1] : null;
|
|
}
|
|
function getClient(i) {
|
|
const c = i.labels.find(l => l.name.startsWith('client:'));
|
|
return c ? c.name : null;
|
|
}
|
|
function getDiscovered(i) {
|
|
// Parse "**Discovered:** YYYY-MM-DD" from issue body
|
|
if (!i.body) return null;
|
|
const m = i.body.match(/\*\*Discovered:\*\*\s*(\d{4}-\d{2}-\d{2})/);
|
|
return m ? m[1] + 'T00:00:00Z' : null;
|
|
}
|
|
function esc(s) { const d=document.createElement('div'); d.textContent=s; return d.innerHTML; }
|
|
function timeAgo(s) {
|
|
const d=(Date.now()-new Date(s))/1000;
|
|
if(d<60) return 'now'; if(d<3600) return Math.floor(d/60)+'m';
|
|
if(d<86400) return Math.floor(d/3600)+'h'; if(d<604800) return Math.floor(d/86400)+'d';
|
|
if(d<2592000) return Math.floor(d/604800)+'w';
|
|
return new Date(s).toLocaleDateString();
|
|
}
|
|
function tagStyle(l) { return `background:#${l.color}20;color:#${l.color};`; }
|
|
|
|
function getFiltered() {
|
|
let items = allIssues;
|
|
if (currentSection.startsWith('cp-')) {
|
|
// Client project section — dynamic
|
|
items = items.filter(i => isClientProject(i));
|
|
if (currentSection !== 'cp-all') {
|
|
const clientTag = 'client:' + currentSection.slice(3);
|
|
items = items.filter(i => i.labels.some(l => l.name === clientTag));
|
|
}
|
|
} else if (currentSection === 'all') {
|
|
// Exclude client projects from "all issues"
|
|
items = items.filter(i => !isClientProject(i));
|
|
} else if (currentSection !== 'activity') {
|
|
const sec = SECTIONS.find(s=>s.id===currentSection);
|
|
if (sec) items = items.filter(i => sec.repos.includes(i._repo) && sec.filter(i) && !isClientProject(i));
|
|
}
|
|
if (searchText) {
|
|
const q = searchText.toLowerCase();
|
|
items = items.filter(i => i.title.toLowerCase().includes(q) || (i.body||'').toLowerCase().includes(q) || ('#'+i.number).includes(q));
|
|
}
|
|
if (!showClosed) items = items.filter(i => i.state !== 'closed');
|
|
return items;
|
|
}
|
|
|
|
// --- Sidebar ---
|
|
function renderSidebar() {
|
|
const sb = document.getElementById('sidebar');
|
|
const countFor = (sec) => {
|
|
let items = allIssues.filter(i => i.state !== 'closed');
|
|
if (sec) items = items.filter(i => sec.repos.includes(i._repo) && sec.filter(i));
|
|
return items.length;
|
|
};
|
|
const totalOpen = allIssues.filter(i=>i.state!=='closed').length;
|
|
|
|
let html = `
|
|
<div class="sidebar-item ${currentSection==='all'?'active':''}" onclick="setSection('all')">
|
|
All Issues <span class="cnt">${totalOpen}</span>
|
|
</div>
|
|
<div class="sidebar-item ${currentSection==='activity'?'active':''}" onclick="setSection('activity')">
|
|
Recent Activity
|
|
</div>
|
|
<div class="sidebar-divider"></div>
|
|
<div class="sidebar-section">GuruRMM</div>
|
|
`;
|
|
for (const sec of SECTIONS.filter(s=>s.id.startsWith('rmm'))) {
|
|
html += `<div class="sidebar-item ${currentSection===sec.id?'active':''}" onclick="setSection('${sec.id}')">
|
|
${sec.label} <span class="cnt">${countFor(sec)}</span>
|
|
</div>`;
|
|
}
|
|
html += `<div class="sidebar-divider"></div><div class="sidebar-section">GuruConnect</div>`;
|
|
for (const sec of SECTIONS.filter(s=>s.id.startsWith('gc'))) {
|
|
html += `<div class="sidebar-item ${currentSection===sec.id?'active':''}" onclick="setSection('${sec.id}')">
|
|
${sec.label} <span class="cnt">${countFor(sec)}</span>
|
|
</div>`;
|
|
}
|
|
html += `<div class="sidebar-divider"></div><div class="sidebar-section">ClaudeTools / Skills</div>`;
|
|
for (const sec of SECTIONS.filter(s=>s.id.startsWith('ct'))) {
|
|
html += `<div class="sidebar-item ${currentSection===sec.id?'active':''}" onclick="setSection('${sec.id}')">
|
|
${sec.label} <span class="cnt">${countFor(sec)}</span>
|
|
</div>`;
|
|
}
|
|
|
|
// Client projects section — built dynamically from issue labels
|
|
const cpItems = allIssues.filter(i => i.state !== 'closed' && isClientProject(i));
|
|
const clientCounts = {};
|
|
for (const i of cpItems) {
|
|
const cl = getClient(i);
|
|
if (cl) clientCounts[cl] = (clientCounts[cl] || 0) + 1;
|
|
}
|
|
const totalCp = cpItems.length;
|
|
html += `<div class="sidebar-divider"></div><div class="sidebar-section">Client Projects</div>`;
|
|
html += `<div class="sidebar-item ${currentSection==='cp-all'?'active':''}" onclick="setSection('cp-all')">
|
|
All Clients <span class="cnt">${totalCp}</span>
|
|
</div>`;
|
|
for (const [cl, cnt] of Object.entries(clientCounts).sort((a,b)=>b[1]-a[1])) {
|
|
const sid = 'cp-' + cl.replace('client:','');
|
|
const displayName = cl.split(':')[1].replace(/-/g,' ').replace(/\b\w/g,c=>c.toUpperCase());
|
|
html += `<div class="sidebar-item ${currentSection===sid?'active':''}" onclick="setSection('${sid}')">
|
|
${displayName} <span class="cnt">${cnt}</span>
|
|
</div>`;
|
|
}
|
|
|
|
sb.innerHTML = html;
|
|
}
|
|
|
|
function setSection(id) { currentSection=id; renderSidebar(); renderMain(); }
|
|
|
|
// --- Main content ---
|
|
function renderMain() {
|
|
const main = document.getElementById('main-content');
|
|
if (currentSection === 'activity') { renderActivity(main); return; }
|
|
const items = getFiltered();
|
|
let html = renderToolbar();
|
|
html += renderStats(items);
|
|
if (currentView === 'board') html += renderBoard(items);
|
|
else html += renderList(items);
|
|
main.innerHTML = html;
|
|
// Re-attach search handler
|
|
const sb = document.querySelector('.search-box');
|
|
if(sb) sb.addEventListener('input', e => { searchText=e.target.value; renderMain(); });
|
|
}
|
|
|
|
function renderToolbar() {
|
|
return `<div class="toolbar">
|
|
<input class="search-box" type="text" placeholder="Search issues..." value="${esc(searchText)}">
|
|
<div style="display:flex;gap:2px">
|
|
<button class="btn ${currentView==='board'?'active':''}" onclick="currentView='board';renderMain()">Board</button>
|
|
<button class="btn ${currentView==='list'?'active':''}" onclick="currentView='list';renderMain()">List</button>
|
|
</div>
|
|
<button class="btn ${showClosed?'active':''}" onclick="showClosed=!showClosed;loadIssues()">
|
|
${showClosed ? 'Showing closed' : 'Show closed'}
|
|
</button>
|
|
<button class="btn btn-primary" onclick="showNewIssue()">+ New Issue</button>
|
|
<button class="btn" onclick="loadIssues()">Refresh</button>
|
|
</div>`;
|
|
}
|
|
|
|
function renderStats(items) {
|
|
const c=fn=>items.filter(fn).length;
|
|
return `<div class="stats">
|
|
<span><strong>${items.length}</strong> total</span>
|
|
<span><strong>${c(i=>getStatus(i)==='open')}</strong> open</span>
|
|
<span><strong>${c(i=>getStatus(i)==='in-progress')}</strong> in progress</span>
|
|
<span><strong>${c(i=>getStatus(i)==='blocked')}</strong> blocked</span>
|
|
<span><strong>${c(i=>getStatus(i)==='done')}</strong> done</span>
|
|
<span>·</span>
|
|
<span><strong>${c(i=>getType(i)==='bug')}</strong> bugs</span>
|
|
<span><strong>${c(i=>getType(i)==='feature')}</strong> features</span>
|
|
<span><strong>${c(i=>getType(i)==='security')}</strong> security</span>
|
|
</div>`;
|
|
}
|
|
|
|
function makeCard(i) {
|
|
const p=getPriority(i); const st=getStatus(i);
|
|
const tags=i.labels.filter(l=>!l.name.match(/^P\d/)&&!['in-progress','blocked','dropped','fixed','verified','wontfix','client-project'].includes(l.name)&&!l.name.startsWith('assigned:')&&!l.name.startsWith('client:'));
|
|
const assignee=getAssignee(i);
|
|
const isCp=isClientProject(i);
|
|
const clientLabel=getClient(i);
|
|
return `<div class="card" onclick="showDetail('${i._repo}',${i.number})">
|
|
<div class="card-header"><span class="card-repo">${isCp && clientLabel ? clientLabel.split(':')[1] : i._rname}</span><span class="card-id">#${i.number}</span></div>
|
|
<div class="card-title">${esc(i.title)}</div>
|
|
<div class="card-labels">${tags.map(l=>`<span class="tag" style="${tagStyle(l)}">${esc(l.name)}</span>`).join('')}${assignee?`<span class="tag" style="background:rgba(191,218,220,0.2);color:#bfdadc">${assignee}</span>`:''}</div>
|
|
<div class="card-foot">
|
|
<span>${p?`<span class="pdot pdot-${p}"></span>${p}`:''}</span>
|
|
<span>${timeAgo(getDiscovered(i)||i.created_at)} old · upd ${timeAgo(i.updated_at)}</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function renderBoard(items) {
|
|
const b={open:[], 'in-progress':[], blocked:[], done:[]};
|
|
items.forEach(i=>b[getStatus(i)].push(i));
|
|
const po={P1:0,P2:1,P3:2,P4:3};
|
|
for(const k in b) b[k].sort((a,c)=>(po[getPriority(a)]??9)-(po[getPriority(c)]??9) || new Date(c.updated_at)-new Date(a.updated_at));
|
|
return `<div class="board">
|
|
${['open','in-progress','blocked','done'].map(k => `
|
|
<div class="col"><div class="col-head">${k.replace('-',' ')} <span class="cnt">${b[k].length}</span></div>
|
|
<div class="col-body">${b[k].map(makeCard).join('')}${b[k].length===0?'<div style="text-align:center;padding:20px;color:var(--muted);font-size:12px">None</div>':''}</div></div>
|
|
`).join('')}
|
|
</div>`;
|
|
}
|
|
|
|
function renderList(items) {
|
|
const po={P1:0,P2:1,P3:2,P4:3};
|
|
const sorted=[...items].sort((a,c)=>{
|
|
if(sortField==='updated') return sortDir*(new Date(c.updated_at)-new Date(a.updated_at));
|
|
if(sortField==='priority') return sortDir*((po[getPriority(a)]??9)-(po[getPriority(c)]??9));
|
|
if(sortField==='repo') return sortDir*a._rname.localeCompare(c._rname);
|
|
if(sortField==='number') return sortDir*(a.number-c.number);
|
|
if(sortField==='title') return sortDir*a.title.localeCompare(c.title);
|
|
if(sortField==='status') return sortDir*getStatus(a).localeCompare(getStatus(c));
|
|
if(sortField==='discovered') return sortDir*(new Date(getDiscovered(c)||c.created_at)-new Date(getDiscovered(a)||a.created_at));
|
|
return 0;
|
|
});
|
|
return `<div class="list-wrap"><table class="ltable">
|
|
<thead><tr>
|
|
<th onclick="doSort('repo')">Repo</th>
|
|
<th onclick="doSort('number')">#</th>
|
|
<th onclick="doSort('title')">Title</th>
|
|
<th>Labels</th>
|
|
<th onclick="doSort('status')">Status</th>
|
|
<th onclick="doSort('priority')">Priority</th>
|
|
<th onclick="doSort('discovered')">Discovered</th>
|
|
<th onclick="doSort('updated')">Updated</th>
|
|
</tr></thead>
|
|
<tbody>${sorted.map(i=>{
|
|
const st=getStatus(i); const p=getPriority(i);
|
|
const tags=i.labels.filter(l=>!l.name.match(/^P\d/));
|
|
return `<tr onclick="showDetail('${i._repo}',${i.number})">
|
|
<td>${esc(i._rname)}</td><td>${i.number}</td>
|
|
<td>${esc(i.title)}</td>
|
|
<td>${tags.map(l=>`<span class="tag" style="${tagStyle(l)}">${esc(l.name)}</span>`).join(' ')}</td>
|
|
<td><span class="sbadge s-${st}">${st}</span></td>
|
|
<td>${p?`<span class="pdot pdot-${p}"></span> ${p}`:'-'}</td>
|
|
<td>${timeAgo(getDiscovered(i)||i.created_at)}</td>
|
|
<td>${timeAgo(i.updated_at)}</td>
|
|
</tr>`;
|
|
}).join('')}</tbody>
|
|
</table></div>`;
|
|
}
|
|
|
|
function doSort(f) { if(sortField===f) sortDir*=-1; else {sortField=f;sortDir=1;} renderMain(); }
|
|
|
|
// --- Activity feed ---
|
|
function renderActivity(main) {
|
|
const recent = [...allIssues].sort((a,b)=>new Date(b.updated_at)-new Date(a.updated_at)).slice(0,40);
|
|
let html = `<div class="toolbar"><span style="font-size:14px;font-weight:600">Recent Activity</span>
|
|
<button class="btn" onclick="loadIssues()" style="margin-left:auto">Refresh</button></div>`;
|
|
html += '<div class="activity">';
|
|
if (!recent.length) {
|
|
html += '<div class="empty-state"><h3>No recent activity</h3><p>Issues will appear here as they are created and updated.</p></div>';
|
|
}
|
|
for (const i of recent) {
|
|
const st = i.state==='closed' ? 'closed' : 'opened';
|
|
html += `<div class="act-item" onclick="showDetail('${i._repo}',${i.number})">
|
|
<div class="act-dot ${st}"></div>
|
|
<div class="act-body">
|
|
<div class="act-title">${esc(i.title)}</div>
|
|
<div class="act-sub">${i._rname} #${i.number} · ${i.state} · updated ${timeAgo(i.updated_at)} · created ${timeAgo(i.created_at)}</div>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
html += '</div>';
|
|
main.innerHTML = html;
|
|
}
|
|
|
|
// --- Detail modal ---
|
|
async function showDetail(repo, num) {
|
|
const m = document.getElementById('detail-modal');
|
|
m.innerHTML = '<p style="color:var(--muted);padding:20px">Loading...</p>';
|
|
document.getElementById('detail-overlay').classList.add('open');
|
|
try {
|
|
const [ir, cr] = await Promise.all([
|
|
fetch(`${API}/repos/${OWNER}/${repo}/issues/${num}`, {headers:headers()}),
|
|
fetch(`${API}/repos/${OWNER}/${repo}/issues/${num}/comments`, {headers:headers()})
|
|
]);
|
|
const issue = await ir.json();
|
|
const comments = await cr.json();
|
|
const labels = issue.labels||[];
|
|
const body = issue.body ? esc(issue.body) : '<em>No description</em>';
|
|
const st = getStatus(issue);
|
|
const giteaUrl = `${GITEA_URL}/${OWNER}/${repo}/issues/${num}`;
|
|
|
|
m.innerHTML = `
|
|
<button class="xbtn" onclick="closeDetail()">×</button>
|
|
<div class="m-repo">${REPO_NAMES[repo]||repo} #${num} <span class="sbadge s-${st}" style="margin-left:6px">${st}</span></div>
|
|
<h2>${esc(issue.title)}</h2>
|
|
<div class="m-labels">${labels.map(l=>`<span class="tag" style="${tagStyle(l)}">${esc(l.name)}</span>`).join('')}</div>
|
|
<div class="m-body">${body}</div>
|
|
<div class="m-meta">
|
|
Created ${new Date(issue.created_at).toLocaleString()} ·
|
|
Updated ${new Date(issue.updated_at).toLocaleString()} ·
|
|
<a href="${giteaUrl}" target="_blank">Open in Gitea</a>
|
|
</div>
|
|
<div class="m-actions">
|
|
${issue.state==='open' ? `
|
|
<button class="btn" onclick="toggleLabel('${repo}',${num},'in-progress')">In-Progress</button>
|
|
<button class="btn" onclick="toggleLabel('${repo}',${num},'blocked')">Blocked</button>
|
|
<button class="btn" onclick="toggleLabel('${repo}',${num},'fixed')">Fixed</button>
|
|
<button class="btn btn-danger" onclick="changeState('${repo}',${num},'closed')">Close</button>
|
|
` : `
|
|
<button class="btn" onclick="changeState('${repo}',${num},'open')">Reopen</button>
|
|
`}
|
|
</div>
|
|
${comments.length ? `
|
|
<div class="m-timeline"><h3>History (${comments.length})</h3>
|
|
${comments.map(c=>`<div class="comment">
|
|
<span class="c-author">${esc(c.user?.login||'')}</span>
|
|
<span class="c-date">${new Date(c.created_at).toLocaleString()}</span>
|
|
<p>${esc(c.body||'')}</p>
|
|
</div>`).join('')}
|
|
</div>
|
|
` : ''}
|
|
<div class="add-comment">
|
|
<textarea id="cmt-text" placeholder="Add a comment..."></textarea>
|
|
<button class="btn btn-primary" onclick="addComment('${repo}',${num})">Comment</button>
|
|
</div>
|
|
`;
|
|
} catch(e) { m.innerHTML=`<p style="color:var(--red)">Failed: ${e.message}</p>`; }
|
|
}
|
|
function closeDetail() { document.getElementById('detail-overlay').classList.remove('open'); }
|
|
|
|
async function addComment(repo,num) {
|
|
const t=document.getElementById('cmt-text').value.trim(); if(!t) return;
|
|
await fetch(`${API}/repos/${OWNER}/${repo}/issues/${num}/comments`, {method:'POST', headers:{...headers(),'Content-Type':'application/json'}, body:JSON.stringify({body:t})});
|
|
showDetail(repo,num);
|
|
}
|
|
async function changeState(repo,num,state) {
|
|
await fetch(`${API}/repos/${OWNER}/${repo}/issues/${num}`, {method:'PATCH', headers:{...headers(),'Content-Type':'application/json'}, body:JSON.stringify({state})});
|
|
closeDetail(); loadIssues();
|
|
}
|
|
async function toggleLabel(repo,num,name) {
|
|
const lc=labelCache[repo]||{}; const id=lc[name]; if(!id) return;
|
|
await fetch(`${API}/repos/${OWNER}/${repo}/issues/${num}/labels`, {method:'POST', headers:{...headers(),'Content-Type':'application/json'}, body:JSON.stringify({labels:[id]})});
|
|
showDetail(repo,num); loadIssues();
|
|
}
|
|
|
|
// --- New issue ---
|
|
function showNewIssue() {
|
|
const m = document.getElementById('new-modal');
|
|
// Pre-select repo based on current section
|
|
let defaultRepo = 'gururmm';
|
|
if (currentSection.startsWith('gc')) defaultRepo = 'guru-connect';
|
|
if (currentSection.startsWith('ct')) defaultRepo = 'claudetools';
|
|
|
|
m.innerHTML = `
|
|
<button class="xbtn" onclick="closeNew()">×</button>
|
|
<h2>New Issue</h2>
|
|
<div style="margin-top:14px">
|
|
<div class="frow">
|
|
<div class="fg"><label>Repository</label><select id="ni-repo">
|
|
<option value="gururmm" ${defaultRepo==='gururmm'?'selected':''}>GuruRMM</option>
|
|
<option value="guru-connect" ${defaultRepo==='guru-connect'?'selected':''}>GuruConnect</option>
|
|
<option value="claudetools" ${defaultRepo==='claudetools'?'selected':''}>ClaudeTools</option>
|
|
</select></div>
|
|
<div class="fg"><label>Type</label><select id="ni-type">
|
|
<option value="bug">Bug</option>
|
|
<option value="feature">Feature</option>
|
|
<option value="skill">Skill</option>
|
|
<option value="project">Project</option>
|
|
<option value="security">Security</option>
|
|
<option value="client-project">Client Project</option>
|
|
</select></div>
|
|
</div>
|
|
<div class="frow">
|
|
<div class="fg"><label>Priority</label><select id="ni-prio">
|
|
<option value="P3-normal">P3 - Normal</option>
|
|
<option value="P1-critical">P1 - Critical</option>
|
|
<option value="P2-high">P2 - High</option>
|
|
<option value="P4-low">P4 - Low</option>
|
|
</select></div>
|
|
<div class="fg"><label>Component</label><select id="ni-comp">
|
|
<option value="">(none)</option>
|
|
<option value="component:rmm-agent">RMM Agent</option>
|
|
<option value="component:rmm-server">RMM Server</option>
|
|
<option value="component:rmm-dashboard">RMM Dashboard</option>
|
|
<option value="component:guruconnect">GuruConnect</option>
|
|
<option value="component:harness">Harness / Skills</option>
|
|
</select></div>
|
|
</div>
|
|
<div class="frow" id="ni-client-row" style="display:none">
|
|
<div class="fg"><label>Client</label><select id="ni-client">
|
|
<option value="">(select client)</option>
|
|
</select></div>
|
|
<div class="fg"><label>Assigned To</label><select id="ni-assign">
|
|
<option value="">(unassigned)</option>
|
|
<option value="assigned:howard">Howard</option>
|
|
<option value="assigned:mike">Mike</option>
|
|
</select></div>
|
|
</div>
|
|
<div class="fg"><label>Title</label><input type="text" id="ni-title" placeholder="Short description"></div>
|
|
<div class="fg"><label>Description</label><textarea id="ni-body" placeholder="What happened? Steps to reproduce? Context?"></textarea></div>
|
|
<div class="factions">
|
|
<button class="btn" onclick="closeNew()">Cancel</button>
|
|
<button class="btn btn-success" onclick="submitNew()">Create Issue</button>
|
|
</div>
|
|
<div id="ni-err" style="color:var(--red);font-size:13px;margin-top:8px;display:none"></div>
|
|
</div>
|
|
`;
|
|
document.getElementById('new-overlay').classList.add('open');
|
|
// Show/hide client fields based on type
|
|
const typeEl = document.getElementById('ni-type');
|
|
const clientRow = document.getElementById('ni-client-row');
|
|
// Populate client dropdown from labelCache
|
|
const clientSel = document.getElementById('ni-client');
|
|
clientSel.innerHTML = '<option value="">(select client)</option>';
|
|
const lc = labelCache['claudetools'] || {};
|
|
const clientLabels = Object.keys(lc).filter(k => k.startsWith('client:') && k !== 'client-project').sort();
|
|
for (const cl of clientLabels) {
|
|
const name = cl.split(':')[1].replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
clientSel.innerHTML += `<option value="${cl}">${name}</option>`;
|
|
}
|
|
const toggleClient = () => { clientRow.style.display = typeEl.value === 'client-project' ? 'flex' : 'none'; };
|
|
typeEl.onchange = toggleClient;
|
|
// Pre-select client-project if we're in a client section
|
|
if (currentSection.startsWith('cp-')) {
|
|
typeEl.value = 'client-project';
|
|
toggleClient();
|
|
if (currentSection !== 'cp-all') {
|
|
const clientTag = 'client:' + currentSection.slice(3);
|
|
document.getElementById('ni-client').value = clientTag;
|
|
}
|
|
}
|
|
setTimeout(()=>document.getElementById('ni-title').focus(), 100);
|
|
}
|
|
function closeNew() { document.getElementById('new-overlay').classList.remove('open'); }
|
|
|
|
async function submitNew() {
|
|
const repo=document.getElementById('ni-repo').value;
|
|
const title=document.getElementById('ni-title').value.trim();
|
|
const body=document.getElementById('ni-body').value.trim();
|
|
const type=document.getElementById('ni-type').value;
|
|
const prio=document.getElementById('ni-prio').value;
|
|
const comp=document.getElementById('ni-comp').value;
|
|
const client=document.getElementById('ni-client').value;
|
|
const assign=document.getElementById('ni-assign').value;
|
|
const err=document.getElementById('ni-err');
|
|
if(!title) { err.textContent='Title required'; err.style.display='block'; return; }
|
|
if(type==='client-project'&&!client) { err.textContent='Select a client'; err.style.display='block'; return; }
|
|
const lc=labelCache[repo]||{};
|
|
const ids=[lc[type],lc[prio]].filter(Boolean);
|
|
if(comp&&lc[comp]) ids.push(lc[comp]);
|
|
if(client&&lc[client]) ids.push(lc[client]);
|
|
if(assign&&lc[assign]) ids.push(lc[assign]);
|
|
try {
|
|
const r=await fetch(`${API}/repos/${OWNER}/${repo}/issues`, {method:'POST', headers:{...headers(),'Content-Type':'application/json'}, body:JSON.stringify({title,body,labels:ids})});
|
|
if(!r.ok) { const e=await r.json(); err.textContent=e.message||'Failed'; err.style.display='block'; return; }
|
|
closeNew(); loadIssues();
|
|
} catch(e) { err.textContent=e.message; err.style.display='block'; }
|
|
}
|
|
|
|
// --- Keyboard ---
|
|
document.addEventListener('keydown', e => {
|
|
if(e.key==='Escape') { closeDetail(); closeNew(); }
|
|
if(e.key==='n' && !e.ctrlKey && !e.metaKey && document.activeElement.tagName!=='INPUT' && document.activeElement.tagName!=='TEXTAREA') showNewIssue();
|
|
});
|
|
|
|
// --- Init ---
|
|
if (getAuth()) boot(); else document.getElementById('login-screen').style.display='flex';
|
|
</script>
|
|
</body>
|
|
</html>
|