feat: version display + issues board

Version: CalVer + git hash shown in top-right corner of main page.
Built via Docker ARG BUILD_VERSION at build time. Served via /api-version.

Issues Board: shared notepad window for tracking issues with plugin,
overlord, nav files, macros. Stored in openissues.json on server.
- GET/POST/DELETE /issues endpoints
- Draggable window matching Chat/Radar pattern
- Category tags (plugin, overlord, nav, macro, other) with colors
- Add/resolve issues through the UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-09 12:53:06 +02:00
parent c4856dc701
commit 38ca6ead12
5 changed files with 258 additions and 1 deletions

View file

@ -29,6 +29,10 @@ COPY Dockerfile /Dockerfile
## Expose the application port to host ## Expose the application port to host
EXPOSE 8765 EXPOSE 8765
## Build version (CalVer + git hash, set via --build-arg)
ARG BUILD_VERSION=dev
ENV APP_VERSION=$BUILD_VERSION
## Default environment variables for application configuration ## Default environment variables for application configuration
ENV DATABASE_URL=postgresql://postgres:password@db:5432/dereth \ ENV DATABASE_URL=postgresql://postgres:password@db:5432/dereth \
DB_MAX_SIZE_MB=2048 \ DB_MAX_SIZE_MB=2048 \

59
main.py
View file

@ -10,6 +10,7 @@ from datetime import datetime, timedelta, timezone
import json import json
import logging import logging
import os import os
import uuid
import sys import sys
import time import time
from typing import Dict, List, Any from typing import Dict, List, Any
@ -1326,6 +1327,64 @@ async def get_recent_activity():
raise HTTPException(status_code=500, detail="Internal server error") raise HTTPException(status_code=500, detail="Internal server error")
# ─── Version endpoint ────────────────────────────────────────────
@app.get("/api-version")
async def get_version():
"""Return the application version (CalVer + git hash, set at build time)."""
return {"version": os.environ.get("APP_VERSION", "dev")}
# ─── Issues board endpoints ──────────────────────────────────────
ISSUES_FILE = Path("openissues.json")
def _load_issues():
if ISSUES_FILE.exists():
try:
return json.loads(ISSUES_FILE.read_text())
except (json.JSONDecodeError, IOError):
pass
return []
def _save_issues(issues):
ISSUES_FILE.write_text(json.dumps(issues, indent=2))
@app.get("/issues")
async def get_issues():
"""Return all open issues."""
return {"issues": _load_issues()}
@app.post("/issues")
async def add_issue(issue: dict):
"""Add a new issue."""
issues = _load_issues()
new_issue = {
"id": uuid.uuid4().hex[:8],
"title": issue.get("title", "").strip(),
"description": issue.get("description", "").strip(),
"category": issue.get("category", "other"),
"author": issue.get("author", "Anonymous").strip(),
"created": datetime.utcnow().isoformat(),
}
if not new_issue["title"]:
raise HTTPException(status_code=400, detail="Title is required")
issues.insert(0, new_issue)
_save_issues(issues)
return new_issue
@app.delete("/issues/{issue_id}")
async def delete_issue(issue_id: str):
"""Resolve (delete) an issue."""
issues = _load_issues()
issues = [i for i in issues if i.get("id") != issue_id]
_save_issues(issues)
return {"status": "ok"}
@app.get("/server-health") @app.get("/server-health")
async def get_server_health(): async def get_server_health():
"""Return current server health status.""" """Return current server health status."""

View file

@ -12,6 +12,9 @@
</head> </head>
<body> <body>
<!-- Version display -->
<div id="versionDisplay" style="position:fixed;top:4px;right:10px;z-index:9999;color:#555;font-size:0.65rem;font-family:monospace;opacity:0.7;"></div>
<!-- Sidebar for active players list and filters --> <!-- Sidebar for active players list and filters -->
<aside id="sidebar"> <aside id="sidebar">
<h2 id="activePlayersHeader">Active Mosswart Enjoyers</h2> <h2 id="activePlayersHeader">Active Mosswart Enjoyers</h2>
@ -94,6 +97,13 @@
👥 Player Dashboard 👥 Player Dashboard
</a> </a>
</div> </div>
<!-- Issues Board link -->
<div class="quest-status-link">
<a href="#" id="issuesBoardBtn" onclick="showIssuesWindow()">
📋 Issues Board
</a>
</div>
<!-- Container for sort and filter controls --> <!-- Container for sort and filter controls -->
<div id="sortButtons" class="sort-buttons"></div> <div id="sortButtons" class="sort-buttons"></div>

View file

