← back to Cc Command Center
Claude Code command-center web viewer (stats dashboard + floating session windows)
40f91afb66bfcc92746b961e13019789afb2b7aa · 2026-06-27 12:59:14 -0700 · Steve
Files touched
A .gitignoreA README.mdA build_stats.pyA public/index.htmlA server.py
Diff
commit 40f91afb66bfcc92746b961e13019789afb2b7aa
Author: Steve <steve@designerwallcoverings.com>
Date: Sat Jun 27 12:59:14 2026 -0700
Claude Code command-center web viewer (stats dashboard + floating session windows)
---
.gitignore | 8 +++
README.md | 23 ++++++
build_stats.py | 192 ++++++++++++++++++++++++++++++++++++++++++++++++++
public/index.html | 205 ++++++++++++++++++++++++++++++++++++++++++++++++++++++
server.py | 30 ++++++++
5 files changed, 458 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..455df06
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+public/data/stats.json
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3c706fb
--- /dev/null
+++ b/README.md
@@ -0,0 +1,23 @@
+# Claude Code — Command Center (web viewer)
+
+A web recreation of the Claude Code desktop "command center" shown in
+@LLMJunky's tweet (x.com/LLMJunky/status/2070733200846909717): the
+"What's up next?" usage dashboard + draggable/tileable floating session windows.
+
+Unlike the demo, this runs on **your real data** — it scans
+`~/.claude/projects/**/*.jsonl` and aggregates sessions, messages, tokens,
+active days, streaks, peak hour, favorite model, a 53-week activity heatmap,
+and your recent sessions.
+
+## Run
+```
+python3 build_stats.py # regenerate public/data/stats.json from session logs
+PORT=9774 python3 server.py # serve public/ (Basic Auth: admin / DW2024!)
+```
+Then open http://127.0.0.1:9774 (or omit PORT for a random free port).
+
+- Click a session in **Recents** to pop it out as a floating window.
+- Drag the title bar to move, drag the corner to resize.
+- **Tile / Cascade / Close all** arrange the open windows.
+
+stats.json is gitignored (contains session snippets); rebuild it locally.
diff --git a/build_stats.py b/build_stats.py
new file mode 100644
index 0000000..fe30b25
--- /dev/null
+++ b/build_stats.py
@@ -0,0 +1,192 @@
+#!/usr/bin/env python3
+"""Aggregate real Claude Code usage from ~/.claude/projects/**/*.jsonl into
+public/data/stats.json — powers the command-center web viewer.
+
+Recreates the "What's up next?" dashboard from the LLMJunky tweet using Steve's
+own session data: stat cards, a 53-week activity heatmap, and a recents list
+rendered as draggable floating session windows.
+"""
+import os, json, glob, datetime as dt
+from collections import defaultdict, Counter
+
+HOME = os.path.expanduser("~")
+ROOT = os.path.join(HOME, ".claude", "projects")
+OUT = os.path.join(os.path.dirname(__file__), "public", "data", "stats.json")
+USER = "Steve"
+
+def parse_ts(s):
+ if not s:
+ return None
+ try:
+ return dt.datetime.fromisoformat(s.replace("Z", "+00:00"))
+ except Exception:
+ return None
+
+sessions = [] # one summary dict per session file
+day_counts = Counter() # date -> assistant-message count (activity)
+hour_counts = Counter() # hour -> count
+model_counts = Counter() # model -> assistant messages
+total_tokens = 0
+total_user_msgs = 0
+active_dates = set()
+
+files = glob.glob(os.path.join(ROOT, "**", "*.jsonl"), recursive=True)
+
+for fp in files:
+ title = None
+ first_user = None
+ last_snippet = None
+ first_ts = None
+ last_ts = None
+ user_msgs = 0
+ sess_tokens = 0
+ sess_id = os.path.splitext(os.path.basename(fp))[0]
+ project = os.path.basename(os.path.dirname(fp)).lstrip("-").replace("-", "/")
+ try:
+ with open(fp, "r", errors="ignore") as fh:
+ for line in fh:
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ d = json.loads(line)
+ except Exception:
+ continue
+ t = d.get("type")
+ if t == "ai-title":
+ title = (d.get("title") or d.get("text") or title)
+ elif t == "last-prompt":
+ if not title:
+ title = (d.get("prompt") or d.get("text") or title)
+ ts = parse_ts(d.get("timestamp"))
+ if ts:
+ first_ts = first_ts or ts
+ last_ts = ts
+ msg = d.get("message") if isinstance(d.get("message"), dict) else {}
+ if t == "user" and msg.get("role") == "user":
+ user_msgs += 1
+ content = msg.get("content")
+ if isinstance(content, str) and content.strip() and not first_user:
+ first_user = content.strip()[:240]
+ if isinstance(content, str) and content.strip():
+ last_snippet = content.strip()[:240]
+ elif t == "assistant" and msg.get("role") == "assistant":
+ u = msg.get("usage") or {}
+ tok = (u.get("input_tokens", 0) + u.get("output_tokens", 0)
+ + u.get("cache_creation_input_tokens", 0)
+ + u.get("cache_read_input_tokens", 0))
+ sess_tokens += tok
+ m = msg.get("model")
+ if m:
+ model_counts[m] += 1
+ if ts:
+ day_counts[ts.date().isoformat()] += 1
+ hour_counts[ts.hour] += 1
+ active_dates.add(ts.date())
+ except Exception:
+ continue
+
+ if user_msgs == 0 and sess_tokens == 0:
+ continue # skip empty/metadata-only sessions
+
+ total_tokens += sess_tokens
+ total_user_msgs += user_msgs
+ title = (title or first_user or "Untitled session").strip().replace("\n", " ")[:90]
+ sessions.append({
+ "id": sess_id,
+ "project": project,
+ "title": title,
+ "snippet": (first_user or "")[:240],
+ "last": (last_snippet or first_user or "")[:400],
+ "user_msgs": user_msgs,
+ "tokens": sess_tokens,
+ "ts": last_ts.isoformat() if last_ts else None,
+ "mtime": os.path.getmtime(fp),
+ })
+
+# ---- streaks ----
+def streaks(dates):
+ if not dates:
+ return 0, 0
+ ds = sorted(dates)
+ longest = cur = 1
+ for i in range(1, len(ds)):
+ if (ds[i] - ds[i-1]).days == 1:
+ cur += 1
+ longest = max(longest, cur)
+ elif (ds[i] - ds[i-1]).days == 0:
+ continue
+ else:
+ cur = 1
+ # current streak counts back from the most recent active day
+ today = max(ds)
+ current = 1
+ i = len(ds) - 1
+ while i > 0 and (ds[i] - ds[i-1]).days in (0, 1):
+ if (ds[i] - ds[i-1]).days == 1:
+ current += 1
+ i -= 1
+ return current, longest
+
+current_streak, longest_streak = streaks(active_dates)
+peak_hour = hour_counts.most_common(1)[0][0] if hour_counts else 0
+fav_model = model_counts.most_common(1)[0][0] if model_counts else "—"
+
+MODEL_PRETTY = {
+ "claude-opus-4-8": "Opus 4.8", "claude-opus-4-8[1m]": "Opus 4.8",
+ "claude-sonnet-4-6": "Sonnet 4.6", "claude-haiku-4-5-20251001": "Haiku 4.5",
+ "claude-fable-5": "Fable 5",
+}
+def pretty_model(m):
+ return MODEL_PRETTY.get(m, m.replace("claude-", "").replace("-", " ").title())
+
+def fmt_hour(h):
+ ap = "AM" if h < 12 else "PM"
+ hh = h % 12 or 12
+ return f"{hh} {ap}"
+
+# ---- heatmap: last 53 weeks, GitHub-style ----
+today = dt.date.today()
+start = today - dt.timedelta(days=today.weekday() + 7*52) # align to Monday a year back
+heat = []
+d = start
+while d <= today:
+ heat.append({"date": d.isoformat(), "count": day_counts.get(d.isoformat(), 0)})
+ d += dt.timedelta(days=1)
+
+sessions.sort(key=lambda s: s["mtime"], reverse=True)
+recents = sessions[:24]
+
+# token comparison flavor line (The Little Prince ~ 22k tokens)
+LITTLE_PRINCE = 22000
+mult = round(total_tokens / LITTLE_PRINCE) if total_tokens else 0
+
+stats = {
+ "user": USER,
+ "generated_at": dt.datetime.now().isoformat(timespec="seconds"),
+ "cards": {
+ "sessions": len(sessions),
+ "messages": total_user_msgs,
+ "total_tokens": total_tokens,
+ "active_days": len(active_dates),
+ "current_streak": current_streak,
+ "longest_streak": longest_streak,
+ "peak_hour": fmt_hour(peak_hour),
+ "favorite_model": pretty_model(fav_model),
+ },
+ "flavor": f"You've used ~{mult:,}× more tokens than The Little Prince.",
+ "models": [{"model": pretty_model(m), "count": c} for m, c in model_counts.most_common(8)],
+ "heatmap": heat,
+ "recents": recents,
+}
+
+os.makedirs(os.path.dirname(OUT), exist_ok=True)
+with open(OUT, "w") as fh:
+ json.dump(stats, fh)
+
+c = stats["cards"]
+print(f"Wrote {OUT}")
+print(f" sessions={c['sessions']} messages={c['messages']:,} tokens={c['total_tokens']:,}")
+print(f" active_days={c['active_days']} current_streak={c['current_streak']} longest={c['longest_streak']}")
+print(f" peak_hour={c['peak_hour']} fav_model={c['favorite_model']}")
+print(f" {stats['flavor']}")
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..82b336e
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,205 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Claude Code — Command Center</title>
+<style>
+ :root{
+ --bg:#1c1c1f; --panel:#212125; --panel2:#26262b; --line:#33333a;
+ --ink:#e9e9ec; --mut:#9a9aa3; --mut2:#6f6f78; --accent:#d8674a;
+ --blue:#5b8def; --cell:#2a2a30; --c1:#1d3a63; --c2:#2f5ea8; --c3:#4f86e0; --c4:#7fb0ff;
+ --shadow:0 18px 50px rgba(0,0,0,.55);
+ }
+ *{box-sizing:border-box}
+ html,body{margin:0;height:100%}
+ body{background:var(--bg);color:var(--ink);font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,sans-serif;overflow:hidden}
+ .app{display:grid;grid-template-columns:236px 1fr;height:100vh}
+
+ /* sidebar */
+ .side{background:#19191c;border-right:1px solid var(--line);padding:10px;display:flex;flex-direction:column;gap:4px;overflow:auto}
+ .winbtns{display:flex;gap:7px;padding:6px 6px 12px}
+ .winbtns i{width:12px;height:12px;border-radius:50%;display:inline-block}
+ .winbtns i:nth-child(1){background:#ff5f57}.winbtns i:nth-child(2){background:#febc2e}.winbtns i:nth-child(3){background:#28c840}
+ .tabs{display:flex;gap:4px;background:#212125;border:1px solid var(--line);border-radius:9px;padding:3px;margin-bottom:8px}
+ .tabs button{flex:1;background:none;border:0;color:var(--mut);padding:5px;border-radius:6px;font-size:12.5px;cursor:pointer}
+ .tabs button.on{background:#34343b;color:var(--ink)}
+ .navi{display:flex;align-items:center;gap:9px;padding:7px 9px;border-radius:8px;color:var(--ink);cursor:pointer;font-size:13.5px}
+ .navi:hover{background:#26262b}
+ .navi .ic{width:15px;text-align:center;color:var(--mut)}
+ .seclabel{color:var(--mut2);font-size:11px;letter-spacing:.08em;text-transform:uppercase;margin:14px 9px 4px;font-weight:600}
+ .rec{display:flex;align-items:center;gap:8px;padding:6px 9px;border-radius:7px;color:var(--mut);cursor:pointer;font-size:12.7px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .rec:hover{background:#26262b;color:var(--ink)}
+ .rec .dot{width:6px;height:6px;border-radius:50%;background:#4a4a52;flex:none}
+ .who{margin-top:auto;display:flex;align-items:center;gap:9px;padding:9px;border-top:1px solid var(--line);color:var(--mut);font-size:12.5px}
+ .who .av{width:20px;height:20px;border-radius:6px;background:linear-gradient(135deg,#d8674a,#b23e6e);color:#fff;display:grid;place-items:center;font-size:11px;font-weight:700}
+
+ /* main */
+ .main{position:relative;overflow:hidden}
+ .canvas{position:absolute;inset:0;overflow:auto;padding:34px 40px}
+ h1{font-size:21px;font-weight:600;margin:0 0 22px;display:flex;align-items:center;gap:10px}
+ .star{color:var(--accent)}
+ .dash{max-width:760px}
+ .dashhead{display:flex;justify-content:space-between;align-items:center;margin-bottom:10px}
+ .pillrow{display:flex;gap:6px}
+ .pill{background:#2a2a30;border:1px solid var(--line);color:var(--mut);font-size:12px;padding:3px 10px;border-radius:7px;cursor:pointer}
+ .pill.on{color:var(--ink);background:#34343b}
+ .card{background:var(--panel);border:1px solid var(--line);border-radius:12px;overflow:hidden}
+ .grid{display:grid;grid-template-columns:repeat(4,1fr)}
+ .cell{padding:13px 15px;border-right:1px solid var(--line);border-bottom:1px solid var(--line)}
+ .cell:nth-child(4n){border-right:0}
+ .cell .k{color:var(--mut);font-size:12px;margin-bottom:5px}
+ .cell .v{font-size:19px;font-weight:600;letter-spacing:-.01em}
+ .heatwrap{padding:15px}
+ .heat{display:grid;grid-auto-flow:column;grid-template-rows:repeat(7,11px);gap:3px;overflow-x:auto}
+ .h{width:11px;height:11px;border-radius:2.5px;background:var(--cell)}
+ .h.l1{background:var(--c1)}.h.l2{background:var(--c2)}.h.l3{background:var(--c3)}.h.l4{background:var(--c4)}
+ .flavor{color:var(--mut);font-size:12.5px;padding:0 15px 15px}
+ .models{display:flex;flex-wrap:wrap;gap:7px;margin-top:16px}
+ .mchip{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:6px 11px;font-size:12.5px;color:var(--mut)}
+ .mchip b{color:var(--ink);font-weight:600}
+ .hint{margin-top:26px;color:var(--mut2);font-size:12.5px}
+ .toolbar{margin-top:14px;display:flex;gap:8px;flex-wrap:wrap}
+ .tbtn{background:#2a2a30;border:1px solid var(--line);color:var(--ink);padding:7px 13px;border-radius:8px;cursor:pointer;font-size:12.5px}
+ .tbtn:hover{background:#34343b}
+
+ /* floating session windows */
+ .swin{position:absolute;width:430px;height:300px;background:var(--panel);border:1px solid var(--line);border-radius:12px;box-shadow:var(--shadow);display:flex;flex-direction:column;overflow:hidden}
+ .swin .bar{display:flex;align-items:center;gap:8px;padding:9px 11px;border-bottom:1px solid var(--line);cursor:move;background:var(--panel2)}
+ .swin .bar .ttl{font-size:12.7px;font-weight:600;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .swin .bar .proj{font-size:10.5px;color:var(--mut2);background:#2a2a30;padding:2px 7px;border-radius:5px;border:1px solid var(--line);max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+ .swin .bar .x{cursor:pointer;color:var(--mut);width:18px;text-align:center}
+ .swin .bar .x:hover{color:var(--ink)}
+ .swin .body{flex:1;overflow:auto;padding:12px 13px;font-size:12.7px;color:var(--mut)}
+ .msg{margin-bottom:10px}
+ .msg .role{font-size:10.5px;text-transform:uppercase;letter-spacing:.06em;color:var(--mut2);margin-bottom:3px}
+ .msg.u .bub{background:#2f5ea8;color:#eaf1ff;display:inline-block;padding:7px 11px;border-radius:10px}
+ .msg.a .bub{color:var(--ink)}
+ .swin .foot{border-top:1px solid var(--line);padding:8px 11px;display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--mut2)}
+ .swin .resz{position:absolute;right:2px;bottom:2px;width:15px;height:15px;cursor:nwse-resize;
+ background:linear-gradient(135deg,transparent 50%,var(--mut2) 50%,var(--mut2) 60%,transparent 60%,transparent 72%,var(--mut2) 72%,var(--mut2) 82%,transparent 82%)}
+ .gen{position:absolute;right:16px;bottom:12px;color:var(--mut2);font-size:11px}
+</style>
+</head>
+<body>
+<div class="app">
+ <aside class="side">
+ <div class="winbtns"><i></i><i></i><i></i></div>
+ <div class="tabs"><button>Chat</button><button>Cowork</button><button class="on">Code</button></div>
+ <div class="navi"><span class="ic">+</span> New session</div>
+ <div class="navi"><span class="ic">⟳</span> Routines</div>
+ <div class="navi"><span class="ic">▦</span> Artifacts</div>
+ <div class="navi"><span class="ic">⚙</span> Customize</div>
+ <div class="seclabel">Recents</div>
+ <div id="recents"></div>
+ <div class="who"><span class="av" id="avatar">S</span><span id="whoname">Steve · Designer Wallcoverings</span></div>
+ </aside>
+
+ <main class="main">
+ <div class="canvas" id="canvas">
+ <h1><span class="star">✳</span> What's up next, <span id="hname">Steve</span>?</h1>
+ <div class="dash">
+ <div class="card">
+ <div class="dashhead" style="padding:11px 15px 0">
+ <div class="pillrow"><span class="pill on">Overview</span><span class="pill">Models</span></div>
+ <div class="pillrow"><span class="pill on">All</span><span class="pill">30d</span><span class="pill">7d</span></div>
+ </div>
+ <div class="grid" id="cards" style="margin-top:11px"></div>
+ <div class="heatwrap"><div class="heat" id="heat"></div></div>
+ <div class="flavor" id="flavor"></div>
+ </div>
+ <div class="models" id="models"></div>
+ <div class="hint">Open a session from <b>Recents</b> to pop it out as a floating window — then drag the title bar, resize from the corner, or tile them all.</div>
+ <div class="toolbar">
+ <button class="tbtn" onclick="openTop(3)">Open recent 3</button>
+ <button class="tbtn" onclick="tileWins()">Tile</button>
+ <button class="tbtn" onclick="cascadeWins()">Cascade</button>
+ <button class="tbtn" onclick="closeAll()">Close all</button>
+ </div>
+ </div>
+ </div>
+ <div class="gen" id="gen"></div>
+ </main>
+</div>
+
+<script>
+let DATA=null, wins=[], z=10, cascade=0;
+const $=s=>document.querySelector(s);
+const esc=s=>(s||"").replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));
+const nf=n=>n.toLocaleString();
+
+function heatClass(c){ if(!c)return''; if(c<5)return'l1'; if(c<15)return'l2'; if(c<40)return'l3'; return'l4'; }
+
+fetch('./data/stats.json').then(r=>r.json()).then(d=>{ DATA=d; render();
+ if(location.hash==='#demo'){ openTop(4); setTimeout(tileWins,50); } });
+
+function render(){
+ const c=DATA.cards;
+ $('#hname').textContent=DATA.user;
+ $('#whoname').firstChild ? $('#whoname').textContent=DATA.user+' · Designer Wallcoverings':0;
+ $('#avatar').textContent=DATA.user[0];
+ const cards=[
+ ['Sessions', nf(c.sessions)],['Messages', nf(c.messages)],
+ ['Total tokens', tok(c.total_tokens)],['Active days', nf(c.active_days)],
+ ['Current streak', c.current_streak+'d'],['Longest streak', c.longest_streak+'d'],
+ ['Peak hour', c.peak_hour],['Favorite model', c.favorite_model],
+ ];
+ $('#cards').innerHTML=cards.map(([k,v])=>`<div class="cell"><div class="k">${k}</div><div class="v">${v}</div></div>`).join('');
+ $('#heat').innerHTML=DATA.heatmap.map(h=>`<div class="h ${heatClass(h.count)}" title="${h.date}: ${h.count}"></div>`).join('');
+ $('#flavor').textContent=DATA.flavor;
+ $('#models').innerHTML=DATA.models.map(m=>`<span class="mchip"><b>${esc(m.model)}</b> · ${nf(m.count)} msgs</span>`).join('');
+ $('#recents').innerHTML=DATA.recents.map((s,i)=>
+ `<div class="rec" title="${esc(s.title)}" onclick="openWin(${i})"><span class="dot"></span>${esc(s.title)}</div>`).join('');
+ $('#gen').textContent='generated '+DATA.generated_at;
+}
+function tok(n){ if(n>=1e9)return (n/1e9).toFixed(1)+'B'; if(n>=1e6)return (n/1e6).toFixed(1)+'M'; if(n>=1e3)return (n/1e3).toFixed(1)+'K'; return n; }
+
+function openWin(i){
+ const s=DATA.recents[i]; if(!s) return;
+ if(wins.find(w=>w.dataset.sid===s.id)) return; // no dupes
+ const el=document.createElement('div'); el.className='swin'; el.dataset.sid=s.id;
+ const x=70+(cascade*30)%360, y=60+(cascade*28)%240; cascade++;
+ el.style.left=x+'px'; el.style.top=y+'px'; el.style.zIndex=++z;
+ el.innerHTML=`
+ <div class="bar"><span class="proj">${esc(s.project)}</span>
+ <span class="ttl">${esc(s.title)}</span><span class="x">✕</span></div>
+ <div class="body">
+ ${s.snippet?`<div class="msg u"><div class="role">You</div><div class="bub">${esc(s.snippet)}</div></div>`:''}
+ <div class="msg a"><div class="role"><span style="color:var(--accent)">✳</span> Claude</div>
+ <div class="bub">${esc(s.last)||'(session resumed)'}</div></div>
+ </div>
+ <div class="foot"><span>${nf(s.user_msgs)} prompts · ${tok(s.tokens)} tokens</span>
+ <span>${s.ts?new Date(s.ts).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}):''}</span></div>
+ <div class="resz"></div>`;
+ $('.main').appendChild(el); wins.push(el);
+ el.addEventListener('mousedown',()=>el.style.zIndex=++z);
+ el.querySelector('.x').onclick=()=>{ el.remove(); wins=wins.filter(w=>w!==el); };
+ drag(el, el.querySelector('.bar'));
+ resize(el, el.querySelector('.resz'));
+}
+function openTop(n){ for(let i=0;i<n;i++) openWin(i); }
+function closeAll(){ wins.forEach(w=>w.remove()); wins=[]; cascade=0; }
+function cascadeWins(){ cascade=0; wins.forEach(w=>{ const x=70+(cascade*30)%360,y=60+(cascade*28)%240; cascade++; w.style.left=x+'px';w.style.top=y+'px';w.style.zIndex=++z;}); }
+function tileWins(){
+ const n=wins.length; if(!n)return; const cols=Math.ceil(Math.sqrt(n)), rows=Math.ceil(n/cols);
+ const m=$('.main').getBoundingClientRect(), pad=12;
+ const w=(m.width-pad*(cols+1))/cols, h=(m.height-pad*(rows+1))/rows;
+ wins.forEach((el,i)=>{ const r=Math.floor(i/cols),col=i%cols;
+ el.style.left=(pad+col*(w+pad))+'px'; el.style.top=(pad+r*(h+pad))+'px';
+ el.style.width=w+'px'; el.style.height=h+'px'; });
+}
+function drag(el,handle){
+ let sx,sy,ox,oy,on=false;
+ handle.addEventListener('mousedown',e=>{ if(e.target.classList.contains('x'))return; on=true; sx=e.clientX;sy=e.clientY;ox=el.offsetLeft;oy=el.offsetTop; e.preventDefault();});
+ window.addEventListener('mousemove',e=>{ if(!on)return; el.style.left=(ox+e.clientX-sx)+'px'; el.style.top=(oy+e.clientY-sy)+'px';});
+ window.addEventListener('mouseup',()=>on=false);
+}
+function resize(el,h){
+ let sx,sy,ow,oh,on=false;
+ h.addEventListener('mousedown',e=>{ on=true; sx=e.clientX;sy=e.clientY;ow=el.offsetWidth;oh=el.offsetHeight; e.preventDefault();e.stopPropagation();});
+ window.addEventListener('mousemove',e=>{ if(!on)return; el.style.width=Math.max(280,ow+e.clientX-sx)+'px'; el.style.height=Math.max(160,oh+e.clientY-sy)+'px';});
+ window.addEventListener('mouseup',()=>on=false);
+}
+</script>
+</body>
+</html>
diff --git a/server.py b/server.py
new file mode 100644
index 0000000..d35f8da
--- /dev/null
+++ b/server.py
@@ -0,0 +1,30 @@
+#!/usr/bin/env python3
+"""Zero-dependency static server for the Claude Code Command Center viewer.
+Basic-auth gated (admin / DW2024!), serves ./public on a free OS-assigned port
+(or PORT env). Prints the URL on startup."""
+import base64, functools, http.server, os, socketserver
+
+ROOT = os.path.join(os.path.dirname(os.path.abspath(__file__)), "public")
+USER, PW = os.environ.get("CC_USER", "admin"), os.environ.get("CC_PASS", "DW2024!")
+TOKEN = base64.b64encode(f"{USER}:{PW}".encode()).decode()
+PORT = int(os.environ.get("PORT", "0"))
+
+class Handler(http.server.SimpleHTTPRequestHandler):
+ def do_GET(self):
+ if self.headers.get("Authorization") != f"Basic {TOKEN}":
+ self.send_response(401)
+ self.send_header("WWW-Authenticate", 'Basic realm="Command Center"')
+ self.end_headers(); self.wfile.write(b"auth required"); return
+ return super().do_GET()
+ def log_message(self, *a): pass
+
+class TCP(socketserver.ThreadingTCPServer):
+ allow_reuse_address = True
+
+if __name__ == "__main__":
+ Handler.directory = ROOT
+ h = functools.partial(Handler, directory=ROOT)
+ with TCP(("127.0.0.1", PORT), h) as httpd:
+ port = httpd.server_address[1]
+ print(f"Command Center → http://127.0.0.1:{port} (auth: {USER} / {PW})")
+ httpd.serve_forever()
(oldest)
·
back to Cc Command Center
·
(newest)