sync: auto-sync from HOWARD-HOME at 2026-07-17 20:00:56

Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-07-17 20:00:56
This commit is contained in:
2026-07-17 20:01:24 -07:00
parent 9edb424869
commit 742bc5f81b
8 changed files with 942 additions and 0 deletions

View File

@@ -19,6 +19,7 @@
- [Syncro API — Invoice Verification Pattern](syncro_invoice_verification_pattern.md) — /invoices?customer_id=X returns no ticket linkage; query /invoices/{number} for ticket_id. Compare by ticket ID, not number. - [Syncro API — Invoice Verification Pattern](syncro_invoice_verification_pattern.md) — /invoices?customer_id=X returns no ticket linkage; query /invoices/{number} for ticket_id. Compare by ticket ID, not number.
- [Syncro asset/RMM IS API-drivable](reference_syncro_rmm_api_gui_only.md) — CORRECTED 2026-07-10: asset read/create/update, patches, installed_apps, rmm_alerts all work via API; ONLY policy folders + `policy_folder_id` move need the `Assets - Policy Change` token scope (401 today). Archive stays GUI-only. Full catalog: `.claude/standards/syncro/api-reference.md`. - [Syncro asset/RMM IS API-drivable](reference_syncro_rmm_api_gui_only.md) — CORRECTED 2026-07-10: asset read/create/update, patches, installed_apps, rmm_alerts all work via API; ONLY policy folders + `policy_folder_id` move need the `Assets - Policy Change` token scope (401 today). Archive stays GUI-only. Full catalog: `.claude/standards/syncro/api-reference.md`.
- [Datto EDR detection behavior](reference_datto_edr_detection_behavior.md) — alert `sourceType`: `av`=Datto AV signature, `rule`=EDR reputation detection (both via `edr.py detections`). EDR is reputation-based not structural (wire known-bad file as autostart exe to trip it; loose files aren't surveyed). AV is tamper-protected (console-only disable); disabling Datto AV uninstalls it + Defender auto-reactivates (AMSI blocks scripts with literal EICAR → build from char codes). Verified live on RMM-TEST-MACHINE. - [Datto EDR detection behavior](reference_datto_edr_detection_behavior.md) — alert `sourceType`: `av`=Datto AV signature, `rule`=EDR reputation detection (both via `edr.py detections`). EDR is reputation-based not structural (wire known-bad file as autostart exe to trip it; loose files aren't surveyed). AV is tamper-protected (console-only disable); disabling Datto AV uninstalls it + Defender auto-reactivates (AMSI blocks scripts with literal EICAR → build from char codes). Verified live on RMM-TEST-MACHINE.
- [Bug & Project Tracker (Gitea Issues)](project_bug_tracker_gitea.md) — Official tracker for bugs/features/skills across GuruRMM, GuruConnect, ClaudeTools. Dashboard + `bug-tracker` skill. Set up 2026-07-17.
- [Approval Workflow: Tools vs Projects](approval-workflow-tools-vs-projects.md) — Tools (remediation, scripts): Howard/Claude with approval. Projects (GuruRMM): Mike approval for architecture/features; Howard can handle merges/deploys himself (2026-06-21); bugs→bug list. - [Approval Workflow: Tools vs Projects](approval-workflow-tools-vs-projects.md) — Tools (remediation, scripts): Howard/Claude with approval. Projects (GuruRMM): Mike approval for architecture/features; Howard can handle merges/deploys himself (2026-06-21); bugs→bug list.
- [CDP Chrome driver](reference_cdp_chrome_driver.md) — Drive Chrome via DevTools Protocol (.claude/scripts/cdp.py): visible window + screenshots-to-disk so Gemini/Grok can SEE the live site. Use localhost not 127.0.0.1; dedicated profile. Antigravity-style. - [CDP Chrome driver](reference_cdp_chrome_driver.md) — Drive Chrome via DevTools Protocol (.claude/scripts/cdp.py): visible window + screenshots-to-disk so Gemini/Grok can SEE the live site. Use localhost not 127.0.0.1; dedicated profile. Antigravity-style.
- [Firefox driver (ff.py)](reference_ff_firefox_driver.md) — PREFERRED browser driver. Drive Firefox via Playwright (.claude/scripts/ff.py): daemon on :9333, persistent profile, nav/shot/click/type/eval/console/network. Mike dislikes Chrome; claude-in-chrome connector disabled 2026-06-06. - [Firefox driver (ff.py)](reference_ff_firefox_driver.md) — PREFERRED browser driver. Drive Firefox via Playwright (.claude/scripts/ff.py): daemon on :9333, persistent profile, nav/shot/click/type/eval/console/network. Mike dislikes Chrome; claude-in-chrome connector disabled 2026-06-06.