@ -4117,3 +4117,126 @@ function compassDir(angleDeg) {
const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; const dirs = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
return dirs[Math.round(a / 45) % 8]; return dirs[Math.round(a / 45) % 8];
} }
// ─── Version Display ────────────────────────────────────────────
fetch('/api-version').then(r => r.json()).then(d => {
const el = document.getElementById('versionDisplay');
if (el) el.textContent = 'v' + d.version;
}).catch(() => {});
// ─── Issues Board ───────────────────────────────────────────────
const ISSUE_CATEGORIES = {
plugin: { label: 'Plugin', color: '#8844cc' },
overlord: { label: 'Overlord', color: '#4488cc' },
nav: { label: 'Nav', color: '#44aa44' },
macro: { label: 'Macro', color: '#cc8844' },
other: { label: 'Other', color: '#888888' },
};
function showIssuesWindow() {
const { win, content, isNew } = createWindow(
'issuesWindow', 'Issues Board', 'issues-window'
);
if (!isNew) {
refreshIssuesList(win);
return;
}
win.style.width = '500px';
win.style.height = '450px';
// Issues list container
const listDiv = document.createElement('div');
listDiv.className = 'issues-list';
content.appendChild(listDiv);
win._issuesList = listDiv;
// Add issue form
const form = document.createElement('div');
form.className = 'issues-form';
form.innerHTML = `
<div style="display:flex;gap:4px;margin-bottom:4px;">
<input type="text" id="issueTitle" placeholder="Issue title..." style="flex:1;padding:3px 6px;font-size:0.8rem;border:1px solid #555;background:#2a2a2a;color:#ddd;">
<select id="issueCategory" style="padding:3px;font-size:0.75rem;border:1px solid #555;background:#2a2a2a;color:#ddd;">
<option value="plugin">Plugin</option>
<option value="overlord">Overlord</option>
<option value="nav">Nav</option>
<option value="macro">Macro</option>
<option value="other">Other</option>
</select>
</div>
<div style="display:flex;gap:4px;">
<textarea id="issueDescription" placeholder="Description (optional)..." rows="2" style="flex:1;padding:3px 6px;font-size:0.75rem;border:1px solid #555;background:#2a2a2a;color:#ddd;resize:vertical;"></textarea>
<button id="issueAddBtn" style="padding:4px 12px;background:#4a80c0;color:#fff;border:1px solid #336699;cursor:pointer;font-size:0.75rem;align-self:flex-end;">Add</button>
</div>
`;
content.appendChild(form);
// Add button handler
form.querySelector('#issueAddBtn').addEventListener('click', async () => {
const title = document.getElementById('issueTitle').value.trim();
const desc = document.getElementById('issueDescription').value.trim();
const cat = document.getElementById('issueCategory').value;
if (!title) return;
await fetch('/issues', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, description: desc, category: cat, author: 'User' })
});
document.getElementById('issueTitle').value = '';
document.getElementById('issueDescription').value = '';
refreshIssuesList(win);
});
refreshIssuesList(win);
}
async function refreshIssuesList(win) {
const listDiv = win._issuesList;
if (!listDiv) return;
try {
const resp = await fetch('/issues');
const data = await resp.json();
const issues = data.issues || [];
if (issues.length === 0) {
listDiv.innerHTML = '<div style="padding:10px;color:#888;text-align:center;font-size:0.8rem;">No open issues</div>';
return;
}
listDiv.innerHTML = '';
issues.forEach(issue => {
const cat = ISSUE_CATEGORIES[issue.category] || ISSUE_CATEGORIES.other;
const date = issue.created ? new Date(issue.created).toLocaleDateString('sv-SE') : '';
const row = document.createElement('div');
row.className = 'issue-row';
row.innerHTML = `
<div style="display:flex;align-items:center;gap:6px;">
<span class="issue-category" style="background:${cat.color}">${cat.label}</span>
<strong style="font-size:0.8rem;color:#ddd;">${issue.title}</strong>
<span style="margin-left:auto;color:#666;font-size:0.65rem;">${date}</span>
</div>
${issue.description ? `<div style="color:#aaa;font-size:0.75rem;margin-top:2px;padding-left:4px;">${issue.description}</div>` : ''}
`;
const resolveBtn = document.createElement('button');
resolveBtn.textContent = '✓ Resolve';
resolveBtn.className = 'issue-resolve-btn';
resolveBtn.addEventListener('click', async () => {
await fetch(`/issues/${issue.id}`, { method: 'DELETE' });
refreshIssuesList(win);
});
row.querySelector('div').appendChild(resolveBtn);
listDiv.appendChild(row);
});
} catch (err) {
listDiv.innerHTML = '<div style="padding:10px;color:#c44;font-size:0.8rem;">Failed to load issues</div>';
}
}

View file

@ -525,7 +525,7 @@ body {
margin-top: 4px; margin-top: 4px;
} }
.chat-window, .stats-window, .inventory-window, .character-window, .radar-window { .chat-window, .stats-window, .inventory-window, .character-window, .radar-window, .issues-window {
position: absolute; position: absolute;
top: 10px; top: 10px;
/* position window to start just right of the sidebar */ /* position window to start just right of the sidebar */
@ -2643,3 +2643,64 @@ table.ts-allegiance td:first-child {
.radar-btn:hover { .radar-btn:hover {
background: #664499; background: #664499;
} }
/* ─── Issues Board ─── */
.issues-window {
width: 500px;
height: 450px;
}
.issues-window .window-content {
display: flex;
flex-direction: column;
overflow: hidden;
}
.issues-list {
flex: 1;
overflow-y: auto;
min-height: 0;
padding: 4px;
}
.issue-row {
padding: 6px 8px;
border-bottom: 1px solid #333;
}
.issue-row:hover {
background: #1a1a2a;
}
.issue-category {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
color: #fff;
font-size: 0.65rem;
font-weight: bold;
text-transform: uppercase;
}
.issue-resolve-btn {
padding: 1px 6px;
font-size: 0.65rem;
background: transparent;
color: #4a4;
border: 1px solid #4a4;
cursor: pointer;
border-radius: 3px;
margin-left: 8px;
}
.issue-resolve-btn:hover {
background: #4a4;
color: #fff;
}
.issues-form {
padding: 6px 8px;
border-top: 1px solid #444;
background: #1a1a1a;
}