Skip to content

Commit 104bf2e

Browse files
Redesign MCP dashboard: sleek grayscale notebook view (#781)
Completely redesigns the `packages/mcp` dashboard from the old Tokyo Night palette to a flat, square, grayscale look. The old design had a glaring white card behind every rich output (DataFrames, plots) sitting on a dark page, plus a colorful blue/green/purple palette that read as busy. This swaps in a layered neutral grayscale where status is conveyed by brightness rather than hue, with a single reserved muted red for errors. What changed: - `dashboard.py`: CSS-variable grayscale palette, no gradients/glow/rounded corners/animation. Running jobs get a filled light status chip and a white left rail; errors get the one red. Rich notebook output now renders on a dark inset instead of a white box, so tables and plots blend into the page. Sticky top bar with a live running/total counter. - `view/__init__.py`: recolors the polars DataFrame palette to grayscale so tables match the dashboard (dtypes shown by lightness, not color). Validated by serving the real aiohttp app over a populated SQLite store and screenshotting the rendered page with playwright (running/done/error/cancelled jobs, a DataFrame, a matplotlib plot, and live resources). Generated with Claude Opus 4.8.
1 parent 536e64b commit 104bf2e

2 files changed

Lines changed: 123 additions & 62 deletions

File tree

packages/mcp/ix_notebook_mcp/dashboard.py

Lines changed: 111 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -16,70 +16,130 @@
1616
from .config import Config
1717

1818
_PAGE = """<!doctype html>
19-
<html><head><meta charset="utf-8"><title>ix-mcp</title>
19+
<html lang="en"><head><meta charset="utf-8">
20+
<meta name="viewport" content="width=device-width,initial-scale=1">
21+
<title>ix · executions</title>
2022
<style>
21-
body{background:#1a1b26;color:#c0caf5;font:13px/1.5 ui-monospace,Menlo,monospace;margin:0;padding:16px}
22-
h1{font-size:14px;color:#7aa2f7;margin:0 0 12px}
23-
.job{border:1px solid #2a2e42;border-radius:6px;margin:0 0 10px;padding:10px}
24-
.job.running{border-color:#9ece6a}
25-
.hdr{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap}
26-
.id{color:#7dcfff}.name{color:#bb9af7}.dur{color:#565f89;margin-left:auto}
27-
.st{padding:0 6px;border-radius:4px;font-size:11px}
28-
.running .st{background:#9ece6a;color:#1a1b26}.done .st{background:#2a2e42}
29-
.error .st{background:#f7768e;color:#1a1b26}.cancelled .st{background:#565f89;color:#1a1b26}
30-
pre{white-space:pre-wrap;word-break:break-word;margin:6px 0 0;color:#a9b1d6;max-height:320px;overflow:auto}
31-
details.code{margin:6px 0 0}
32-
details.code>summary{cursor:pointer;color:#565f89;font-size:11px;list-style:none;user-select:none;display:inline-block}
33-
details.code>summary::-webkit-details-marker{display:none}
34-
details.code>summary::before{content:"▸ code"}
35-
details.code[open]>summary::before{content:"▾ code"}
36-
details.code>pre{color:#565f89;max-height:320px}
37-
.res{color:#9ece6a}
38-
.empty{color:#565f89}
39-
.rich{background:#fff;color:#111;padding:8px;border-radius:4px;margin:6px 0 0;overflow:auto;max-height:460px}
40-
.rich table{border-collapse:collapse;font:12px/1.4 ui-monospace,Menlo,monospace}
41-
.rich th,.rich td{border:1px solid #d0d7de;padding:2px 7px;text-align:right}
42-
.rich th{background:#f6f8fa}
43-
.img{display:block;max-width:100%;margin:6px 0 0;border-radius:4px;background:#fff}
44-
.layout{display:flex;gap:16px;align-items:flex-start}
23+
:root{
24+
--bg:#0b0b0c; --panel:#141416; --panel-2:#1a1a1d; --inset:#101012;
25+
--line:#242427; --line-2:#2e2e33;
26+
--text:#e6e6e6; --dim:#9a9aa0; --muted:#6a6a70; --faint:#45454b;
27+
--active:#f2f2f2; --err:#cf5a5a;
28+
--mono:ui-monospace,"SF Mono",SFMono-Regular,Menlo,"Cascadia Code",monospace;
29+
}
30+
*{box-sizing:border-box}
31+
html{scrollbar-color:var(--line-2) transparent}
32+
body{background:var(--bg);color:var(--text);font:13px/1.55 var(--mono);margin:0;
33+
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
34+
::selection{background:#33333a;color:#fff}
35+
36+
header.top{position:sticky;top:0;z-index:5;display:flex;align-items:center;gap:12px;
37+
padding:11px 18px;background:rgba(11,11,12,.86);backdrop-filter:blur(8px);
38+
border-bottom:1px solid var(--line)}
39+
.brand{font-size:11px;letter-spacing:.22em;text-transform:uppercase;color:var(--dim);font-weight:600}
40+
.brand b{color:var(--text);font-weight:600}
41+
.spacer{flex:1}
42+
.stat{font-size:11px;letter-spacing:.04em;color:var(--muted)}
43+
.stat b{color:var(--text);font-weight:600}
44+
.dot{display:inline-block;width:6px;height:6px;background:var(--active);margin-right:6px;vertical-align:middle}
45+
46+
.wrap{display:flex;gap:18px;align-items:flex-start;padding:18px;max-width:1600px;margin:0 auto}
4547
#main{flex:1 1 auto;min-width:0}
46-
.sidebar{flex:0 0 540px;position:sticky;top:0;max-height:100vh;overflow:auto}
47-
.res-card{border:1px solid #2a2e42;border-radius:6px;margin:0 0 10px;padding:8px}
48-
.res-card.live{border-color:#7dcfff}
49-
.res-card.error{border-color:#f7768e}
50-
.res-hdr{display:flex;gap:8px;align-items:center;margin:0 0 6px}
51-
.res-dot{width:8px;height:8px;border-radius:50%;background:#7dcfff;flex:none}
52-
.res-card.error .res-dot{background:#f7768e}
53-
.res-title{color:#bb9af7;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
54-
.res-kind{color:#565f89;font-size:11px;margin-left:auto;flex:none}
55-
.res-body{overflow:auto;max-height:60vh}
48+
.sidebar{flex:0 0 520px;position:sticky;top:62px;max-height:calc(100vh - 78px);overflow:auto}
49+
@media(max-width:1100px){.wrap{flex-direction:column}.sidebar{flex:none;width:100%;position:static;max-height:none}}
50+
51+
.sec{font-size:10px;letter-spacing:.2em;text-transform:uppercase;color:var(--muted);
52+
margin:0 0 12px;padding-bottom:7px;border-bottom:1px solid var(--line);font-weight:600}
53+
54+
/* execution card */
55+
.job{background:var(--panel);border:1px solid var(--line);border-left:2px solid var(--line-2);
56+
margin:0 0 9px;padding:11px 14px}
57+
.job.running{border-left-color:var(--active)}
58+
.job.error{border-left-color:var(--err)}
59+
.hdr{display:flex;gap:9px;align-items:center;flex-wrap:wrap}
60+
.st{font-size:9px;letter-spacing:.12em;text-transform:uppercase;font-weight:600;
61+
padding:2px 6px;border:1px solid var(--line-2);color:var(--dim)}
62+
.running .st{background:var(--active);color:#0b0b0c;border-color:var(--active)}
63+
.error .st{color:var(--err);border-color:#43282b}
64+
.cancelled .st{color:var(--muted)}
65+
.id{color:var(--muted);font-size:12px}
66+
.name{color:var(--text);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
67+
.dur{margin-left:auto;color:var(--faint);font-size:11px;font-variant-numeric:tabular-nums;flex:none}
68+
69+
details.code{margin:8px 0 0}
70+
details.code>summary{cursor:pointer;color:var(--faint);font-size:10px;letter-spacing:.14em;
71+
text-transform:uppercase;list-style:none;user-select:none;display:inline-block}
72+
details.code>summary::-webkit-details-marker{display:none}
73+
details.code>summary::before{content:"+ source"}
74+
details.code[open]>summary::before{content:"− source"}
75+
details.code>pre{color:var(--dim);background:var(--inset);border:1px solid var(--line);
76+
padding:9px 11px;max-height:340px}
77+
78+
pre{white-space:pre-wrap;word-break:break-word;margin:8px 0 0;color:var(--dim);
79+
max-height:340px;overflow:auto;font-size:12px}
80+
pre.res{color:var(--text)}
81+
pre.error{color:var(--err)}
82+
.empty{color:var(--faint);font-style:italic;font-size:12px;padding:2px 0}
83+
84+
/* rich notebook output: blends into the surface, no white card */
85+
.rich{background:var(--inset);border:1px solid var(--line);padding:10px;margin:8px 0 0;
86+
overflow:auto;max-height:480px;color:var(--text)}
87+
.rich table{border-collapse:collapse;font:12px/1.45 var(--mono);color:var(--text)}
88+
.rich th,.rich td{border-bottom:1px solid var(--line);padding:3px 12px;text-align:right;white-space:nowrap}
89+
.rich th{color:var(--dim);font-weight:600;border-bottom:1px solid var(--line-2)}
90+
.rich tr:hover td{background:var(--panel-2)}
91+
.img{display:block;max-width:100%;margin:8px 0 0;border:1px solid var(--line);background:#fff}
92+
93+
/* resources */
94+
.res-card{background:var(--panel);border:1px solid var(--line);border-left:2px solid var(--line-2);
95+
margin:0 0 9px;padding:9px 11px}
96+
.res-card.live{border-left-color:var(--active)}
97+
.res-card.error{border-left-color:var(--err)}
98+
.res-hdr{display:flex;gap:8px;align-items:center;margin:0 0 7px}
99+
.res-dot{width:6px;height:6px;background:var(--active);flex:none}
100+
.res-card.error .res-dot{background:var(--err)}
101+
.res-title{color:var(--text);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
102+
.res-kind{margin-left:auto;color:var(--faint);font-size:10px;letter-spacing:.1em;
103+
text-transform:uppercase;flex:none}
104+
.res-body{overflow:auto;max-height:62vh}
105+
::-webkit-scrollbar{width:9px;height:9px}
106+
::-webkit-scrollbar-thumb{background:var(--line-2)}
107+
::-webkit-scrollbar-track{background:transparent}
56108
</style></head><body>
57-
<div class="layout">
58-
<div id="main"><h1>ix-mcp executions</h1><div id="jobs"></div></div>
59-
<aside class="sidebar"><h1>resources</h1><div id="resources"></div></aside>
109+
<header class="top">
110+
<span class="brand"><b>ix</b> &middot; mcp</span>
111+
<span class="spacer"></span>
112+
<span class="stat" id="run-stat"></span>
113+
</header>
114+
<div class="wrap">
115+
<div id="main"><div class="sec">executions</div><div id="jobs"></div></div>
116+
<aside class="sidebar"><div class="sec">resources</div><div id="resources"></div></aside>
60117
</div>
61118
<script>
119+
const esc=s=>(s||'').replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
62120
async function tick(){
63121
try{
64122
const r=await fetch('api/jobs');const js=await r.json();
65123
const el=document.getElementById('jobs');
124+
const running=js.filter(j=>j.status==='running').length;
125+
document.getElementById('run-stat').innerHTML=
126+
(running?`<span class="dot"></span><b>${running}</b> running &nbsp;`:'')+`<b>${js.length}</b> total`;
66127
if(!js.length){el.innerHTML='<div class="empty">no executions yet</div>';return;}
67128
js.sort((a,b)=>a.started_at-b.started_at); // oldest at top, newest at bottom
68129
const nearBottom=(window.innerHeight+window.scrollY)>=(document.body.scrollHeight-120);
130+
// Rich outputs render like a notebook cell. The dashboard is read-only over the
131+
// tailnet (the trust boundary); the HTML is the agent's own code output, so it
132+
// is injected as-is rather than re-sanitized.
133+
const rich=o=>{const d=(o&&o.data)||{};
134+
if(d['image/png'])return `<img class="img" src="data:image/png;base64,${d['image/png']}">`;
135+
if(d['image/jpeg'])return `<img class="img" src="data:image/jpeg;base64,${d['image/jpeg']}">`;
136+
if(d['image/svg+xml'])return `<div class="rich">${d['image/svg+xml']}</div>`;
137+
if(d['text/html'])return `<div class="rich">${d['text/html']}</div>`;
138+
if(d['text/markdown'])return `<pre>${esc(d['text/markdown'])}</pre>`;
139+
if(d['text/plain'])return `<pre class="res">${esc(d['text/plain'])}</pre>`;
140+
return '';};
69141
el.innerHTML=js.map(j=>{
70142
const dur=((j.ended_at||Date.now()/1000)-j.started_at).toFixed(1);
71-
const esc=s=>(s||'').replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
72-
// Rich outputs render like a notebook cell. The dashboard is read-only over the
73-
// tailnet (the trust boundary); the HTML is the agent's own code output, so it
74-
// is injected as-is rather than re-sanitized.
75-
const rich=o=>{const d=(o&&o.data)||{};
76-
if(d['image/png'])return `<img class="img" src="data:image/png;base64,${d['image/png']}">`;
77-
if(d['image/jpeg'])return `<img class="img" src="data:image/jpeg;base64,${d['image/jpeg']}">`;
78-
if(d['image/svg+xml'])return `<div class="rich">${d['image/svg+xml']}</div>`;
79-
if(d['text/html'])return `<div class="rich">${d['text/html']}</div>`;
80-
if(d['text/markdown'])return `<pre>${esc(d['text/markdown'])}</pre>`;
81-
if(d['text/plain'])return `<pre class="res">${esc(d['text/plain'])}</pre>`;
82-
return '';};
83143
const richOut=(j.outputs&&j.outputs.length)
84144
? j.outputs.map(rich).join('')
85145
: (j.result?`<pre class="res">${esc(j.result)}</pre>`:'');
@@ -90,7 +150,7 @@
90150
<details class="code"><summary></summary><pre>${esc(j.code)}</pre></details>
91151
${j.output?`<pre>${esc(j.output)}</pre>`:''}
92152
${richOut}
93-
${j.error&&!j.output.includes(j.error)?`<pre class="error">${esc(j.error)}</pre>`:''}
153+
${j.error&&!(j.output||'').includes(j.error)?`<pre class="error">${esc(j.error)}</pre>`:''}
94154
</div>`;
95155
}).join('');
96156
if(nearBottom) window.scrollTo(0, document.body.scrollHeight);
@@ -101,7 +161,6 @@
101161
const r=await fetch('api/resources');const rs=await r.json();
102162
const el=document.getElementById('resources');
103163
if(!rs.length){el.innerHTML='<div class="empty">no live resources</div>';return;}
104-
const esc=s=>(s||'').replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));
105164
// A resource's html is the agent's own live render (a terminal screen, a custom
106165
// widget). The dashboard is read-only over the tailnet (the trust boundary), so
107166
// it is injected as-is, exactly like job output above.

packages/mcp/src/view/view/__init__.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -51,16 +51,18 @@
5151
# (no gradients/animation): the "sexy" comes from typography, spacing, and a
5252
# small dtype-aware color set, not motion.
5353
_PAL = {
54-
"panel": "#1f2335",
55-
"alt": "#1c2030",
56-
"border": "#2a2e42",
57-
"head": "#7aa2f7",
58-
"text": "#c0caf5",
59-
"muted": "#565f89",
60-
"num": "#ff9e64",
61-
"str": "#9ece6a",
62-
"bool": "#bb9af7",
63-
"null": "#414868",
54+
# Grayscale to match the dashboard: dtypes are distinguished by lightness,
55+
# not hue (numbers brightest, then strings, then bools, then null).
56+
"panel": "#141416",
57+
"alt": "#17171a",
58+
"border": "#242427",
59+
"head": "#2e2e33",
60+
"text": "#e6e6e6",
61+
"muted": "#6a6a70",
62+
"num": "#e6e6e6",
63+
"str": "#bcbcc2",
64+
"bool": "#9a9aa0",
65+
"null": "#55555b",
6466
}
6567
_MONO = "ui-monospace,SFMono-Regular,Menlo,monospace"
6668

0 commit comments

Comments
 (0)