Deploy authenticated newsroom SSE updates
This commit is contained in:
parent
696a2e17d6
commit
7bcdb02bba
45
src/db.ts
45
src/db.ts
@ -4,6 +4,7 @@ import { createHash } from "node:crypto";
|
||||
import type { NormalizedItem, SourceDefinition } from "./types.js";
|
||||
import type { ScoredItem } from "./types.js";
|
||||
import { fetchSourceDocument } from "./documents.js";
|
||||
import { publishNewsroomEvent } from "./realtime.js";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
@ -521,7 +522,9 @@ export async function upsertSourceTrustEntry(
|
||||
})]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
const realtimeRow = result.rows[0];
|
||||
publishNewsroomEvent("source_trust.updated", { entry: realtimeRow });
|
||||
return realtimeRow;
|
||||
}
|
||||
|
||||
export async function evaluateSourceTrust(
|
||||
@ -903,7 +906,9 @@ export async function markOpportunity(id: string, status: string) {
|
||||
VALUES($1,'opportunity.status_changed','opportunity',$2,$3::jsonb)`,
|
||||
[randomUUID(), id, JSON.stringify({ status })]
|
||||
);
|
||||
return result.rows[0];
|
||||
const realtimeRow = result.rows[0];
|
||||
publishNewsroomEvent("opportunity.updated", { opportunity: realtimeRow });
|
||||
return realtimeRow;
|
||||
}
|
||||
|
||||
export async function getEvidence(id: string) {
|
||||
@ -991,7 +996,9 @@ export async function createStoryThread(
|
||||
[randomUUID(), id, JSON.stringify({ actor, title, question })]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
const realtimeRow = result.rows[0];
|
||||
publishNewsroomEvent("story_thread.created", { story: realtimeRow });
|
||||
return realtimeRow;
|
||||
}
|
||||
|
||||
export async function listStoryThreads(args: {
|
||||
@ -1336,7 +1343,9 @@ export async function addStoryNode(
|
||||
[randomUUID(), id, JSON.stringify({ threadId, nodeType, actor })]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
const realtimeRow = result.rows[0];
|
||||
publishNewsroomEvent("story_node.added", { node: realtimeRow });
|
||||
return realtimeRow;
|
||||
}
|
||||
|
||||
export async function setStoryNodeReviewState(
|
||||
@ -1363,7 +1372,9 @@ export async function setStoryNodeReviewState(
|
||||
VALUES($1,'story_node.review_state_changed','story_node',$2,$3::jsonb)`,
|
||||
[randomUUID(), nodeId, JSON.stringify({ threadId, reviewState, actor })]
|
||||
);
|
||||
return result.rows[0];
|
||||
const realtimeRow = result.rows[0];
|
||||
publishNewsroomEvent("story_node.updated", { node: realtimeRow });
|
||||
return realtimeRow;
|
||||
}
|
||||
|
||||
export async function addStoryEdge(
|
||||
@ -1407,7 +1418,9 @@ export async function addStoryEdge(
|
||||
);
|
||||
|
||||
await pool.query("UPDATE story_threads SET updated_at=NOW() WHERE id=$1", [threadId]);
|
||||
return result.rows[0];
|
||||
const realtimeRow = result.rows[0];
|
||||
publishNewsroomEvent("story_edge.updated", { edge: realtimeRow });
|
||||
return realtimeRow;
|
||||
}
|
||||
|
||||
export async function completeCounterEvidenceCheck(
|
||||
@ -1452,7 +1465,9 @@ export async function completeCounterEvidenceCheck(
|
||||
);
|
||||
|
||||
await pool.query("UPDATE story_threads SET updated_at=NOW() WHERE id=$1", [threadId]);
|
||||
return result.rows[0];
|
||||
const realtimeRow = result.rows[0];
|
||||
publishNewsroomEvent("story_thread.research_check", { check: realtimeRow });
|
||||
return realtimeRow;
|
||||
}
|
||||
|
||||
export async function markStoryThreadStatus(
|
||||
@ -1489,7 +1504,9 @@ export async function markStoryThreadStatus(
|
||||
[randomUUID(), id, JSON.stringify({ status, actorLabel })]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
const realtimeRow = result.rows[0];
|
||||
publishNewsroomEvent("story_thread.status_changed", { story: realtimeRow });
|
||||
return realtimeRow;
|
||||
}
|
||||
|
||||
|
||||
@ -1835,7 +1852,9 @@ export async function postAgentMessage(
|
||||
})]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
const realtimeRow = result.rows[0];
|
||||
publishNewsroomEvent("agent_message.created", { message: realtimeRow });
|
||||
return realtimeRow;
|
||||
}
|
||||
|
||||
export async function listAgentMessages(args: {
|
||||
@ -1896,7 +1915,9 @@ export async function acknowledgeAgentMessage(id: string, acknowledgedBy: string
|
||||
VALUES($1,'agent_message.acknowledged','agent_message',$2,$3::jsonb)`,
|
||||
[randomUUID(), id, JSON.stringify({ acknowledgedBy })]
|
||||
);
|
||||
return result.rows[0];
|
||||
const realtimeRow = result.rows[0];
|
||||
publishNewsroomEvent("agent_message.acknowledged", { message: realtimeRow });
|
||||
return realtimeRow;
|
||||
}
|
||||
|
||||
export async function postSystemMessageOnce(args: {
|
||||
@ -1930,5 +1951,7 @@ export async function postSystemMessageOnce(args: {
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0] ?? null;
|
||||
const message = result.rows[0] ?? null;
|
||||
if (message) publishNewsroomEvent("agent_message.created", { message });
|
||||
return message;
|
||||
}
|
||||
|
||||
@ -33,6 +33,7 @@ import {
|
||||
import { handleMcp } from "./mcp.js";
|
||||
import { SOURCES } from "./sources.js";
|
||||
import { syncAll } from "./sync.js";
|
||||
import { serveNewsroomEvents } from "./realtime.js";
|
||||
import { renderAboutNewsroom, renderDashboard, renderOpportunities, renderOpportunity, renderStories, renderStory, renderTrustPolicy } from "./web.js";
|
||||
|
||||
const app = express();
|
||||
@ -181,6 +182,8 @@ app.get("/auth/login", beginWebLogin);
|
||||
app.get("/auth/callback", finishWebLogin);
|
||||
app.get("/auth/logout", logoutWeb);
|
||||
|
||||
app.get("/api/events", requireWebAuth, serveNewsroomEvents);
|
||||
|
||||
app.post("/api/mcp", requireMcpAuth, handleMcp);
|
||||
app.get("/api/mcp", requireMcpAuth, (_req, res) => {
|
||||
res.status(405).setHeader("Allow", "POST").json({
|
||||
|
||||
82
src/realtime.ts
Normal file
82
src/realtime.ts
Normal file
@ -0,0 +1,82 @@
|
||||
import type { Request, Response } from "express";
|
||||
|
||||
export interface NewsroomRealtimeEvent {
|
||||
id: string;
|
||||
type: string;
|
||||
at: string;
|
||||
data: unknown;
|
||||
}
|
||||
|
||||
type Listener = (event: NewsroomRealtimeEvent) => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
const replayBuffer: NewsroomRealtimeEvent[] = [];
|
||||
let sequence = 0;
|
||||
const MAX_REPLAY_EVENTS = 100;
|
||||
const HEARTBEAT_MS = 20_000;
|
||||
|
||||
function writeEvent(res: Response, event: NewsroomRealtimeEvent) {
|
||||
res.write(`id: ${event.id}\n`);
|
||||
res.write("event: newsroom\n");
|
||||
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
||||
}
|
||||
|
||||
export function publishNewsroomEvent(type: string, data: unknown = {}) {
|
||||
const event: NewsroomRealtimeEvent = {
|
||||
id: `${Date.now()}-${++sequence}`,
|
||||
type,
|
||||
at: new Date().toISOString(),
|
||||
data
|
||||
};
|
||||
|
||||
replayBuffer.push(event);
|
||||
if (replayBuffer.length > MAX_REPLAY_EVENTS) replayBuffer.shift();
|
||||
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch {
|
||||
// A disconnected client is cleaned up by the request close handler.
|
||||
}
|
||||
}
|
||||
return event;
|
||||
}
|
||||
|
||||
export function serveNewsroomEvents(req: Request, res: Response) {
|
||||
res.status(200);
|
||||
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
||||
res.setHeader("Cache-Control", "no-cache, no-transform");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("X-Accel-Buffering", "no");
|
||||
res.flushHeaders?.();
|
||||
res.write("retry: 3000\n\n");
|
||||
|
||||
const lastEventId = String(req.headers["last-event-id"] ?? "").trim();
|
||||
if (lastEventId) {
|
||||
const index = replayBuffer.findIndex((event) => event.id === lastEventId);
|
||||
if (index >= 0) {
|
||||
for (const event of replayBuffer.slice(index + 1)) writeEvent(res, event);
|
||||
}
|
||||
}
|
||||
|
||||
writeEvent(res, {
|
||||
id: `connected-${Date.now()}`,
|
||||
type: "stream.connected",
|
||||
at: new Date().toISOString(),
|
||||
data: { replayCapacity: MAX_REPLAY_EVENTS }
|
||||
});
|
||||
|
||||
const listener: Listener = (event) => writeEvent(res, event);
|
||||
listeners.add(listener);
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
res.write(`: heartbeat ${Date.now()}\n\n`);
|
||||
}, HEARTBEAT_MS);
|
||||
|
||||
const close = () => {
|
||||
clearInterval(heartbeat);
|
||||
listeners.delete(listener);
|
||||
};
|
||||
req.on("close", close);
|
||||
req.on("aborted", close);
|
||||
}
|
||||
92
src/web.ts
92
src/web.ts
@ -153,7 +153,7 @@ button,input,select{font:inherit}
|
||||
.callout.success{border-left-color:var(--green)}
|
||||
.source-row{display:block}
|
||||
.dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-right:6px;background:#94a3b8}
|
||||
.dot.ok{background:#22a06b}.dot.bad{background:#d04f4f}.dot.warn{background:#d78a1f}.attention-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-bottom:20px}.attention-card{display:block;text-decoration:none;background:#fff;border:1px solid var(--line);border-radius:12px;padding:14px 15px;box-shadow:var(--shadow);transition:transform .12s ease,border-color .12s ease}.attention-card:hover{transform:translateY(-1px);border-color:#c8d0db}.attention-top{display:flex;align-items:center;justify-content:space-between;gap:8px}.attention-label{font-size:.78rem;color:var(--muted);font-weight:700}.attention-value{font-size:1.45rem;font-weight:800;letter-spacing:-.03em;margin-top:4px}.attention-copy{font-size:.78rem;color:var(--muted);margin-top:3px}.role-pill{display:inline-flex;align-items:center;border:1px solid rgba(255,255,255,.13);border-radius:999px;padding:2px 7px;font-size:.68rem;color:#cbd5e1;margin-left:5px;text-transform:capitalize}.thread-jumps{position:sticky;top:64px;z-index:35;display:flex;gap:6px;flex-wrap:wrap;margin:15px 0 2px;padding:7px;background:rgba(243,245,247,.92);backdrop-filter:blur(8px);border:1px solid rgba(214,220,228,.9);border-radius:11px}.thread-jumps a{font-size:.77rem;text-decoration:none;color:#536075;padding:6px 9px;border-radius:7px;font-weight:650}.thread-jumps a:hover{background:#fff;color:#172033}.progress-line{height:6px;background:#edf0f4;border-radius:999px;overflow:hidden;margin-top:10px}.progress-line span{display:block;height:100%;background:#16805c;border-radius:999px}.trust-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-bottom:14px}.trust-stat{padding:13px 14px;background:#f8fafc;border:1px solid #e6eaf0;border-radius:10px}.trust-stat strong{display:block;font-size:1.15rem;letter-spacing:-.025em}.trust-stat span{font-size:.76rem;color:var(--muted)}
|
||||
.dot.ok{background:#22a06b}.dot.bad{background:#d04f4f}.dot.warn{background:#d78a1f}.attention-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-bottom:20px}.attention-card{display:block;text-decoration:none;background:#fff;border:1px solid var(--line);border-radius:12px;padding:14px 15px;box-shadow:var(--shadow);transition:transform .12s ease,border-color .12s ease}.attention-card:hover{transform:translateY(-1px);border-color:#c8d0db}.attention-top{display:flex;align-items:center;justify-content:space-between;gap:8px}.attention-label{font-size:.78rem;color:var(--muted);font-weight:700}.attention-value{font-size:1.45rem;font-weight:800;letter-spacing:-.03em;margin-top:4px}.attention-copy{font-size:.78rem;color:var(--muted);margin-top:3px}.role-pill{display:inline-flex;align-items:center;border:1px solid rgba(255,255,255,.13);border-radius:999px;padding:2px 7px;font-size:.68rem;color:#cbd5e1;margin-left:5px;text-transform:capitalize}.live-pill{display:inline-flex;align-items:center;gap:5px;border:1px solid rgba(255,255,255,.13);border-radius:999px;padding:3px 7px;font-size:.66rem;color:#9ee7c5;text-transform:uppercase;letter-spacing:.05em}.live-pill::before{content:"";width:6px;height:6px;border-radius:50%;background:currentColor}.live-pill.reconnecting{color:#f0c57a}.thread-jumps{position:sticky;top:64px;z-index:35;display:flex;gap:6px;flex-wrap:wrap;margin:15px 0 2px;padding:7px;background:rgba(243,245,247,.92);backdrop-filter:blur(8px);border:1px solid rgba(214,220,228,.9);border-radius:11px}.thread-jumps a{font-size:.77rem;text-decoration:none;color:#536075;padding:6px 9px;border-radius:7px;font-weight:650}.thread-jumps a:hover{background:#fff;color:#172033}.progress-line{height:6px;background:#edf0f4;border-radius:999px;overflow:hidden;margin-top:10px}.progress-line span{display:block;height:100%;background:#16805c;border-radius:999px}.trust-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-bottom:14px}.trust-stat{padding:13px 14px;background:#f8fafc;border:1px solid #e6eaf0;border-radius:10px}.trust-stat strong{display:block;font-size:1.15rem;letter-spacing:-.025em}.trust-stat span{font-size:.76rem;color:var(--muted)}
|
||||
.thread-hero{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:22px;align-items:start}
|
||||
.thread-title{font-size:clamp(1.75rem,3vw,2.55rem);letter-spacing:-.045em;line-height:1.08;margin:5px 0 9px}
|
||||
.thread-question{font-size:1.03rem;color:#536075;max-width:920px}
|
||||
@ -230,21 +230,93 @@ button,input,select{font:inherit}
|
||||
@media(max-width:700px){.attention-grid,.trust-summary{grid-template-columns:1fr 1fr}.thread-jumps{top:60px;overflow:auto;flex-wrap:nowrap}.thread-jumps a{white-space:nowrap}.trust-form{grid-template-columns:1fr}.topbar-inner{padding:0 14px;gap:13px}.brand span:last-child{display:none}.user-name,.signout{display:none}.page{padding:20px 14px 40px}.page-head{display:block}.page-head>.actions{margin-top:14px}.grid-metrics{grid-template-columns:repeat(2,minmax(0,1fr))}.list-row{grid-template-columns:1fr}.right-meta{text-align:left}.graph-toolbar{align-items:flex-start;flex-direction:column}.graph-scroll{display:none}.mobile-only{display:block}.assertion-grid,.evidence-grid{grid-template-columns:1fr}.relationship{grid-template-columns:1fr}.relationship-mid{text-align:left}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<body data-media-role="${esc(args.role ?? "viewer")}">
|
||||
<div class="topbar"><div class="topbar-inner">
|
||||
<a class="brand" href="/"><span class="brand-mark">MI</span><span>Media Intelligence</span></a>
|
||||
<nav class="nav">${nav.map(([key, href, label]) => `<a href="${href}" class="${args.active === key ? "active" : ""}">${label}</a>`).join("")}</nav>
|
||||
<div class="userbox"><div class="avatar">${esc(initials(args.identity))}</div><div><div class="user-name">${esc(args.identity)}<span class="role-pill">${esc(args.role ?? "viewer")}</span></div><a class="signout" href="/auth/logout">Sign out</a></div></div>
|
||||
<div class="userbox"><span id="live-state" class="live-pill">live</span><div class="avatar">${esc(initials(args.identity))}</div><div><div class="user-name">${esc(args.identity)}<span class="role-pill">${esc(args.role ?? "viewer")}</span></div><a class="signout" href="/auth/logout">Sign out</a></div></div>
|
||||
</div></div>
|
||||
<main class="page">${args.body}</main>
|
||||
<div id="toast" class="toast">Link copied</div>
|
||||
<script>
|
||||
function copyCurrentLink(){
|
||||
navigator.clipboard.writeText(window.location.href).then(function(){
|
||||
var t=document.getElementById("toast"); if(!t)return;
|
||||
t.classList.add("show"); setTimeout(function(){t.classList.remove("show")},1800);
|
||||
});
|
||||
function showToast(message){
|
||||
var t=document.getElementById("toast"); if(!t)return;
|
||||
t.textContent=message; t.classList.add("show");
|
||||
clearTimeout(window.__newsroomToastTimer);
|
||||
window.__newsroomToastTimer=setTimeout(function(){t.classList.remove("show")},1800);
|
||||
}
|
||||
function copyCurrentLink(){
|
||||
navigator.clipboard.writeText(window.location.href).then(function(){ showToast("Link copied"); });
|
||||
}
|
||||
function setLiveState(label,reconnecting){
|
||||
var el=document.getElementById("live-state"); if(!el)return;
|
||||
el.textContent=label; el.classList.toggle("reconnecting",Boolean(reconnecting));
|
||||
}
|
||||
function setUnreadCount(delta){
|
||||
var el=document.getElementById("unread-message-count"); if(!el)return;
|
||||
var current=Number((el.textContent||"0").replace(/[^0-9]/g,""))||0;
|
||||
el.textContent=String(Math.max(0,current+delta));
|
||||
}
|
||||
function liveTime(value){
|
||||
try{return new Intl.DateTimeFormat("en-NZ",{timeZone:"Pacific/Auckland",dateStyle:"medium",timeStyle:"short"}).format(new Date(value));}
|
||||
catch(_){return String(value||"");}
|
||||
}
|
||||
function removeAgentMessage(id){
|
||||
var row=document.querySelector('[data-message-id="'+CSS.escape(String(id))+'"]');
|
||||
if(row){row.remove();setUnreadCount(-1);}
|
||||
var list=document.getElementById("agent-activity-list");
|
||||
if(list && !list.querySelector("[data-message-id]")){
|
||||
var empty=document.createElement("div");empty.className="empty";empty.dataset.agentEmpty="true";
|
||||
empty.textContent="No unread Agent Activity.";list.appendChild(empty);
|
||||
}
|
||||
}
|
||||
function insertAgentMessage(message){
|
||||
var list=document.getElementById("agent-activity-list"); if(!list||!message||!message.id)return;
|
||||
if(list.querySelector('[data-message-id="'+CSS.escape(String(message.id))+'"]'))return;
|
||||
var empty=list.querySelector("[data-agent-empty]");if(empty)empty.remove();
|
||||
|
||||
var row=document.createElement("div");row.className="message-row";row.dataset.messageId=String(message.id);
|
||||
var inner=document.createElement("div");inner.className="list-row";
|
||||
var left=document.createElement("div");
|
||||
var title=document.createElement("div");title.className="row-title";title.textContent=message.title||"Agent update";
|
||||
var body=document.createElement("div");body.className="row-sub";body.textContent=message.body||"";
|
||||
var meta=document.createElement("div");meta.className="row-meta";
|
||||
var badge=document.createElement("span");
|
||||
var severity=String(message.severity||"info");
|
||||
badge.className="badge "+(severity==="warning"?"badge-amber":severity==="error"?"badge-red":"badge-blue");
|
||||
badge.textContent=severity;meta.appendChild(badge);
|
||||
var actor=document.createElement("span");actor.className="meta";
|
||||
actor.append(document.createTextNode(String(message.actor_name||"Agent")+" via "));
|
||||
var client=document.createElement("span");client.className="mono";client.textContent=String(message.actor_client_id||"media-intelligence");
|
||||
actor.appendChild(client);meta.appendChild(actor);
|
||||
left.append(title,body,meta);
|
||||
|
||||
var role=document.body.dataset.mediaRole||"viewer";
|
||||
if(role==="editor"||role==="admin"){
|
||||
var form=document.createElement("form");form.method="post";form.action="/messages/"+encodeURIComponent(String(message.id));form.action+="/ack";form.style.marginTop="9px";
|
||||
var button=document.createElement("button");button.className="btn btn-sm";button.type="submit";button.textContent="Acknowledge";
|
||||
form.appendChild(button);left.appendChild(form);
|
||||
}
|
||||
var right=document.createElement("div");right.className="right-meta meta";right.textContent=liveTime(message.created_at);
|
||||
inner.append(left,right);row.appendChild(inner);list.prepend(row);setUnreadCount(1);
|
||||
while(list.querySelectorAll("[data-message-id]").length>10){
|
||||
var rows=list.querySelectorAll("[data-message-id]");rows[rows.length-1].remove();
|
||||
}
|
||||
}
|
||||
(function connectNewsroomEvents(){
|
||||
if(!window.EventSource){setLiveState("static",true);return;}
|
||||
var source=new EventSource("/api/events");
|
||||
source.onopen=function(){setLiveState("live",false);};
|
||||
source.onerror=function(){setLiveState("reconnecting",true);};
|
||||
source.addEventListener("newsroom",function(event){
|
||||
try{
|
||||
var payload=JSON.parse(event.data||"{}");
|
||||
if(payload.type==="agent_message.created") insertAgentMessage(payload.data&&payload.data.message);
|
||||
if(payload.type==="agent_message.acknowledged") removeAgentMessage(payload.data&&payload.data.message&&payload.data.message.id);
|
||||
window.dispatchEvent(new CustomEvent("newsroom:sse",{detail:payload}));
|
||||
}catch(_){}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body></html>`;
|
||||
}
|
||||
@ -458,7 +530,7 @@ export function renderDashboard(args:{identity:string;role?:string;counts:AnyRow
|
||||
<div class="panel metric"><div class="metric-value">${compact(c.opportunities)}</div><div class="metric-label">active opportunities</div></div>
|
||||
<div class="panel metric"><div class="metric-value">${compact(c.new_opportunities)}</div><div class="metric-label">new to triage</div></div>
|
||||
<div class="panel metric"><div class="metric-value">${compact(c.story_threads)}</div><div class="metric-label">open Story Threads</div></div>
|
||||
<div class="panel metric"><div class="metric-value">${compact(c.unread_messages)}</div><div class="metric-label">agent updates</div></div>
|
||||
<div class="panel metric"><div class="metric-value" id="unread-message-count">${compact(c.unread_messages)}</div><div class="metric-label">agent updates</div></div>
|
||||
</div>
|
||||
<div class="section grid grid-2">
|
||||
<div><div class="section-head"><div><h2>Research needing attention</h2><p>Threads that still have sourcing or evidence gaps.</p></div><a class="btn btn-sm" href="/stories">All threads</a></div>
|
||||
@ -470,7 +542,7 @@ export function renderDashboard(args:{identity:string;role?:string;counts:AnyRow
|
||||
</div>
|
||||
</div>
|
||||
<section class="section" id="agent-activity"><div class="section-head"><div><h2>Agent Activity</h2><p>Operational findings and source-monitor updates.</p></div></div>
|
||||
<div class="panel">${args.messages.length?args.messages.map((m:AnyRow)=>`<div class="message-row"><div class="list-row"><div><div class="row-title">${esc(m.title)}</div><div class="row-sub">${esc(m.body)}</div><div class="row-meta">${badge(m.severity,m.severity==="warning"?"amber":m.severity==="error"?"red":"blue")}<span class="meta">${esc(m.actor_name)} via <span class="mono">${esc(m.actor_client_id)}</span></span></div>${canRole(args.role,"editor")?`<form method="post" action="/messages/${encodeURIComponent(m.id)}/ack" style="margin-top:9px"><button class="btn btn-sm" type="submit">Acknowledge</button></form>`:""}</div><div class="right-meta meta">${esc(nz(m.created_at))}</div></div></div>`).join(""):`<div class="empty">No unread Agent Activity.</div>`}</div></section>`;
|
||||
<div class="panel" id="agent-activity-list">${args.messages.length?args.messages.map((m:AnyRow)=>`<div class="message-row" data-message-id="${esc(m.id)}"><div class="list-row"><div><div class="row-title">${esc(m.title)}</div><div class="row-sub">${esc(m.body)}</div><div class="row-meta">${badge(m.severity,m.severity==="warning"?"amber":m.severity==="error"?"red":"blue")}<span class="meta">${esc(m.actor_name)} via <span class="mono">${esc(m.actor_client_id)}</span></span></div>${canRole(args.role,"editor")?`<form method="post" action="/messages/${encodeURIComponent(m.id)}/ack" style="margin-top:9px"><button class="btn btn-sm" type="submit">Acknowledge</button></form>`:""}</div><div class="right-meta meta">${esc(nz(m.created_at))}</div></div></div>`).join(""):`<div class="empty" data-agent-empty="true">No unread Agent Activity.</div>`}</div></section>`;
|
||||
return shell({title:"Desk",active:"dashboard",identity:args.identity,role:args.role,body});
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user