ui: harden streaming page rendering
This commit is contained in:
@@ -2,20 +2,18 @@
|
|||||||
|
|
||||||
## Current
|
## Current
|
||||||
|
|
||||||
### `feat/distributed-mcp-session-store`
|
### `feat/frontend-xss-hardening`
|
||||||
|
|
||||||
Status: in_progress
|
Status: in_progress
|
||||||
|
|
||||||
DoD:
|
DoD:
|
||||||
- transport sessions are stored behind a shared store abstraction
|
- API-provided values are not rendered into `innerHTML` on user-facing admin pages
|
||||||
- mcp-server no longer depends directly on process-local session storage implementation
|
- error strings from API responses are rendered through text nodes
|
||||||
- in-memory session store remains available for tests and local fallback
|
- streaming admin pages use DOM construction instead of HTML string concatenation for dynamic cards
|
||||||
- production mcp-server uses a Postgres-backed transport session store
|
|
||||||
- expired transport sessions are evicted on read and no longer leak indefinitely
|
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- `feat/runtime-rate-limiting-and-backpressure`
|
- `feat/distributed-mcp-session-store`
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
|
|||||||
+90
-39
@@ -33,8 +33,93 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
return new Date(value).toLocaleString();
|
return new Date(value).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function element(tag, className, text) {
|
||||||
|
var node = document.createElement(tag);
|
||||||
|
if (className) node.className = className;
|
||||||
|
if (text !== undefined && text !== null) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
function renderEmpty(title, body) {
|
function renderEmpty(title, body) {
|
||||||
list.innerHTML = '<div class="empty-state"><div class="empty-state-title">' + title + '</div><div class="empty-state-text">' + body + '</div></div>';
|
list.innerHTML = '';
|
||||||
|
var empty = element('div', 'empty-state');
|
||||||
|
empty.appendChild(element('div', 'empty-state-title', title));
|
||||||
|
empty.appendChild(element('div', 'empty-state-text', body));
|
||||||
|
list.appendChild(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMetaItem(parent, label, value) {
|
||||||
|
var item = element('div', 'resource-meta-item');
|
||||||
|
item.appendChild(element('div', 'resource-meta-label', label));
|
||||||
|
item.appendChild(element('div', 'resource-meta-value', value));
|
||||||
|
parent.appendChild(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendDetailBlock(parent, title, value, dataResultFor) {
|
||||||
|
var block = element('div', 'resource-detail-block');
|
||||||
|
block.appendChild(element('div', 'resource-detail-title', title));
|
||||||
|
var pre = element('pre', 'resource-detail-pre', value);
|
||||||
|
if (dataResultFor) pre.setAttribute('data-result-for', dataResultFor);
|
||||||
|
block.appendChild(pre);
|
||||||
|
parent.appendChild(block);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderJobCard(item) {
|
||||||
|
var open = state.openId === item.id;
|
||||||
|
var card = element('div', 'resource-card');
|
||||||
|
card.setAttribute('data-job-id', item.id);
|
||||||
|
|
||||||
|
var header = element('div', 'resource-card-header');
|
||||||
|
var headerMain = element('div');
|
||||||
|
headerMain.appendChild(element('div', 'resource-card-title', item.id));
|
||||||
|
headerMain.appendChild(element('div', 'resource-card-subtitle', tKey('async_jobs.operation', { operation: item.operation_id })));
|
||||||
|
|
||||||
|
var pillRow = element('div', 'resource-pill-row');
|
||||||
|
pillRow.appendChild(
|
||||||
|
element('span', 'resource-status-pill ' + String(item.status || '').toLowerCase(), tKey('async_jobs.status.' + item.status))
|
||||||
|
);
|
||||||
|
headerMain.appendChild(pillRow);
|
||||||
|
header.appendChild(headerMain);
|
||||||
|
|
||||||
|
var actions = element('div', 'resource-card-actions');
|
||||||
|
if (item.status === 'created' || item.status === 'running') {
|
||||||
|
var cancelButton = element('button', 'btn-secondary', tKey('async_jobs.cancel'));
|
||||||
|
cancelButton.setAttribute('data-action', 'cancel');
|
||||||
|
cancelButton.setAttribute('data-job-id', item.id);
|
||||||
|
actions.appendChild(cancelButton);
|
||||||
|
}
|
||||||
|
var toggleButton = element(
|
||||||
|
'button',
|
||||||
|
'btn-secondary',
|
||||||
|
open ? tKey('async_jobs.hide_details') : tKey('async_jobs.show_details')
|
||||||
|
);
|
||||||
|
toggleButton.setAttribute('data-action', 'toggle');
|
||||||
|
toggleButton.setAttribute('data-job-id', item.id);
|
||||||
|
actions.appendChild(toggleButton);
|
||||||
|
header.appendChild(actions);
|
||||||
|
card.appendChild(header);
|
||||||
|
|
||||||
|
var metaGrid = element('div', 'resource-meta-grid');
|
||||||
|
appendMetaItem(metaGrid, tKey('async_jobs.meta.created'), formatDateTime(item.created_at));
|
||||||
|
appendMetaItem(metaGrid, tKey('async_jobs.meta.updated'), formatDateTime(item.updated_at));
|
||||||
|
appendMetaItem(metaGrid, tKey('async_jobs.meta.finished'), formatDateTime(item.finished_at));
|
||||||
|
appendMetaItem(metaGrid, tKey('async_jobs.meta.expires'), formatDateTime(item.expires_at));
|
||||||
|
card.appendChild(metaGrid);
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
appendDetailBlock(card, tKey('async_jobs.progress'), formatJson(item.progress));
|
||||||
|
appendDetailBlock(card, tKey('async_jobs.error_preview'), formatJson(item.error));
|
||||||
|
if (
|
||||||
|
item.status === 'completed' ||
|
||||||
|
item.status === 'failed' ||
|
||||||
|
item.status === 'cancelled' ||
|
||||||
|
item.status === 'expired'
|
||||||
|
) {
|
||||||
|
appendDetailBlock(card, tKey('async_jobs.result'), tKey('async_jobs.result_loading'), item.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
@@ -55,44 +140,10 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
list.innerHTML = state.items.map(function(item) {
|
list.innerHTML = '';
|
||||||
var open = state.openId === item.id;
|
state.items.forEach(function(item) {
|
||||||
var statusLabel = tKey('async_jobs.status.' + item.status);
|
list.appendChild(renderJobCard(item));
|
||||||
return [
|
});
|
||||||
'<div class="resource-card" data-job-id="' + item.id + '">',
|
|
||||||
' <div class="resource-card-header">',
|
|
||||||
' <div>',
|
|
||||||
' <div class="resource-card-title">' + item.id + '</div>',
|
|
||||||
' <div class="resource-card-subtitle">' + tKey('async_jobs.operation', { operation: item.operation_id }) + '</div>',
|
|
||||||
' <div class="resource-pill-row">',
|
|
||||||
' <span class="resource-status-pill ' + String(item.status || '').toLowerCase() + '">' + statusLabel + '</span>',
|
|
||||||
' </div>',
|
|
||||||
' </div>',
|
|
||||||
' <div class="resource-card-actions">',
|
|
||||||
item.status === 'created' || item.status === 'running'
|
|
||||||
? ' <button class="btn-secondary" data-action="cancel" data-job-id="' + item.id + '">' + tKey('async_jobs.cancel') + '</button>'
|
|
||||||
: '',
|
|
||||||
' <button class="btn-secondary" data-action="toggle" data-job-id="' + item.id + '">' + (open ? tKey('async_jobs.hide_details') : tKey('async_jobs.show_details')) + '</button>',
|
|
||||||
' </div>',
|
|
||||||
' </div>',
|
|
||||||
' <div class="resource-meta-grid">',
|
|
||||||
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('async_jobs.meta.created') + '</div><div class="resource-meta-value">' + formatDateTime(item.created_at) + '</div></div>',
|
|
||||||
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('async_jobs.meta.updated') + '</div><div class="resource-meta-value">' + formatDateTime(item.updated_at) + '</div></div>',
|
|
||||||
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('async_jobs.meta.finished') + '</div><div class="resource-meta-value">' + formatDateTime(item.finished_at) + '</div></div>',
|
|
||||||
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('async_jobs.meta.expires') + '</div><div class="resource-meta-value">' + formatDateTime(item.expires_at) + '</div></div>',
|
|
||||||
' </div>',
|
|
||||||
open
|
|
||||||
? ' <div class="resource-detail-block"><div class="resource-detail-title">' + tKey('async_jobs.progress') + '</div><pre class="resource-detail-pre">' + formatJson(item.progress) + '</pre></div>'
|
|
||||||
: '',
|
|
||||||
open
|
|
||||||
? ' <div class="resource-detail-block"><div class="resource-detail-title">' + tKey('async_jobs.error_preview') + '</div><pre class="resource-detail-pre">' + formatJson(item.error) + '</pre></div>'
|
|
||||||
: '',
|
|
||||||
open && (item.status === 'completed' || item.status === 'failed' || item.status === 'cancelled' || item.status === 'expired')
|
|
||||||
? ' <div class="resource-detail-block"><div class="resource-detail-title">' + tKey('async_jobs.result') + '</div><pre class="resource-detail-pre" data-result-for="' + item.id + '">' + tKey('async_jobs.result_loading') + '</pre></div>'
|
|
||||||
: '',
|
|
||||||
'</div>'
|
|
||||||
].join('');
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
list.querySelectorAll('[data-action="toggle"]').forEach(function(button) {
|
list.querySelectorAll('[data-action="toggle"]').forEach(function(button) {
|
||||||
button.addEventListener('click', function () {
|
button.addEventListener('click', function () {
|
||||||
|
|||||||
@@ -34,8 +34,83 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
return new Date(value).toLocaleString();
|
return new Date(value).toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function element(tag, className, text) {
|
||||||
|
var node = document.createElement(tag);
|
||||||
|
if (className) node.className = className;
|
||||||
|
if (text !== undefined && text !== null) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
function renderEmpty(title, body) {
|
function renderEmpty(title, body) {
|
||||||
list.innerHTML = '<div class="empty-state"><div class="empty-state-title">' + title + '</div><div class="empty-state-text">' + body + '</div></div>';
|
list.innerHTML = '';
|
||||||
|
var empty = element('div', 'empty-state');
|
||||||
|
empty.appendChild(element('div', 'empty-state-title', title));
|
||||||
|
empty.appendChild(element('div', 'empty-state-text', body));
|
||||||
|
list.appendChild(empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMetaItem(parent, label, value) {
|
||||||
|
var item = element('div', 'resource-meta-item');
|
||||||
|
item.appendChild(element('div', 'resource-meta-label', label));
|
||||||
|
item.appendChild(element('div', 'resource-meta-value', value));
|
||||||
|
parent.appendChild(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendDetailBlock(parent, title, value) {
|
||||||
|
var block = element('div', 'resource-detail-block');
|
||||||
|
block.appendChild(element('div', 'resource-detail-title', title));
|
||||||
|
block.appendChild(element('pre', 'resource-detail-pre', formatJson(value)));
|
||||||
|
parent.appendChild(block);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSessionCard(item) {
|
||||||
|
var open = state.openId === item.id;
|
||||||
|
var card = element('div', 'resource-card');
|
||||||
|
card.setAttribute('data-session-id', item.id);
|
||||||
|
|
||||||
|
var header = element('div', 'resource-card-header');
|
||||||
|
var headerMain = element('div');
|
||||||
|
headerMain.appendChild(element('div', 'resource-card-title', item.id));
|
||||||
|
headerMain.appendChild(element('div', 'resource-card-subtitle', tKey('stream_sessions.operation', { operation: item.operation_id })));
|
||||||
|
|
||||||
|
var pillRow = element('div', 'resource-pill-row');
|
||||||
|
var statusPill = element('span', 'resource-status-pill ' + String(item.status || '').toLowerCase(), tKey('stream_sessions.status.' + item.status));
|
||||||
|
pillRow.appendChild(statusPill);
|
||||||
|
pillRow.appendChild(element('span', 'resource-status-pill', item.mode));
|
||||||
|
pillRow.appendChild(element('span', 'resource-status-pill', item.protocol));
|
||||||
|
headerMain.appendChild(pillRow);
|
||||||
|
header.appendChild(headerMain);
|
||||||
|
|
||||||
|
var actions = element('div', 'resource-card-actions');
|
||||||
|
if (item.status === 'created' || item.status === 'running') {
|
||||||
|
var stopButton = element('button', 'btn-secondary', tKey('stream_sessions.stop'));
|
||||||
|
stopButton.setAttribute('data-action', 'stop');
|
||||||
|
stopButton.setAttribute('data-session-id', item.id);
|
||||||
|
actions.appendChild(stopButton);
|
||||||
|
}
|
||||||
|
var toggleButton = element(
|
||||||
|
'button',
|
||||||
|
'btn-secondary',
|
||||||
|
open ? tKey('stream_sessions.hide_details') : tKey('stream_sessions.show_details')
|
||||||
|
);
|
||||||
|
toggleButton.setAttribute('data-action', 'toggle');
|
||||||
|
toggleButton.setAttribute('data-session-id', item.id);
|
||||||
|
actions.appendChild(toggleButton);
|
||||||
|
header.appendChild(actions);
|
||||||
|
card.appendChild(header);
|
||||||
|
|
||||||
|
var metaGrid = element('div', 'resource-meta-grid');
|
||||||
|
appendMetaItem(metaGrid, tKey('stream_sessions.meta.created'), formatDateTime(item.created_at));
|
||||||
|
appendMetaItem(metaGrid, tKey('stream_sessions.meta.last_poll'), formatDateTime(item.last_poll_at));
|
||||||
|
appendMetaItem(metaGrid, tKey('stream_sessions.meta.expires'), formatDateTime(item.expires_at));
|
||||||
|
appendMetaItem(metaGrid, tKey('stream_sessions.meta.agent'), item.agent_id || '—');
|
||||||
|
card.appendChild(metaGrid);
|
||||||
|
|
||||||
|
if (open) {
|
||||||
|
appendDetailBlock(card, tKey('stream_sessions.cursor'), item.cursor);
|
||||||
|
}
|
||||||
|
|
||||||
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
@@ -56,40 +131,10 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
list.innerHTML = state.items.map(function(item) {
|
list.innerHTML = '';
|
||||||
var open = state.openId === item.id;
|
state.items.forEach(function(item) {
|
||||||
var statusLabel = tKey('stream_sessions.status.' + item.status);
|
list.appendChild(renderSessionCard(item));
|
||||||
return [
|
});
|
||||||
'<div class="resource-card" data-session-id="' + item.id + '">',
|
|
||||||
' <div class="resource-card-header">',
|
|
||||||
' <div>',
|
|
||||||
' <div class="resource-card-title">' + item.id + '</div>',
|
|
||||||
' <div class="resource-card-subtitle">' + tKey('stream_sessions.operation', { operation: item.operation_id }) + '</div>',
|
|
||||||
' <div class="resource-pill-row">',
|
|
||||||
' <span class="resource-status-pill ' + String(item.status || '').toLowerCase() + '">' + statusLabel + '</span>',
|
|
||||||
' <span class="resource-status-pill">' + item.mode + '</span>',
|
|
||||||
' <span class="resource-status-pill">' + item.protocol + '</span>',
|
|
||||||
' </div>',
|
|
||||||
' </div>',
|
|
||||||
' <div class="resource-card-actions">',
|
|
||||||
item.status === 'created' || item.status === 'running'
|
|
||||||
? ' <button class="btn-secondary" data-action="stop" data-session-id="' + item.id + '">' + tKey('stream_sessions.stop') + '</button>'
|
|
||||||
: '',
|
|
||||||
' <button class="btn-secondary" data-action="toggle" data-session-id="' + item.id + '">' + (open ? tKey('stream_sessions.hide_details') : tKey('stream_sessions.show_details')) + '</button>',
|
|
||||||
' </div>',
|
|
||||||
' </div>',
|
|
||||||
' <div class="resource-meta-grid">',
|
|
||||||
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('stream_sessions.meta.created') + '</div><div class="resource-meta-value">' + formatDateTime(item.created_at) + '</div></div>',
|
|
||||||
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('stream_sessions.meta.last_poll') + '</div><div class="resource-meta-value">' + formatDateTime(item.last_poll_at) + '</div></div>',
|
|
||||||
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('stream_sessions.meta.expires') + '</div><div class="resource-meta-value">' + formatDateTime(item.expires_at) + '</div></div>',
|
|
||||||
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('stream_sessions.meta.agent') + '</div><div class="resource-meta-value">' + (item.agent_id || '—') + '</div></div>',
|
|
||||||
' </div>',
|
|
||||||
open
|
|
||||||
? ' <div class="resource-detail-block"><div class="resource-detail-title">' + tKey('stream_sessions.cursor') + '</div><pre class="resource-detail-pre">' + formatJson(item.cursor) + '</pre></div>'
|
|
||||||
: '',
|
|
||||||
'</div>'
|
|
||||||
].join('');
|
|
||||||
}).join('');
|
|
||||||
|
|
||||||
list.querySelectorAll('[data-action="toggle"]').forEach(function(button) {
|
list.querySelectorAll('[data-action="toggle"]').forEach(function(button) {
|
||||||
button.addEventListener('click', function () {
|
button.addEventListener('click', function () {
|
||||||
|
|||||||
Reference in New Issue
Block a user