Merge pull request 'Fahne mit externen Tickets klickbar: Modal listet die Tickets (#117)' (#118) from fds2610-bot/monitor:main into main

Reviewed-on: https://codeberg.org/fds2610/monitor/pulls/118
This commit is contained in:
fds2610 2026-10-10 11:23:53 +02:00
commit e5b58ed93c
6 changed files with 159 additions and 34 deletions

View file

@ -4,6 +4,9 @@ Format in Anlehnung an [Keep a Changelog](https://keepachangelog.com/de/1.0.0/).
## Unreleased
- Klick auf die rote Fahne am PO oeffnet ein Modal, das alle offenen Tickets externer User
wie bei den Kanban-Spalten listet (Repo, Nummer mit Link, Titel, Typ, Beschreibung), jeweils
mit ihrer Spalte, neuestes zuerst (#117).
- `update.sh` erzeugt die Container `api` und `sync` bei jedem Update neu. Bisher lief der
`sync`-Prozess nach einem Update mit dem alten Code weiter, schrieb deshalb keinen
Ticket-Ersteller und die rote Fahne am PO (#113) erschien nicht (#115).

View file

@ -31,7 +31,9 @@ die dazugehörigen Klärungen in [Detailfragen.md](Detailfragen.md) und
den `MONITOR_ADMIN_API_KEY` ab und merken ihn sich im Browser.
- Liegen offene Tickets (Issues oder PRs, alles außer „Done") vor, die nicht von `fds2610`
oder `fds2610-bot` angelegt wurden, steht auf dem Tisch des PO eine kleine rote Fahne mit
deren Anzahl (Issue #113). Den Ersteller liest der Codeberg-Sync mit (`author`).
deren Anzahl (Issue #113). Den Ersteller liest der Codeberg-Sync mit (`author`). Ein Klick
auf die Fahne listet diese Tickets in einem Modal wie bei den Board-Spalten, zusätzlich mit
ihrer jeweiligen Spalte (Issue #117).
- Klick auf das Zahnrad neben der Überschrift „Systeme" öffnet die Verwaltung der per Ping
überwachten Hosts (Issue #70): Server hinzufügen, umbenennen und entfernen, ohne sich auf
den Rechner mit `check_fds.sh` einzuloggen — der holt seine `hosts.conf` bei jedem Lauf von

View file

@ -382,6 +382,16 @@ header.office-header h1 {
font-weight: 700;
}
/* Issue #117: die Fahne oeffnet die Liste der externen Tickets. */
.external-ticket-flag-hit {
fill: transparent;
pointer-events: all;
}
.external-ticket-flag:hover .external-ticket-flag-cloth {
fill: #b91c1c;
}
.person-head {
fill: #f3d9b1;
stroke: #d8b98a;
@ -795,6 +805,15 @@ header.office-header h1 {
gap: 8px;
}
/* Spalte eines Tickets im Modal der externen Tickets (Issue #117). */
.office-modal-column {
font-size: 0.75rem;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid #cbd5e1;
color: #475569;
}
.office-modal-badge {
font-size: 0.75rem;
padding: 2px 8px;

View file

@ -19,6 +19,7 @@ import {
aggregateTicketsByColumn,
groupTicketsByColumn,
countExternalOpenTickets,
buildExternalTicketModalContent,
buildTicketModalContent,
mapEventForDisplay,
formatEventLineText,
@ -791,17 +792,15 @@ function renderDesk({ deskIndex, agent }) {
* PO gilt unabhaengig von der API-Erreichbarkeit als anwesend, Issue #19/#97).
* @param {() => void} onPoClick - oeffnet das Projekte-Modal (Issue #27).
* @param {number} externalTicketCount - offene Tickets externer User (Issue #113).
* @param {() => void} [onExternalTicketsClick] - oeffnet die Liste dieser Tickets (Issue #117).
*/
function renderDesks(desks, poAgent, onPoClick, externalTicketCount = 0) {
function renderDesks(desks, poAgent, onPoClick, externalTicketCount = 0, onExternalTicketsClick = () => {}) {
const g = el("g", { class: "desks" });
desks.forEach((desk) => g.appendChild(renderDesk(desk)));
const poDeskGroup = renderDeskAt(PO_DESK, poAgent, PO_DESK.w);
poDeskGroup.classList.add("po-desk", "clickable");
poDeskGroup.setAttribute("aria-label", "Projekte des Codeberg-Sync verwalten");
poDeskGroup.appendChild(renderPoConfigHint());
if (externalTicketCount > 0) {
poDeskGroup.appendChild(renderExternalTicketFlag(externalTicketCount));
}
// Eine <g> selbst faengt keine Klicks - nur ihre gefuellten Kinder. Zwischen Tischplatte,
// Stuhl und Namensschild liegen Luecken, in denen ein Klick "auf den PO" beim Boden landete
// (Fehlerbericht zu Issue #27). Diese unsichtbare Flaeche deckt den ganzen Platz ab, analog
@ -815,6 +814,10 @@ function renderDesks(desks, poAgent, onPoClick, externalTicketCount = 0) {
class: "po-desk-hit",
})
);
// Issue #117: die Fahne liegt ueber der Trefferflaeche, sonst kaeme ihr eigener Klick nie an.
if (externalTicketCount > 0) {
poDeskGroup.appendChild(renderExternalTicketFlag(externalTicketCount, onExternalTicketsClick));
}
poDeskGroup.addEventListener("click", onPoClick);
g.appendChild(poDeskGroup);
return g;
@ -841,9 +844,10 @@ function renderPoConfigHint() {
// Issue #113: kleine rote Fahne auf dem PO-Tisch, sobald offene Tickets von externen Usern
// (nicht fds2610/fds2610-bot) vorliegen - die Zahl steht im Fahnentuch. Sitzt zwischen
// Bildschirm (x 20-80) und Blumen-Deko (ab x ~122), damit sie mit keinem von beiden kollidiert.
function renderExternalTicketFlag(count) {
// Issue #117: Klick auf die Fahne listet diese Tickets in einem Modal wie bei den Board-Spalten.
function renderExternalTicketFlag(count, onClick) {
const poleX = 92;
const g = el("g", { class: "external-ticket-flag" });
const g = el("g", { class: "external-ticket-flag clickable" });
const title = document.createElementNS(SVG_NS, "title");
title.textContent = `${count} offene${count === 1 ? "s" : ""} Ticket${count === 1 ? "" : "s"} externer User`;
@ -860,6 +864,15 @@ function renderExternalTicketFlag(count) {
"text-anchor": "middle",
})
);
// Groessere unsichtbare Trefferflaeche (Stange + Tuch), analog zu .board-reload-hint-hit.
g.appendChild(el("rect", { x: poleX - 6, y: 0, width: 34, height: 50, class: "external-ticket-flag-hit" }));
g.addEventListener("click", (event) => {
// Der ganze PO-Platz oeffnet die Projektverwaltung (Issue #27) - ohne das Stoppen laege
// sie sofort ueber der Ticketliste.
event.stopPropagation();
onClick();
});
return g;
}
@ -1470,7 +1483,7 @@ function triggerDoorVisit(visit, onArrived) {
function renderTicketModalBody(body, content) {
if (content.items.length === 0) {
body.appendChild(emptyMessage("Keine Tickets in dieser Spalte."));
body.appendChild(emptyMessage(content.emptyText || "Keine Tickets in dieser Spalte."));
return;
}
const list = document.createElement("ul");
@ -1501,6 +1514,13 @@ function renderTicketModalBody(body, content) {
const titleEl = document.createElement("strong");
titleEl.textContent = item.title;
heading.appendChild(titleEl);
// Issue #117: im Modal der externen Tickets stammen die Eintraege aus mehreren Spalten.
if (item.column) {
const columnEl = document.createElement("span");
columnEl.className = "office-modal-column";
columnEl.textContent = item.column;
heading.appendChild(columnEl);
}
if (item.type) {
const badge = document.createElement("span");
badge.className = "office-modal-badge";
@ -2460,7 +2480,8 @@ export function renderOfficeScene(container, data = {}) {
deskLayout,
poDeskAgent,
openRepoConfigModal,
countExternalOpenTickets(tickets)
countExternalOpenTickets(tickets),
() => openModal(buildExternalTicketModalContent(tickets), renderTicketModalBody)
);
const existingDesks = svg.querySelector(".desks");
if (existingDesks) {

View file

@ -217,13 +217,13 @@ export function groupTicketsByColumn(tickets = []) {
export const INTERNAL_TICKET_AUTHORS = ["fds2610", "fds2610-bot"];
/**
* Zaehlt offene Tickets (Issues und PRs, alles ausser "done") externer User. Tickets ohne
* author (per API angelegt oder noch nicht vom Sync nachgetragen) zaehlen nicht mit.
* Offene Tickets (Issues und PRs, alles ausser "done") externer User. Tickets ohne author
* (per API angelegt oder noch nicht vom Sync nachgetragen) zaehlen nicht mit.
*
* @param {Array<{column_name: string, author?: string|null}>} tickets
* @returns {number}
* @returns {Array<object>}
*/
export function countExternalOpenTickets(tickets = []) {
export function filterExternalOpenTickets(tickets = []) {
const internal = INTERNAL_TICKET_AUTHORS.map((login) => login.toLowerCase());
return tickets.filter(
(ticket) =>
@ -231,7 +231,17 @@ export function countExternalOpenTickets(tickets = []) {
typeof ticket.author === "string" &&
ticket.author !== "" &&
!internal.includes(ticket.author.toLowerCase())
).length;
);
}
/**
* Zaehlt offene Tickets externer User (siehe filterExternalOpenTickets()).
*
* @param {Array<{column_name: string, author?: string|null}>} tickets
* @returns {number}
*/
export function countExternalOpenTickets(tickets = []) {
return filterExternalOpenTickets(tickets).length;
}
// Issue #88: Beschreibungen im Ticket-Modal werden auf diese Zeilenzahl gekuerzt.
@ -273,26 +283,64 @@ export function buildTicketModalContent(columnKey, tickets = [], opts = {}) {
return {
title: column ? column.label : columnKey,
items: newestFirst.map((ticket) => ({
id: ticket.id,
title: ticket.title,
description: truncateDescriptionLines(ticket.description),
type: ticket.type || null,
color: ticket.color || null,
// Issue #88: Repo und Ticket-Nummer stehen in der Ueberschrift, die Nummer ist der Link.
repo:
ticket.source === "codeberg" && ticket.external_ref
? ticket.repo || `${codebergOwner}/${codebergRepo}`
: null,
number: ticket.source === "codeberg" && ticket.external_ref ? String(ticket.external_ref) : null,
isPullRequest: isPullRequest(ticket),
link:
ticket.source === "codeberg" && ticket.external_ref
? `https://codeberg.org/${ticket.repo || `${codebergOwner}/${codebergRepo}`}/${
isPullRequest(ticket) ? "pulls" : "issues"
}/${ticket.external_ref}`
: null,
})),
items: newestFirst.map((ticket) => buildTicketModalItem(ticket, codebergOwner, codebergRepo)),
};
}
/**
* Issue #117: Inhaltsmodell fuer das Modal hinter der roten Fahne am PO - alle offenen
* Tickets externer User (filterExternalOpenTickets()), aufgebaut wie ein Board-Spalten-Modal.
* Weil die Tickets aus mehreren Spalten stammen, traegt jeder Eintrag zusaetzlich seine
* Spalte (column); sortiert wird spaltenuebergreifend nach created_at, neuestes zuerst
* (wie im Spalten-Modal, Issue #102).
*
* @param {Array<object>} tickets - rohe Ticketliste (GET /api/v1/tickets)
* @param {{codebergOwner?: string, codebergRepo?: string}} [opts]
*/
export function buildExternalTicketModalContent(tickets = [], opts = {}) {
const { codebergOwner = "fds2610", codebergRepo = "monitor" } = opts;
const newestFirst = filterExternalOpenTickets(tickets)
.map((ticket, index) => ({ ticket, index }))
.sort(
(a, b) =>
String(b.ticket.created_at || "").localeCompare(String(a.ticket.created_at || "")) ||
b.index - a.index
)
.map(({ ticket }) => ticket);
return {
title: "Externe Tickets",
emptyText: "Keine offenen Tickets externer User.",
items: newestFirst.map((ticket) => {
const column = BOARD_COLUMNS.find((c) => c.key === ticket.column_name);
return {
...buildTicketModalItem(ticket, codebergOwner, codebergRepo),
column: column ? column.label : ticket.column_name,
};
}),
};
}
function buildTicketModalItem(ticket, codebergOwner, codebergRepo) {
return {
id: ticket.id,
title: ticket.title,
description: truncateDescriptionLines(ticket.description),
type: ticket.type || null,
color: ticket.color || null,
// Issue #88: Repo und Ticket-Nummer stehen in der Ueberschrift, die Nummer ist der Link.
repo:
ticket.source === "codeberg" && ticket.external_ref
? ticket.repo || `${codebergOwner}/${codebergRepo}`
: null,
number: ticket.source === "codeberg" && ticket.external_ref ? String(ticket.external_ref) : null,
isPullRequest: isPullRequest(ticket),
link:
ticket.source === "codeberg" && ticket.external_ref
? `https://codeberg.org/${ticket.repo || `${codebergOwner}/${codebergRepo}`}/${
isPullRequest(ticket) ? "pulls" : "issues"
}/${ticket.external_ref}`
: null,
};
}

View file

@ -57,6 +57,8 @@ import {
resolveReloadIntervalSeconds,
nextCountdownSeconds,
countExternalOpenTickets,
filterExternalOpenTickets,
buildExternalTicketModalContent,
INTERNAL_TICKET_AUTHORS,
} from "../../html/js/office-scene.js";
@ -1006,3 +1008,33 @@ test("countExternalOpenTickets zaehlt nur offene Tickets externer User (Issue #1
assert.equal(countExternalOpenTickets(), 0);
assert.deepEqual(INTERNAL_TICKET_AUTHORS, ["fds2610", "fds2610-bot"]);
});
test("buildExternalTicketModalContent listet offene Tickets externer User wie ein Spalten-Modal (Issue #117)", () => {
const tickets = [
{ id: 1, column_name: "backlog", author: "jemand", title: "Alt", source: "codeberg", external_ref: "3", repo: "fds2610/monitor", created_at: "2026-10-01T10:00:00+02:00", type: "bug", color: "#ff0000" },
{ id: 2, column_name: "fertig", author: "extern-pr", title: "PR", source: "codeberg", external_ref: "9", repo: "fds2610/andere", is_pull_request: 1, created_at: "2026-10-05T10:00:00+02:00" },
{ id: 3, column_name: "in_arbeit", author: "fds2610", title: "Intern", created_at: "2026-10-06T10:00:00+02:00" },
{ id: 4, column_name: "done", author: "jemand", title: "Erledigt", created_at: "2026-10-07T10:00:00+02:00" },
{ id: 5, column_name: "qa", author: "jemand", title: "Neu", source: "api", created_at: "2026-10-08T10:00:00+02:00" },
];
assert.deepEqual(filterExternalOpenTickets(tickets).map((t) => t.id), [1, 2, 5]);
const content = buildExternalTicketModalContent(tickets);
assert.equal(content.title, "Externe Tickets");
assert.equal(typeof content.emptyText, "string");
assert.deepEqual(content.items.map((item) => item.id), [5, 2, 1]);
assert.deepEqual(content.items.map((item) => item.column), ["QA", "Fertig", "Backlog"]);
const [api, pr, issue] = content.items;
assert.equal(api.link, null);
assert.equal(api.number, null);
assert.equal(pr.isPullRequest, true);
assert.equal(pr.link, "https://codeberg.org/fds2610/andere/pulls/9");
assert.equal(issue.link, "https://codeberg.org/fds2610/monitor/issues/3");
assert.equal(issue.repo, "fds2610/monitor");
assert.equal(issue.type, "bug");
assert.equal(issue.color, "#ff0000");
assert.deepEqual(buildExternalTicketModalContent().items, []);
});