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:
46
services/arbiter-3.0/migrations/141_issues.sql
Normal file
46
services/arbiter-3.0/migrations/141_issues.sql
Normal 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);
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
361
services/arbiter-3.0/src/routes/admin/issues.js
Normal file
361
services/arbiter-3.0/src/routes/admin/issues.js
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
54
services/arbiter-3.0/src/services/issueNotifier.js
Normal file
54
services/arbiter-3.0/src/services/issueNotifier.js
Normal 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 };
|
||||
137
services/arbiter-3.0/src/views/admin/issues/detail.ejs
Normal file
137
services/arbiter-3.0/src/views/admin/issues/detail.ejs
Normal 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>
|
||||
122
services/arbiter-3.0/src/views/admin/issues/index.ejs
Normal file
122
services/arbiter-3.0/src/views/admin/issues/index.ejs
Normal 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>
|
||||
63
services/arbiter-3.0/src/views/admin/issues/new.ejs
Normal file
63
services/arbiter-3.0/src/views/admin/issues/new.ejs
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user