View File

@@ -0,0 +1,27 @@
---
name: Bug & Project Tracker (Gitea Issues)
description: Gitea Issues is the official bug/feature/skill/project tracker for GuruRMM, GuruConnect, and ClaudeTools. Dashboard at projects/msp-tools/bug-tracker/dashboard.html. Skill at .claude/skills/bug-tracker/skill.md.
type: project
---
Mike and Howard want centralized tracking of bugs, features, skills, and project work across
GuruRMM, GuruConnect, and ClaudeTools — the wiki was not working as a tracker (no status,
no history, things got forgotten). Set up 2026-07-17.
**Why:** Bug fixes and feature work were getting dropped because there was no visible tracker.
Unless someone remembered to ask, work just disappeared. The forum (Discord ct-forum) is good
for discussion/decisions but has no status fields, filtering, or board view.
**How to apply:**
- When a bug surfaces during a session, file it as a Gitea issue (use the `bug-tracker` skill)
- Repos tracked: `gururmm`, `guru-connect`, `claudetools` (owner: `azcomputerguru`)
- Standard labels: bug/feature/skill/project, P1-P4 priority, component tags, status tags (in-progress/blocked/dropped/fixed/verified)
- Milestones: "Active Sprint" and "Backlog" on each repo
- **Live dashboard:** `https://tracker.azcomputerguru.com/dashboard.html` (SSL via NPM on Jupiter)
- Docker container `acg-tracker` on Jupiter (172.16.3.20:8089), nginx:alpine, files at `/mnt/user/appdata/acg-tracker/`
- NPM proxy host ID 12, SSL cert ID 13 (Let's Encrypt DNS challenge via Cloudflare)
- Cloudflare DNS A record: tracker.azcomputerguru.com -> 72.194.62.10
- Login with Gitea username/password (stored in localStorage via Basic auth)
- To update dashboard: re-upload `dashboard.html` to Jupiter via SFTP, `docker restart acg-tracker`
- Kanban board (Open / In Progress / Blocked / Done), list view, create/comment/close issues from the dashboard
- Source files: `projects/msp-tools/bug-tracker/`

View File

@@ -0,0 +1,90 @@
---
name: bug-tracker
description: "File, list, update, and close bugs/features/tasks in the Gitea issue tracker across GuruRMM, GuruConnect, and ClaudeTools repos. Visual dashboard at projects/msp-tools/bug-tracker/dashboard.html."
triggers:
- "file a bug"
- "track a bug"
- "open an issue"
- "bug tracker"
- "what bugs are open"
- "list bugs"
- "close issue"
- "update issue"
- "mark fixed"
- "what's being worked on"
---
# Bug & Project Tracker — Gitea Issues
Manage the ACG bug/feature/skill/project tracker via Gitea Issues API.
Dashboard: `projects/msp-tools/bug-tracker/dashboard.html`
## Setup
- Gitea: `https://git.azcomputerguru.com/api/v1`
- Owner: `azcomputerguru`
- Token: vault `services/gitea``credentials.api.api-token`
- Tracked repos: `gururmm`, `guru-connect`, `claudetools`
## Label scheme
| Label | Purpose |
|-------|---------|
| `bug` | Something broken |
| `feature` | New capability |
| `skill` | Skill work |
| `project` | Project-level |
| `P1-critical` / `P2-high` / `P3-normal` / `P4-low` | Priority |
| `in-progress` | Being worked |
| `blocked` | Blocked |
| `dropped` | Stalled |
| `fixed` | Fix applied |
| `verified` | Confirmed working |
| `security` | Security-related |
| `component:rmm-agent` / `rmm-server` / `rmm-dashboard` / `guruconnect` / `harness` | Component |
## Operations
### File a new issue
Read the token from vault. POST to `/repos/{owner}/{repo}/issues`:
```json
{
"title": "...",
"body": "## Problem\n...\n\n## Steps to reproduce\n...\n\n## Expected behavior\n...\n\n## Session context\nFiled from session on MACHINE at DATE",
"labels": [label_id, label_id, ...]
}
```
Always include: one type label (bug/feature/skill/project), one priority label, component label(s) if applicable.
### List open issues
GET `/repos/{owner}/{repo}/issues?state=open&type=issues&limit=50`
For cross-repo view, query all tracked repos and merge results.
### Update an issue (add progress, change status)
- Add a comment: POST `/repos/{owner}/{repo}/issues/{number}/comments` with `{"body": "..."}`
- Change labels: PATCH `/repos/{owner}/{repo}/issues/{number}` or use label endpoints
- Close: PATCH with `{"state": "closed"}`
### Mark fixed
Add `fixed` label, add a comment describing the fix, reference the commit if available.
### Mark verified
Add `verified` label, close the issue.
## Auto-filing (IMPORTANT)
When a bug surfaces during a Claude session — a command fails unexpectedly, a skill hits an error that needs follow-up, or the user reports something broken — **file it as an issue automatically** (with user confirmation for the title/priority). This is the whole point: bugs should not require someone to remember them.
The issue body should include:
- What happened (error message, unexpected behavior)
- Context (what was being attempted, which machine, which session)
- Any workaround applied
- Links to relevant session logs if available
## Dashboard
Open `projects/msp-tools/bug-tracker/dashboard.html` in a browser.
Pass the Gitea token as a URL parameter: `?token=xxx`
It shows a kanban board (Open / In Progress / Blocked / Done) with filtering by repo and type.
$ARGUMENTS

View File

@@ -0,0 +1,4 @@
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY dashboard.html /usr/share/nginx/html/dashboard.html
EXPOSE 80

View File

@@ -0,0 +1,655 @@
<!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 &middot; 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' },
];
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 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 !== 'all' && currentSection !== 'activity') {
const sec = SECTIONS.find(s=>s.id===currentSection);
if (sec) items = items.filter(i => sec.repos.includes(i._repo) && sec.filter(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>`;
}
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>&middot;</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'].includes(l.name));
return `<div class="card" onclick="showDetail('${i._repo}',${i.number})">
<div class="card-header"><span class="card-repo">${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('')}</div>
<div class="card-foot">
<span>${p?`<span class="pdot pdot-${p}"></span>${p}`:''}</span>
<span>${timeAgo(getDiscovered(i)||i.created_at)} old &middot; 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} &middot; ${i.state} &middot; updated ${timeAgo(i.updated_at)} &middot; 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()">&times;</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()} &middot;
Updated ${new Date(issue.updated_at).toLocaleString()} &middot;
<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()">&times;</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>
</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="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');
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 err=document.getElementById('ni-err');
if(!title) { err.textContent='Title required'; 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]);
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>

View File

@@ -0,0 +1,19 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index dashboard.html;
# Proxy Gitea API to avoid CORS issues
# Use host gateway to reach Gitea on the Docker host at port 3000
location /gitea/ {
proxy_pass http://host.docker.internal:3000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
try_files $uri $uri/ /dashboard.html;
}
}

