Task #166: Trinity Console Issue Tracker (REQ-2026-04-14-issue-tracker)

- Migration 141: issues, issue_attachments, issue_comments
- src/routes/admin/issues.js: session-auth UI routes (list/new/detail/status/assign/comments/upload)
- src/routes/api.js: /api/internal/issues REST surface (Bearer token)
- src/services/issueNotifier.js: Discord webhook helper (DISCORD_ISSUE_WEBHOOK_URL)
- Views: index (list+filters), new (mobile-first form), detail (screenshots, comments, workflow)
- layout.ejs: sidebar nav link
- package.json: add multer ^1.4.5-lts.1
- CSRF token passed via query param on multipart forms (body unparsed when csurf runs)
- Screenshots stored in services/arbiter-3.0/uploads/issues/ (10MB limit, 6 files max)
This commit is contained in:
Claude Code
2026-04-15 01:53:18 -05:00
parent 263a7e3e47
commit b0b69fb172
11 changed files with 916 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
-- Migration 141: Trinity Console Issue Tracker (Task #166)
-- Filed by Chronicler #89 on REQ-2026-04-14-issue-tracker.
-- Creates issues + attachments + comments tables.
CREATE TABLE IF NOT EXISTS issues (
id SERIAL PRIMARY KEY,
issue_number INTEGER UNIQUE,
title VARCHAR(255) NOT NULL,
description TEXT,
status VARCHAR(20) DEFAULT 'open', -- open, in_progress, blocked, resolved, closed
priority VARCHAR(20) DEFAULT 'medium', -- critical, high, medium, low
category VARCHAR(50) DEFAULT 'general', -- bug, feature, content, infrastructure, holly, general
submitted_by VARCHAR(100) NOT NULL, -- Discord username
assigned_to VARCHAR(100) DEFAULT 'unassigned',
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
resolved_at TIMESTAMPTZ,
resolved_by VARCHAR(100)
);
CREATE INDEX IF NOT EXISTS idx_issues_status ON issues (status);
CREATE INDEX IF NOT EXISTS idx_issues_priority ON issues (priority);
CREATE INDEX IF NOT EXISTS idx_issues_category ON issues (category);
CREATE INDEX IF NOT EXISTS idx_issues_submitter ON issues (submitted_by);
CREATE TABLE IF NOT EXISTS issue_attachments (
id SERIAL PRIMARY KEY,
issue_id INTEGER REFERENCES issues(id) ON DELETE CASCADE,
filename VARCHAR(255) NOT NULL,
original_name VARCHAR(255),
mime_type VARCHAR(100),
file_size INTEGER,
uploaded_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_issue_attachments_issue ON issue_attachments (issue_id);
CREATE TABLE IF NOT EXISTS issue_comments (
id SERIAL PRIMARY KEY,
issue_id INTEGER REFERENCES issues(id) ON DELETE CASCADE,
author VARCHAR(100) NOT NULL,
content TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_issue_comments_issue ON issue_comments (issue_id);

View File

@@ -21,6 +21,7 @@
"express": "^4.18.2",
"express-ejs-layouts": "^2.5.1",
"express-session": "^1.19.0",
"multer": "^1.4.5-lts.1",
"node-cron": "^3.0.3",
"passport": "^0.7.0",
"passport-discord": "^0.1.4",

View File

@@ -24,6 +24,7 @@ const mcpLogsRouter = require('./mcp-logs');
const tasksRouter = require('./tasks');
const forgeRouter = require('./forge');
const nodeHealthRouter = require('./node-health');
const issuesRouter = require('./issues');
router.use(requireTrinityAccess);
@@ -143,5 +144,6 @@ router.use('/mcp-logs', mcpLogsRouter);
router.use('/tasks', tasksRouter);
router.use('/forge', forgeRouter);
router.use('/node-health', nodeHealthRouter);
router.use('/issues', issuesRouter);
module.exports = router;

View File

@@ -0,0 +1,361 @@
/**
* Issue Tracker — Trinity Console
*
* Task #166 — REQ-2026-04-14-issue-tracker (Chronicler #89)
* Holly's primary submission channel. Mobile-first form, screenshot upload
* from camera roll, minimal fields, fire-and-forget.
*
* Session auth (shared admin middleware). REST-at-distance via /api/internal/issues
* lives in src/routes/api.js for n8n/external submission.
*
* Routes
* GET /admin/issues — list + filters
* GET /admin/issues/new — mobile submit form
* POST /admin/issues — create (with optional screenshots)
* GET /admin/issues/:id — detail page
* POST /admin/issues/:id/status — change status (HTMX)
* POST /admin/issues/:id/assign — change assignee (HTMX)
* POST /admin/issues/:id/comments — add comment
* POST /admin/issues/:id/upload — add more screenshots after create
*/
const express = require('express');
const path = require('path');
const fs = require('fs');
const multer = require('multer');
const db = require('../../database');
const notifier = require('../../services/issueNotifier');
const router = express.Router();
const STATUSES = ['open', 'in_progress', 'blocked', 'resolved', 'closed'];
const PRIORITIES = ['critical', 'high', 'medium', 'low'];
const CATEGORIES = ['bug', 'feature', 'content', 'infrastructure', 'holly', 'general'];
const ASSIGNEES = ['Michael', 'Meg', 'Holly', 'Trinity', 'unassigned'];
// -----------------------------------------------------------------------------
// Upload storage
// -----------------------------------------------------------------------------
const UPLOAD_DIR = path.resolve(__dirname, '..', '..', '..', 'uploads', 'issues');
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
const storage = multer.diskStorage({
destination: (req, file, cb) => cb(null, UPLOAD_DIR),
filename: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase().slice(0, 8);
const unique = Date.now() + '-' + Math.random().toString(36).slice(2, 10);
cb(null, `${unique}${ext}`);
}
});
const upload = multer({
storage,
limits: { fileSize: 10 * 1024 * 1024, files: 6 }, // 10MB each, max 6 screenshots
fileFilter: (req, file, cb) => {
if (!/^image\//.test(file.mimetype)) {
return cb(new Error('Only image uploads are allowed'));
}
cb(null, true);
}
});
// Lenient wrapper so multer errors don't kill the request
function uploadMaybe(field) {
return (req, res, next) => {
upload.array(field, 6)(req, res, (err) => {
if (err) {
console.warn('[Issues] upload warning:', err.message);
req.uploadError = err.message;
}
next();
});
};
}
// -----------------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------------
async function nextIssueNumber() {
const r = await db.query('SELECT COALESCE(MAX(issue_number), 0) + 1 AS n FROM issues');
return r.rows[0].n;
}
async function loadIssueFull(id) {
const iss = await db.query('SELECT * FROM issues WHERE id = $1', [id]);
if (iss.rows.length === 0) return null;
const issue = iss.rows[0];
const atts = await db.query(
'SELECT * FROM issue_attachments WHERE issue_id = $1 ORDER BY uploaded_at ASC',
[id]
);
const cmts = await db.query(
'SELECT * FROM issue_comments WHERE issue_id = $1 ORDER BY created_at ASC',
[id]
);
issue.attachments = atts.rows;
issue.comments = cmts.rows;
return issue;
}
function currentUsername(req) {
return req.user?.username || req.user?.global_name || 'Trinity Console';
}
// -----------------------------------------------------------------------------
// GET /admin/issues — list
// -----------------------------------------------------------------------------
router.get('/', async (req, res) => {
try {
const { status, priority, category, submitter, sort } = req.query;
const params = [];
let where = 'WHERE 1=1';
let p = 0;
if (status) { p++; where += ` AND status = $${p}`; params.push(status); }
else if (!req.query.all) { where += ` AND status NOT IN ('resolved','closed')`; }
if (priority) { p++; where += ` AND priority = $${p}`; params.push(priority); }
if (category) { p++; where += ` AND category = $${p}`; params.push(category); }
if (submitter) { p++; where += ` AND submitted_by = $${p}`; params.push(submitter); }
let order = `CASE status
WHEN 'in_progress' THEN 1 WHEN 'blocked' THEN 2 WHEN 'open' THEN 3
WHEN 'resolved' THEN 4 WHEN 'closed' THEN 5 END,
CASE priority
WHEN 'critical' THEN 1 WHEN 'high' THEN 2
WHEN 'medium' THEN 3 WHEN 'low' THEN 4 END,
issue_number DESC`;
if (sort === 'newest') order = 'created_at DESC';
if (sort === 'updated') order = 'updated_at DESC';
if (sort === 'priority') order = `CASE priority WHEN 'critical' THEN 1 WHEN 'high' THEN 2 WHEN 'medium' THEN 3 WHEN 'low' THEN 4 END, issue_number DESC`;
const result = await db.query(
`SELECT i.*,
(SELECT COUNT(*) FROM issue_attachments a WHERE a.issue_id = i.id) AS attachment_count,
(SELECT COUNT(*) FROM issue_comments c WHERE c.issue_id = i.id) AS comment_count
FROM issues i ${where}
ORDER BY ${order}`,
params
);
const stats = await db.query(`
SELECT
COUNT(*) FILTER (WHERE status NOT IN ('resolved','closed')) AS active,
COUNT(*) FILTER (WHERE status = 'open') AS open,
COUNT(*) FILTER (WHERE status = 'in_progress') AS in_progress,
COUNT(*) FILTER (WHERE status = 'blocked') AS blocked,
COUNT(*) FILTER (WHERE priority IN ('critical','high') AND status NOT IN ('resolved','closed')) AS urgent
FROM issues
`);
res.render('admin/issues/index', {
title: 'Issues',
currentPath: '/issues',
issues: result.rows,
stats: stats.rows[0],
filters: { status, priority, category, submitter, sort, all: req.query.all },
statuses: STATUSES,
priorities: PRIORITIES,
categories: CATEGORIES,
assignees: ASSIGNEES,
adminUser: req.user,
layout: 'layout'
});
} catch (err) {
console.error('[Issues] list error:', err);
res.status(500).send('Error loading issues');
}
});
// -----------------------------------------------------------------------------
// GET /admin/issues/new — mobile-first submit form
// -----------------------------------------------------------------------------
router.get('/new', (req, res) => {
res.render('admin/issues/new', {
title: 'New Issue',
currentPath: '/issues',
priorities: PRIORITIES,
categories: CATEGORIES,
adminUser: req.user,
layout: 'layout'
});
});
// -----------------------------------------------------------------------------
// POST /admin/issues — create
// -----------------------------------------------------------------------------
router.post('/', uploadMaybe('screenshots'), async (req, res) => {
try {
const { title, description, priority, category } = req.body;
if (!title || !title.trim()) {
return res.status(400).send('Title required');
}
const issueNumber = await nextIssueNumber();
const submittedBy = currentUsername(req);
const inserted = await db.query(
`INSERT INTO issues (issue_number, title, description, priority, category, submitted_by)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
[
issueNumber,
title.trim(),
description || null,
PRIORITIES.includes(priority) ? priority : 'medium',
CATEGORIES.includes(category) ? category : 'general',
submittedBy
]
);
const issue = inserted.rows[0];
// Attach screenshots
if (req.files && req.files.length > 0) {
for (const f of req.files) {
await db.query(
`INSERT INTO issue_attachments (issue_id, filename, original_name, mime_type, file_size)
VALUES ($1, $2, $3, $4, $5)`,
[issue.id, f.filename, f.originalname, f.mimetype, f.size]
);
}
}
console.log(`🐛 [Issues] #${issueNumber} opened by ${submittedBy}: ${title}`);
notifier.notifyCreated(issue);
res.redirect(`/admin/issues/${issue.id}`);
} catch (err) {
console.error('[Issues] create error:', err);
res.status(500).send('Error creating issue');
}
});
// -----------------------------------------------------------------------------
// GET /admin/issues/:id — detail
// -----------------------------------------------------------------------------
router.get('/:id(\\d+)', async (req, res) => {
try {
const issue = await loadIssueFull(req.params.id);
if (!issue) return res.status(404).send('Issue not found');
res.render('admin/issues/detail', {
title: `Issue #${issue.issue_number}`,
currentPath: '/issues',
issue,
statuses: STATUSES,
priorities: PRIORITIES,
categories: CATEGORIES,
assignees: ASSIGNEES,
adminUser: req.user,
layout: 'layout'
});
} catch (err) {
console.error('[Issues] detail error:', err);
res.status(500).send('Error loading issue');
}
});
// -----------------------------------------------------------------------------
// POST /admin/issues/:id/status
// -----------------------------------------------------------------------------
router.post('/:id(\\d+)/status', async (req, res) => {
try {
const { status } = req.body;
if (!STATUSES.includes(status)) return res.status(400).send('Invalid status');
const actor = currentUsername(req);
const extras = [];
const params = [status, req.params.id];
if (status === 'resolved' || status === 'closed') {
extras.push('resolved_at = NOW()', `resolved_by = $3`);
params.splice(2, 0, actor);
}
const sql = `UPDATE issues SET status = $1, updated_at = NOW()${extras.length ? ', ' + extras.join(', ') : ''}
WHERE id = $${params.length} RETURNING *`;
const r = await db.query(sql, params);
if (r.rows.length === 0) return res.status(404).send('Not found');
notifier.notifyStatusChange(r.rows[0], status, actor);
res.redirect(`/admin/issues/${req.params.id}`);
} catch (err) {
console.error('[Issues] status error:', err);
res.status(500).send('Error updating status');
}
});
// -----------------------------------------------------------------------------
// POST /admin/issues/:id/assign
// -----------------------------------------------------------------------------
router.post('/:id(\\d+)/assign', async (req, res) => {
try {
const { assigned_to } = req.body;
await db.query(
`UPDATE issues SET assigned_to = $1, updated_at = NOW() WHERE id = $2`,
[assigned_to || 'unassigned', req.params.id]
);
res.redirect(`/admin/issues/${req.params.id}`);
} catch (err) {
console.error('[Issues] assign error:', err);
res.status(500).send('Error updating assignment');
}
});
// -----------------------------------------------------------------------------
// POST /admin/issues/:id/comments
// -----------------------------------------------------------------------------
router.post('/:id(\\d+)/comments', async (req, res) => {
try {
const { content } = req.body;
if (!content || !content.trim()) return res.redirect(`/admin/issues/${req.params.id}`);
const author = currentUsername(req);
await db.query(
`INSERT INTO issue_comments (issue_id, author, content) VALUES ($1, $2, $3)`,
[req.params.id, author, content.trim()]
);
await db.query(`UPDATE issues SET updated_at = NOW() WHERE id = $1`, [req.params.id]);
const issue = await loadIssueFull(req.params.id);
if (issue) notifier.notifyComment(issue, author);
res.redirect(`/admin/issues/${req.params.id}`);
} catch (err) {
console.error('[Issues] comment error:', err);
res.status(500).send('Error adding comment');
}
});
// -----------------------------------------------------------------------------
// POST /admin/issues/:id/upload — add screenshots post-hoc
// -----------------------------------------------------------------------------
router.post('/:id(\\d+)/upload', uploadMaybe('screenshots'), async (req, res) => {
try {
if (req.files && req.files.length > 0) {
for (const f of req.files) {
await db.query(
`INSERT INTO issue_attachments (issue_id, filename, original_name, mime_type, file_size)
VALUES ($1, $2, $3, $4, $5)`,
[req.params.id, f.filename, f.originalname, f.mimetype, f.size]
);
}
await db.query(`UPDATE issues SET updated_at = NOW() WHERE id = $1`, [req.params.id]);
}
res.redirect(`/admin/issues/${req.params.id}`);
} catch (err) {
console.error('[Issues] upload error:', err);
res.status(500).send('Error uploading attachments');
}
});
// -----------------------------------------------------------------------------
// GET /admin/issues/attachments/:filename — serve image
// -----------------------------------------------------------------------------
router.get('/attachments/:filename', (req, res) => {
const name = req.params.filename;
if (!/^[a-zA-Z0-9._-]+$/.test(name)) return res.status(400).send('Bad filename');
const abs = path.join(UPLOAD_DIR, name);
if (!abs.startsWith(UPLOAD_DIR) || !fs.existsSync(abs)) return res.status(404).send('Not found');
res.sendFile(abs);
});
module.exports = router;

View File

@@ -516,4 +516,131 @@ router.patch('/tasks/:id', async (req, res) => {
}
});
// =============================================================================
// Issue Tracker — /api/internal/issues*
// Task #166 — REQ-2026-04-14-issue-tracker
// Token-auth'd REST surface that mirrors the admin UI. Use for n8n / external
// submitters. The admin UI under /admin/issues uses the same DB tables.
// =============================================================================
const issueNotifier = require('../services/issueNotifier');
const ISSUE_STATUSES = ['open', 'in_progress', 'blocked', 'resolved', 'closed'];
const ISSUE_PRIORITIES = ['critical', 'high', 'medium', 'low'];
const ISSUE_CATEGORIES = ['bug', 'feature', 'content', 'infrastructure', 'holly', 'general'];
router.get('/issues', async (req, res) => {
try {
const { status, priority, category } = req.query;
const params = [];
let where = 'WHERE 1=1';
let p = 0;
if (status) { p++; where += ` AND status = $${p}`; params.push(status); }
if (priority) { p++; where += ` AND priority = $${p}`; params.push(priority); }
if (category) { p++; where += ` AND category = $${p}`; params.push(category); }
const result = await db.query(
`SELECT * FROM issues ${where} ORDER BY issue_number DESC LIMIT 200`,
params
);
res.json({ issues: result.rows });
} catch (err) {
console.error('[API Issues] list error:', err);
res.status(500).json({ error: 'Failed to list issues' });
}
});
router.post('/issues', async (req, res) => {
try {
const { title, description, priority, category, submitted_by } = req.body;
if (!title || !submitted_by) {
return res.status(400).json({ error: 'title and submitted_by required' });
}
const next = await db.query('SELECT COALESCE(MAX(issue_number), 0) + 1 AS n FROM issues');
const issueNumber = next.rows[0].n;
const result = await db.query(
`INSERT INTO issues (issue_number, title, description, priority, category, submitted_by)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
[
issueNumber,
title,
description || null,
ISSUE_PRIORITIES.includes(priority) ? priority : 'medium',
ISSUE_CATEGORIES.includes(category) ? category : 'general',
submitted_by
]
);
const issue = result.rows[0];
issueNotifier.notifyCreated(issue);
res.json({ success: true, issue });
} catch (err) {
console.error('[API Issues] create error:', err);
res.status(500).json({ error: 'Failed to create issue' });
}
});
router.get('/issues/:id', async (req, res) => {
try {
const iss = await db.query('SELECT * FROM issues WHERE id = $1', [req.params.id]);
if (iss.rows.length === 0) return res.status(404).json({ error: 'Not found' });
const atts = await db.query(
'SELECT * FROM issue_attachments WHERE issue_id = $1 ORDER BY uploaded_at',
[req.params.id]
);
const cmts = await db.query(
'SELECT * FROM issue_comments WHERE issue_id = $1 ORDER BY created_at',
[req.params.id]
);
res.json({ issue: iss.rows[0], attachments: atts.rows, comments: cmts.rows });
} catch (err) {
console.error('[API Issues] detail error:', err);
res.status(500).json({ error: 'Failed to load issue' });
}
});
router.patch('/issues/:id', async (req, res) => {
try {
const { status, priority, category, assigned_to } = req.body;
const updates = [];
const params = [];
let p = 0;
if (status) { if (!ISSUE_STATUSES.includes(status)) return res.status(400).json({ error: 'bad status' }); p++; updates.push(`status = $${p}`); params.push(status); }
if (priority) { if (!ISSUE_PRIORITIES.includes(priority)) return res.status(400).json({ error: 'bad priority' }); p++; updates.push(`priority = $${p}`); params.push(priority); }
if (category) { if (!ISSUE_CATEGORIES.includes(category)) return res.status(400).json({ error: 'bad category' }); p++; updates.push(`category = $${p}`); params.push(category); }
if (assigned_to) { p++; updates.push(`assigned_to = $${p}`); params.push(assigned_to); }
if (status === 'resolved' || status === 'closed') {
updates.push('resolved_at = NOW()');
p++; updates.push(`resolved_by = $${p}`); params.push(req.body.actor || 'api');
}
updates.push('updated_at = NOW()');
if (updates.length <= 1) return res.status(400).json({ error: 'Nothing to update' });
p++; params.push(req.params.id);
const r = await db.query(
`UPDATE issues SET ${updates.join(', ')} WHERE id = $${p} RETURNING *`,
params
);
if (r.rows.length === 0) return res.status(404).json({ error: 'Not found' });
if (status) issueNotifier.notifyStatusChange(r.rows[0], status, req.body.actor || 'api');
res.json({ success: true, issue: r.rows[0] });
} catch (err) {
console.error('[API Issues] patch error:', err);
res.status(500).json({ error: 'Failed to update issue' });
}
});
router.post('/issues/:id/comments', async (req, res) => {
try {
const { author, content } = req.body;
if (!author || !content) return res.status(400).json({ error: 'author and content required' });
const r = await db.query(
`INSERT INTO issue_comments (issue_id, author, content) VALUES ($1, $2, $3) RETURNING *`,
[req.params.id, author, content]
);
await db.query(`UPDATE issues SET updated_at = NOW() WHERE id = $1`, [req.params.id]);
res.json({ success: true, comment: r.rows[0] });
} catch (err) {
console.error('[API Issues] comment error:', err);
res.status(500).json({ error: 'Failed to add comment' });
}
});
module.exports = router;

View File

@@ -0,0 +1,54 @@
/**
* Issue Notifier — Discord webhook dispatch for the Trinity Console Issue Tracker.
*
* Posts to #issue-tracker via DISCORD_ISSUE_WEBHOOK_URL env var.
* Failures are logged but never thrown — issue CRUD must never fail because
* Discord is unreachable.
*
* Task #166 — REQ-2026-04-14-issue-tracker
*/
const axios = require('axios');
const WEBHOOK_URL = process.env.DISCORD_ISSUE_WEBHOOK_URL || '';
async function postWebhook(content) {
if (!WEBHOOK_URL) {
console.warn('[IssueNotifier] DISCORD_ISSUE_WEBHOOK_URL not set — skipping notify');
return;
}
try {
await axios.post(WEBHOOK_URL, { content }, { timeout: 5000 });
} catch (err) {
console.error('[IssueNotifier] webhook post failed:', err.message);
}
}
function notifyCreated(issue) {
const pri = String(issue.priority || 'medium').toUpperCase();
return postWebhook(
`🐛 Issue #${issue.issue_number} opened by **${issue.submitted_by}**: ` +
`${issue.title} _(priority: ${pri}, category: ${issue.category})_`
);
}
function notifyStatusChange(issue, newStatus, actor) {
const icon = {
open: '📂',
in_progress: '🔧',
blocked: '⛔',
resolved: '✅',
closed: '📦'
}[newStatus] || '🔄';
return postWebhook(
`${icon} Issue #${issue.issue_number} → **${newStatus}** by ${actor || 'Trinity Console'}: ${issue.title}`
);
}
function notifyComment(issue, author) {
return postWebhook(
`💬 New comment on Issue #${issue.issue_number} by **${author}**: ${issue.title}`
);
}
module.exports = { notifyCreated, notifyStatusChange, notifyComment };

View File

@@ -0,0 +1,137 @@
<!-- Issue detail view -->
<!-- Task #166 — REQ-2026-04-14-issue-tracker -->
<style>
.chip { display:inline-block; padding:2px 10px; border-radius:9999px; font-size:11px; font-weight:500; }
.pri-critical { background:#7f1d1d; color:#fecaca; }
.pri-high { background:#9a3412; color:#fed7aa; }
.pri-medium { background:#374151; color:#d1d5db; }
.pri-low { background:#1e3a8a; color:#bfdbfe; }
.st-open { background:#1e40af; color:#bfdbfe; }
.st-in_progress { background:#065f46; color:#a7f3d0; }
.st-blocked { background:#7f1d1d; color:#fecaca; }
.st-resolved { background:#374151; color:#9ca3af; }
.st-closed { background:#1f2937; color:#6b7280; }
.thumb { width:140px; height:140px; object-fit:cover; border-radius:6px; border:1px solid #374151; cursor:pointer; }
.lightbox { position:fixed; inset:0; background:rgba(0,0,0,0.85); display:none; align-items:center; justify-content:center; z-index:50; }
.lightbox.open { display:flex; }
.lightbox img { max-width:95vw; max-height:95vh; }
</style>
<div class="max-w-3xl mx-auto">
<div class="flex items-center justify-between mb-4">
<a href="/admin/issues" class="text-sm text-gray-400 hover:text-gray-200">← Back to issues</a>
<span class="text-xs text-gray-500">Submitted by <strong><%= issue.submitted_by %></strong> · <%= new Date(issue.created_at).toLocaleString() %></span>
</div>
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-5 mb-4">
<div class="flex items-start justify-between gap-3 mb-3">
<h1 class="text-2xl font-bold">
<span class="text-gray-500 font-mono">#<%= issue.issue_number %></span>
<%= issue.title %>
</h1>
<div class="flex gap-2 shrink-0">
<span class="chip st-<%= issue.status %>"><%= issue.status %></span>
<span class="chip pri-<%= issue.priority %>"><%= issue.priority %></span>
<span class="chip" style="background:#1f2937;color:#9ca3af;"><%= issue.category %></span>
</div>
</div>
<% if (issue.description) { %>
<div class="text-gray-300 whitespace-pre-wrap mt-3"><%= issue.description %></div>
<% } else { %>
<div class="text-gray-500 italic mt-3">No description provided.</div>
<% } %>
<!-- Screenshots -->
<% if (issue.attachments && issue.attachments.length > 0) { %>
<div class="mt-4">
<div class="text-xs uppercase tracking-wider text-gray-500 mb-2">Screenshots (<%= issue.attachments.length %>)</div>
<div class="flex flex-wrap gap-2">
<% issue.attachments.forEach(function(a) { %>
<img src="/admin/issues/attachments/<%= a.filename %>" alt="<%= a.original_name %>"
class="thumb" onclick="openLightbox(this.src)">
<% }) %>
</div>
</div>
<% } %>
<!-- Upload more -->
<!-- csurf reads _csrf from query string for multipart forms -->
<form method="POST" action="/admin/issues/<%= issue.id %>/upload?_csrf=<%= encodeURIComponent(csrfToken) %>" enctype="multipart/form-data" class="mt-4">
<label class="text-xs text-gray-500">Add screenshots</label>
<div class="flex gap-2 mt-1">
<input type="file" name="screenshots" accept="image/*" multiple
class="block flex-1 text-xs text-gray-300 file:mr-2 file:py-1 file:px-2 file:rounded file:border-0 file:bg-gray-700 file:text-white">
<button type="submit" class="bg-gray-700 hover:bg-gray-600 text-white px-3 py-1 rounded text-xs">Upload</button>
</div>
</form>
</div>
<!-- Workflow controls -->
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mb-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<form method="POST" action="/admin/issues/<%= issue.id %>/status" class="flex items-center gap-2">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<label class="text-xs text-gray-500 w-16">Status</label>
<select name="status" class="flex-1 bg-gray-800 border border-gray-700 rounded px-2 py-1 text-sm">
<% statuses.forEach(function(s) { %>
<option value="<%= s %>" <%= issue.status === s ? 'selected' : '' %>><%= s %></option>
<% }) %>
</select>
<button class="bg-cyan-600 hover:bg-cyan-700 text-white px-3 py-1 rounded text-xs">Save</button>
</form>
<form method="POST" action="/admin/issues/<%= issue.id %>/assign" class="flex items-center gap-2">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<label class="text-xs text-gray-500 w-16">Assignee</label>
<select name="assigned_to" class="flex-1 bg-gray-800 border border-gray-700 rounded px-2 py-1 text-sm">
<% assignees.forEach(function(a) { %>
<option value="<%= a %>" <%= issue.assigned_to === a ? 'selected' : '' %>><%= a %></option>
<% }) %>
</select>
<button class="bg-cyan-600 hover:bg-cyan-700 text-white px-3 py-1 rounded text-xs">Save</button>
</form>
</div>
<% if (issue.resolved_at) { %>
<div class="text-xs text-gray-500 mt-3">Resolved <%= new Date(issue.resolved_at).toLocaleString() %> by <strong><%= issue.resolved_by %></strong></div>
<% } %>
</div>
<!-- Comments -->
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<h2 class="text-sm font-semibold mb-3 text-gray-400 uppercase tracking-wider">Comments (<%= issue.comments.length %>)</h2>
<% if (issue.comments.length === 0) { %>
<div class="text-gray-500 text-sm italic mb-3">No comments yet.</div>
<% } else { %>
<div class="space-y-3 mb-4">
<% issue.comments.forEach(function(c) { %>
<div class="border-l-2 border-cyan-700 pl-3">
<div class="text-xs text-gray-500">
<strong class="text-gray-300"><%= c.author %></strong> · <%= new Date(c.created_at).toLocaleString() %>
</div>
<div class="text-sm text-gray-200 whitespace-pre-wrap mt-1"><%= c.content %></div>
</div>
<% }) %>
</div>
<% } %>
<form method="POST" action="/admin/issues/<%= issue.id %>/comments">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<textarea name="content" rows="3" required placeholder="Add a comment…"
class="w-full bg-gray-800 border border-gray-700 rounded-md px-3 py-2 text-sm text-white"></textarea>
<button type="submit" class="mt-2 bg-cyan-600 hover:bg-cyan-700 text-white px-4 py-2 rounded text-sm">Comment</button>
</form>
</div>
</div>
<div id="lightbox" class="lightbox" onclick="this.classList.remove('open')">
<img id="lightbox-img" src="" alt="">
</div>
<script>
function openLightbox(src) {
document.getElementById('lightbox-img').src = src;
document.getElementById('lightbox').classList.add('open');
}
</script>

View File

@@ -0,0 +1,122 @@
<!-- Issue Tracker — Trinity Console -->
<!-- Task #166 — REQ-2026-04-14-issue-tracker (Chronicler #89) -->
<style>
.chip { cursor:pointer; padding:2px 10px; border-radius:9999px; font-size:11px; font-weight:500; border:1px solid transparent; transition:all 0.15s; }
.chip.active { background:#06b6d4; color:#fff; border-color:#06b6d4; }
.chip.inactive { background:#374151; color:#9ca3af; border-color:#4b5563; }
.chip:hover { opacity:0.85; }
.pri-critical { background:#7f1d1d; color:#fecaca; }
.pri-high { background:#9a3412; color:#fed7aa; }
.pri-medium { background:#374151; color:#d1d5db; }
.pri-low { background:#1e3a8a; color:#bfdbfe; }
.st-open { background:#1e40af; color:#bfdbfe; }
.st-in_progress { background:#065f46; color:#a7f3d0; }
.st-blocked { background:#7f1d1d; color:#fecaca; }
.st-resolved { background:#374151; color:#9ca3af; }
.st-closed { background:#1f2937; color:#6b7280; }
</style>
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<h1 class="text-2xl font-bold">🐛 Issues</h1>
<a href="/admin/issues/new" class="bg-cyan-600 hover:bg-cyan-700 text-white px-4 py-2 rounded-md text-sm font-medium transition">+ New Issue</a>
</div>
<!-- Stats -->
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Active</div>
<div class="text-2xl font-bold mt-1"><%= stats.active %></div>
</div>
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Open</div>
<div class="text-2xl font-bold mt-1 text-blue-400"><%= stats.open %></div>
</div>
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">In Progress</div>
<div class="text-2xl font-bold mt-1 text-green-400"><%= stats.in_progress %></div>
</div>
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Blocked</div>
<div class="text-2xl font-bold mt-1 text-red-500"><%= stats.blocked %></div>
</div>
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
<div class="text-sm text-gray-500 dark:text-gray-400">Urgent</div>
<div class="text-2xl font-bold mt-1 text-fire"><%= stats.urgent %></div>
</div>
</div>
<!-- Filter bar -->
<form method="get" action="/admin/issues" class="flex flex-wrap gap-2 mb-4 items-center">
<select name="status" onchange="this.form.submit()" class="bg-gray-800 text-gray-200 text-xs rounded px-2 py-1 border border-gray-700">
<option value="">All active</option>
<% statuses.forEach(function(s) { %>
<option value="<%= s %>" <%= filters.status === s ? 'selected' : '' %>><%= s %></option>
<% }) %>
</select>
<select name="priority" onchange="this.form.submit()" class="bg-gray-800 text-gray-200 text-xs rounded px-2 py-1 border border-gray-700">
<option value="">Any priority</option>
<% priorities.forEach(function(p) { %>
<option value="<%= p %>" <%= filters.priority === p ? 'selected' : '' %>><%= p %></option>
<% }) %>
</select>
<select name="category" onchange="this.form.submit()" class="bg-gray-800 text-gray-200 text-xs rounded px-2 py-1 border border-gray-700">
<option value="">Any category</option>
<% categories.forEach(function(c) { %>
<option value="<%= c %>" <%= filters.category === c ? 'selected' : '' %>><%= c %></option>
<% }) %>
</select>
<select name="sort" onchange="this.form.submit()" class="bg-gray-800 text-gray-200 text-xs rounded px-2 py-1 border border-gray-700">
<option value="" <%= !filters.sort ? 'selected' : '' %>>Default</option>
<option value="newest" <%= filters.sort === 'newest' ? 'selected' : '' %>>Newest</option>
<option value="updated" <%= filters.sort === 'updated' ? 'selected' : '' %>>Recently updated</option>
<option value="priority" <%= filters.sort === 'priority' ? 'selected' : '' %>>Priority</option>
</select>
<label class="text-xs text-gray-400 flex items-center gap-1 ml-2">
<input type="checkbox" name="all" value="1" <%= filters.all ? 'checked' : '' %> onchange="this.form.submit()">
Show resolved/closed
</label>
<% if (filters.status || filters.priority || filters.category || filters.sort || filters.all) { %>
<a href="/admin/issues" class="text-xs text-cyan-400 hover:underline ml-2">Clear</a>
<% } %>
</form>
<!-- List -->
<div class="bg-white dark:bg-darkcard rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden">
<% if (issues.length === 0) { %>
<div class="p-6 text-center text-gray-500">No issues match the current filters.</div>
<% } else { %>
<table class="w-full text-sm">
<thead class="bg-gray-100 dark:bg-gray-800 text-xs uppercase text-gray-500 dark:text-gray-400">
<tr>
<th class="px-3 py-2 text-left">#</th>
<th class="px-3 py-2 text-left">Title</th>
<th class="px-3 py-2 text-left">Status</th>
<th class="px-3 py-2 text-left">Priority</th>
<th class="px-3 py-2 text-left">Category</th>
<th class="px-3 py-2 text-left">Submitter</th>
<th class="px-3 py-2 text-left">Updated</th>
</tr>
</thead>
<tbody>
<% issues.forEach(function(i) { %>
<tr class="border-t border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800/40 cursor-pointer"
onclick="location.href='/admin/issues/<%= i.id %>'">
<td class="px-3 py-2 font-mono text-gray-400">#<%= i.issue_number %></td>
<td class="px-3 py-2">
<%= i.title %>
<% if (i.attachment_count > 0) { %><span class="text-gray-500 text-xs">📎<%= i.attachment_count %></span><% } %>
<% if (i.comment_count > 0) { %><span class="text-gray-500 text-xs">💬<%= i.comment_count %></span><% } %>
</td>
<td class="px-3 py-2"><span class="chip st-<%= i.status %>"><%= i.status %></span></td>
<td class="px-3 py-2"><span class="chip pri-<%= i.priority %>"><%= i.priority %></span></td>
<td class="px-3 py-2 text-gray-400 text-xs"><%= i.category %></td>
<td class="px-3 py-2 text-gray-400 text-xs"><%= i.submitted_by %></td>
<td class="px-3 py-2 text-gray-500 text-xs"><%= new Date(i.updated_at).toLocaleString() %></td>
</tr>
<% }) %>
</tbody>
</table>
<% } %>
</div>

View File

@@ -0,0 +1,63 @@
<!-- Mobile-first submit form — Holly's primary entry point. -->
<!-- Task #166 — REQ-2026-04-14-issue-tracker -->
<div class="max-w-lg mx-auto">
<div class="flex items-center justify-between mb-4">
<h1 class="text-xl font-bold">🐛 New Issue</h1>
<a href="/admin/issues" class="text-sm text-gray-400 hover:text-gray-200">← Back</a>
</div>
<!-- csurf reads _csrf from query string for multipart forms (body isn't parsed until multer runs) -->
<form method="POST" action="/admin/issues?_csrf=<%= encodeURIComponent(csrfToken) %>" enctype="multipart/form-data"
class="space-y-4 bg-white dark:bg-darkcard p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<div>
<label class="block text-sm font-medium mb-1">Title</label>
<input name="title" required maxlength="255" autofocus
placeholder="What broke / what's needed?"
class="w-full bg-gray-800 border border-gray-700 rounded-md px-3 py-3 text-base text-white focus:ring-cyan-500 focus:border-cyan-500">
</div>
<div>
<label class="block text-sm font-medium mb-1">Description <span class="text-gray-500 text-xs">(optional)</span></label>
<textarea name="description" rows="4"
placeholder="Steps to reproduce, expected behavior, context…"
class="w-full bg-gray-800 border border-gray-700 rounded-md px-3 py-2 text-sm text-white focus:ring-cyan-500 focus:border-cyan-500"></textarea>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-sm font-medium mb-1">Priority</label>
<select name="priority" class="w-full bg-gray-800 border border-gray-700 rounded-md px-3 py-2 text-sm text-white">
<% priorities.forEach(function(p) { %>
<option value="<%= p %>" <%= p === 'medium' ? 'selected' : '' %>><%= p %></option>
<% }) %>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">Category</label>
<select name="category" class="w-full bg-gray-800 border border-gray-700 rounded-md px-3 py-2 text-sm text-white">
<% categories.forEach(function(c) { %>
<option value="<%= c %>" <%= c === 'general' ? 'selected' : '' %>><%= c %></option>
<% }) %>
</select>
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1">Screenshots <span class="text-gray-500 text-xs">(optional — up to 6)</span></label>
<!-- accept=image/* + capture hint triggers phone camera roll / camera on mobile -->
<input type="file" name="screenshots" accept="image/*" multiple
class="block w-full text-sm text-gray-300 file:mr-3 file:py-2 file:px-3 file:rounded-md file:border-0 file:bg-cyan-600 file:text-white hover:file:bg-cyan-700">
</div>
<button type="submit"
class="w-full bg-cyan-600 hover:bg-cyan-700 text-white font-semibold py-3 rounded-md text-base transition">
Submit Issue
</button>
</form>
<p class="text-xs text-gray-500 mt-3 text-center">
Submitting as <strong><%= adminUser?.username || 'Trinity Console' %></strong>
</p>
</div>

View File

@@ -108,6 +108,9 @@
<span class="inline-flex items-center justify-center w-5 h-5 text-[10px] font-bold text-white bg-cyan-500 rounded-full"><%= codeQueueCount %></span>
<% } %>
</a>
<a href="/admin/issues" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/issues') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
🐛 Issues
</a>
<a href="/admin/servers" class="block px-4 py-2 rounded-md <%= currentPath.startsWith('/servers') ? 'bg-gray-200 dark:bg-gray-700' : 'hover:bg-gray-100 dark:hover:bg-gray-800' %>">
🖥️ Servers
</a>