View File

@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# Serve the bug tracker dashboard on localhost:8089
# Usage: bash projects/msp-tools/bug-tracker/serve.sh [--open]
#
# Pass the Gitea token as ?token=xxx in the browser URL, or set GITEA_TOKEN env var
# to have it injected automatically.
DIR="$(cd "$(dirname "$0")" && pwd)"
PORT=8089
echo "[INFO] Serving bug tracker dashboard at http://localhost:$PORT/dashboard.html"
if [[ -n "$GITEA_TOKEN" ]]; then
echo "[INFO] Open: http://localhost:$PORT/dashboard.html?token=$GITEA_TOKEN"
else
echo "[INFO] Add ?token=YOUR_GITEA_TOKEN to the URL for authenticated access"
fi
if [[ "$1" == "--open" ]]; then
(sleep 1 && start "" "http://localhost:$PORT/dashboard.html") &
fi
cd "$DIR"
python -m http.server $PORT --bind 127.0.0.1

View File

@@ -0,0 +1,122 @@
# 2026-07-17 — Bug & Project Tracker Deployment
## User
- **User:** Howard Enos (howard)
- **Machine:** Howard-Home
- **Role:** tech
## Session Summary
Howard relayed Mike's request for a real bug and project tracker. The existing wiki-based bug tracking was insufficient — bugs and feature work were getting dropped and forgotten because there was no visible tracker with status, priority, history, or assignment. The Discord ct-forum was considered but rejected as a tracker because it lacks status fields, filtering, and board views.
Evaluated options and chose Gitea Issues as the backend since the infrastructure was already running (Gitea on Jupiter, 33 repos, issues enabled everywhere). Created a standardized label scheme (type: bug/feature/skill/project/security; priority: P1-P4; status: in-progress/blocked/dropped/fixed/verified; components: rmm-agent/server/dashboard/guruconnect/harness) and applied it across 4 tracked repos: gururmm, guru-rmm, guru-connect, and claudetools. Created milestones (Active Sprint, Backlog) on each.
Built a single-page dashboard (dashboard.html) that reads/writes directly to the Gitea API — no separate database, no sync jobs, no cache. The dashboard provides: login via Gitea credentials (stored in localStorage), kanban board view (Open/In Progress/Blocked/Done), sortable list view, sidebar sections (RMM Bugs/Features/Security, GuruConnect, Harness Bugs, Skill Work, Projects), search, issue creation, commenting, status changes (in-progress/blocked/fixed/close/reopen), and detail modals with full comment history.
Deployed the dashboard as a Docker container (nginx:alpine) on Jupiter (172.16.3.20:8089), proxied through NPM with Let's Encrypt SSL at tracker.azcomputerguru.com. The nginx config proxies /gitea/ to the local Gitea instance to avoid CORS issues. Created Cloudflare DNS A record pointing to 72.194.62.10. Verified round-trip: create -> read -> comment -> close all work through the proxy, both Howard and Mike logins confirmed working.
Backfilled 60+ issues from existing sources: errorlog.md bugs became ClaudeTools issues (vault leak, remediation-tool failures, discord-dm attachment bug, msp360 licensing, syncro parse errors, unifi-wifi truncation, coord API errors); RMM Thoughts features became gururmm issues (14 features from the backlog); skill work items filed (8 skills needing work + 3 project-level items). All backfilled issues include a "Discovered: YYYY-MM-DD" field in the body so the dashboard shows accurate ages rather than the filing date. Fixed label gaps on all older issues (12 closed gururmm, 5 older guru-connect, guru-rmm #1) so every issue across all repos has type + priority labels. DM'd Mike the tracker URL and login instructions.
## Key Decisions
- **Gitea Issues over Discord forum or external tools** — Gitea was already running, has issues/labels/milestones/API, and requires no new accounts or services. The forum is good for discussion but lacks status fields and board views.
- **Single HTML dashboard with Gitea API proxy** — no separate database or sync mechanism. The dashboard IS Gitea's data displayed as a board. Changes in Gitea appear immediately in the dashboard and vice versa.
- **nginx proxy for /gitea/ path** — avoids CORS issues by proxying API calls through the same origin. Uses host.docker.internal to reach Gitea on the Docker host.
- **Basic auth (localStorage) over API tokens in URLs** — users log in with their Gitea username/password once, stored in browser localStorage. No tokens in URLs or embedded in the page.
- **"Discovered" date field in issue body** — Gitea doesn't support backdating created_at via API. Added `**Discovered:** YYYY-MM-DD` to issue bodies and the dashboard parses it to show accurate ages.
- **Strict separation: our code bugs only, no client machine issues** — the tracker only holds bugs in our code (skills, scripts, harness, RMM, GuruConnect) and our feature/project work. Client machine problems, vendor API limitations, and client-specific quirks stay in the wiki and errorlog.
- **Sidebar sections by domain** — not just repo filters. RMM Bugs / RMM Features / RMM Security / GuruConnect / Harness Bugs / Skill Work / Projects give focused views into each area.
## Problems Encountered
- **Gitea project boards API not exposed in v1.25** — the built-in project board feature exists in the web UI but has no REST API. Solved by building the custom dashboard with its own kanban rendering.
- **CORS on local file:// origin** — the initial dashboard opened as a local file couldn't make fetch calls to the Gitea API. Solved by serving via a local HTTP server, then deploying as a proper Docker container behind NPM.
- **nginx proxy 502 to git.azcomputerguru.com** — the container couldn't resolve the external hostname. Fixed by proxying to host.docker.internal:3000 (Gitea's internal port) with --add-host flag on docker run.
- **NPM SSL cert creation API schema** — the meta field validation was strict. Required dns_challenge + dns_provider + dns_provider_credentials in exact format (no extra fields like letsencrypt_email).
- **Local DNS at Cascades (10.137.42.1) not resolving new record** — the UniFi DNS server hadn't picked up the new Cloudflare record. Public DNS (8.8.8.8, 1.1.1.1) resolved fine. Workaround: direct IP access; DNS propagation resolves naturally.
- **sshpass not available on Windows** — needed for SSH to Jupiter with password. Solved using Python paramiko for all SSH/SFTP operations.
- **Backfilled issues showing "7m" age** — all newly created issues showed minutes-old ages instead of their real discovery dates. Solved by adding Discovered dates to issue bodies and updating the dashboard to parse and display them.
## Configuration Changes
### Files Created
- `projects/msp-tools/bug-tracker/dashboard.html` — main tracker dashboard (single-page app)
- `projects/msp-tools/bug-tracker/nginx.conf` — nginx config with Gitea API proxy
- `projects/msp-tools/bug-tracker/Dockerfile` — nginx:alpine container definition
- `projects/msp-tools/bug-tracker/serve.sh` — local development server script
- `.claude/skills/bug-tracker/skill.md` — bug-tracker skill definition
- `.claude/memory/project_bug_tracker_gitea.md` — project memory for the tracker
### Files Modified
- `.claude/memory/MEMORY.md` — added bug tracker memory entry
### Gitea Changes (via API)
- Created 20 labels on each of 4 repos (gururmm, guru-rmm, guru-connect, claudetools)
- Created 2 milestones per repo (Active Sprint, Backlog) on gururmm, guru-connect, claudetools
- Filed 10 ClaudeTools bug issues from errorlog
- Filed 14 GuruRMM feature issues from RMM Thoughts
- Filed 8 skill work issues + 3 project issues on ClaudeTools
- Added type/priority labels to all 12 closed gururmm issues, 5 older guru-connect issues, guru-rmm #1
- Added Discovered dates to all 35 backfilled issue bodies
### Infrastructure Changes
- Docker container `acg-tracker` created on Jupiter (172.16.3.20:8089)
- Files deployed to `/mnt/user/appdata/acg-tracker/` on Jupiter
- NPM proxy host ID 12 created: tracker.azcomputerguru.com -> 172.16.3.20:8089
- SSL cert ID 13 created: Let's Encrypt via Cloudflare DNS challenge
- Cloudflare DNS A record: tracker.azcomputerguru.com -> 72.194.62.10
## Credentials & Secrets
- Gitea API token (azcomputerguru): `9b1da4b79a38ef782268341d25a4b6880572063f` (vault: services/gitea)
- Gitea Howard login: howard / q6Uh-qZCq-d6mMf_DrJ64pf7WQQ6 (vault: services/gitea-howard)
- Jupiter SSH: root / Th1nk3r^99## (vault: infrastructure/jupiter-unraid-primary)
- NPM admin: mike@azcomputerguru.com / JcmO6nERg4m5JtD6 (vault: services/npm)
- Cloudflare DNS token: DRRGkHS33pxAUjQfRDzDeVPtt6wwUU6FwtXqOzNj (vault: services/cloudflare)
## Infrastructure & Servers
- **Jupiter:** 172.16.3.20, Docker host, Unraid
- `acg-tracker` container: nginx:alpine, port 8089, restart unless-stopped
- Files: `/mnt/user/appdata/acg-tracker/dashboard.html`, `nginx.conf`
- `--add-host host.docker.internal:host-gateway` for Gitea proxy
- **Gitea:** 172.16.3.20:3000 (Docker container `gitea`)
- **NPM:** 172.16.3.20:7818 (admin), ports 1880/18443
- Proxy host #12: tracker.azcomputerguru.com -> 172.16.3.20:8089 (SSL, cert #13)
- **Cloudflare:** zone 1beb9917c22b54be32e5215df2c227ce
- A record: tracker -> 72.194.62.10 (not proxied)
- **Public URL:** https://tracker.azcomputerguru.com/dashboard.html
## Commands & Outputs
```bash
# Deploy tracker to Jupiter (via paramiko)
docker run -d --name acg-tracker --restart unless-stopped \
--add-host host.docker.internal:host-gateway \
-p 8089:80 \
-v /mnt/user/appdata/acg-tracker/dashboard.html:/usr/share/nginx/html/dashboard.html:ro \
-v /mnt/user/appdata/acg-tracker/nginx.conf:/etc/nginx/conf.d/default.conf:ro \
nginx:alpine
# Update dashboard on Jupiter
# Upload via SFTP, then: docker restart acg-tracker
```
## Pending / Incomplete Tasks
- Local DNS at Cascades (10.137.42.1) may still not resolve tracker.azcomputerguru.com — will propagate naturally
- The bug-tracker skill needs integration into session error-handling workflows so bugs are auto-filed (tracked as claudetools issue #13)
- Consider adding assignee support to the dashboard (Gitea supports assignees)
- RMM Thoughts backlog (RMM_THOUGHTS.md) now has corresponding Gitea issues — future feature requests should go directly to Gitea via the tracker
## Reference Information
- Dashboard source: `projects/msp-tools/bug-tracker/dashboard.html`
- Skill definition: `.claude/skills/bug-tracker/skill.md`
- Memory: `.claude/memory/project_bug_tracker_gitea.md`
- Live URL: https://tracker.azcomputerguru.com/dashboard.html
- Direct URL: http://172.16.3.20:8089/dashboard.html
- Gitea repos tracked: gururmm, guru-rmm, guru-connect, claudetools
- NPM proxy host ID: 12, SSL cert ID: 13
- Discord DM to Mike: message_id 1527866483869220864
- Final issue counts: gururmm 21 open / 12 closed, guru-connect 18 open, claudetools 21 open, guru-rmm 1 open