feat(observer): M-Obs 4-7 — widgets, dashboard, docs, integration example
Some checks failed
Test / test (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
M-Obs 4: @shade/widgets React library - ShadeProvider context with observer URL + token + theme - useShadeState (polling) + useShadeEvents (SSE) hooks - 7 widgets: IdentityCard, SessionList, PrekeyStock, RecentActivity, ServerStatus, FingerprintCompare, WidgetCatalog (meta-widget for user-selectable layout with localStorage persistence) - Self-contained CSS via inline styles, no external CSS conflicts - Light/dark/auto theme via tokens M-Obs 5: @shade/dashboard standalone SPA - Vite + React app composing all widgets into a full debugger layout - Login screen with token persistence to localStorage - Build script copies dist/ to @shade/observer/dist/ for embedded serving - 211 KB JS bundle (66 KB gzipped) M-Obs 6: Documentation + integration example - READMEs for @shade/observer and @shade/widgets - examples/06-observer-dashboard runnable demo: spins up prekey server + observer, runs Alice ↔ Bob conversation loop, dashboard at :3901 - Updated root README and docker-compose.yml with observer integration M-Obs 7: End-to-end verification - StateAggregator now drains buffered events on subscription, so identity.initialized fires before observer construction are still seen - Verified live: snapshot endpoint returns full state, dashboard serves, 401 without auth, sessions/messages/ratchet steps all tracked 220 tests passing, 0 failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
23
packages/shade-dashboard/index.html
Normal file
23
packages/shade-dashboard/index.html
Normal file
@@ -0,0 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Shade Observer</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
background: #0a0a0a;
|
||||
color: #e5e5e5;
|
||||
font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
min-height: 100vh;
|
||||
}
|
||||
#root { min-height: 100vh; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
20
packages/shade-dashboard/package.json
Normal file
20
packages/shade-dashboard/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "@shade/dashboard",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build && bun run scripts/copy-to-observer.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@shade/widgets": "workspace:*",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
26
packages/shade-dashboard/scripts/copy-to-observer.ts
Normal file
26
packages/shade-dashboard/scripts/copy-to-observer.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* After Vite builds the dashboard, copy the dist/ output into
|
||||
* @shade/observer's dist/ directory so the observer endpoint can
|
||||
* serve it from /dashboard/.
|
||||
*/
|
||||
import { existsSync, mkdirSync, cpSync, rmSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const dashboardDist = join(here, '..', 'dist');
|
||||
const observerDist = join(here, '..', '..', 'shade-observer', 'dist');
|
||||
|
||||
if (!existsSync(dashboardDist)) {
|
||||
console.error(`Dashboard dist not found at ${dashboardDist}. Run \`vite build\` first.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Clean and recreate observer dist
|
||||
if (existsSync(observerDist)) {
|
||||
rmSync(observerDist, { recursive: true });
|
||||
}
|
||||
mkdirSync(observerDist, { recursive: true });
|
||||
|
||||
cpSync(dashboardDist, observerDist, { recursive: true });
|
||||
console.log(`✓ Copied dashboard build to ${observerDist}`);
|
||||
149
packages/shade-dashboard/src/App.tsx
Normal file
149
packages/shade-dashboard/src/App.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
ShadeProvider,
|
||||
IdentityCard,
|
||||
SessionList,
|
||||
PrekeyStock,
|
||||
RecentActivity,
|
||||
ServerStatus,
|
||||
FingerprintCompare,
|
||||
} from '@shade/widgets';
|
||||
import { Login } from './Login.js';
|
||||
|
||||
const STORAGE_KEY = 'shade-dashboard-config';
|
||||
|
||||
interface DashboardConfig {
|
||||
observerUrl: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export function App(): React.ReactElement {
|
||||
const [config, setConfig] = useState<DashboardConfig | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Determine the default observer URL: assume dashboard is served from
|
||||
// the same origin as the observer, so the API is at the parent path
|
||||
const defaultUrl = window.location.origin + window.location.pathname.replace(/\/dashboard\/?$/, '');
|
||||
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as DashboardConfig;
|
||||
if (parsed.observerUrl && parsed.token) {
|
||||
setConfig(parsed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Show login screen
|
||||
setConfig({ observerUrl: defaultUrl, token: '' });
|
||||
}, []);
|
||||
|
||||
function handleLogin(observerUrl: string, token: string): void {
|
||||
const cfg = { observerUrl, token };
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(cfg));
|
||||
setConfig(cfg);
|
||||
}
|
||||
|
||||
function handleLogout(): void {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
setConfig({ observerUrl: window.location.origin, token: '' });
|
||||
}
|
||||
|
||||
if (!config) {
|
||||
return <div style={{ padding: 40, textAlign: 'center' }}>Loading…</div>;
|
||||
}
|
||||
|
||||
if (!config.token) {
|
||||
return <Login defaultUrl={config.observerUrl} onLogin={handleLogin} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ShadeProvider observerUrl={config.observerUrl} token={config.token}>
|
||||
<DashboardLayout onLogout={handleLogout} />
|
||||
</ShadeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function DashboardLayout({ onLogout }: { onLogout: () => void }): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 1400,
|
||||
margin: '0 auto',
|
||||
padding: 24,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 24,
|
||||
}}
|
||||
>
|
||||
<header
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingBottom: 16,
|
||||
borderBottom: '1px solid #262626',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 22,
|
||||
fontWeight: 600,
|
||||
color: '#f7c948',
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
Shade Observer
|
||||
</h1>
|
||||
<div style={{ fontSize: 12, color: '#a3a3a3', marginTop: 2 }}>
|
||||
Live debugger for your Signal Protocol deployment
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onLogout}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: '1px solid #262626',
|
||||
color: '#a3a3a3',
|
||||
padding: '6px 14px',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<IdentityCard />
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(280px, 1fr) minmax(280px, 1fr) minmax(280px, 1fr)',
|
||||
gap: 20,
|
||||
}}
|
||||
>
|
||||
<PrekeyStock />
|
||||
<ServerStatus />
|
||||
<FingerprintCompare />
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 20,
|
||||
}}
|
||||
>
|
||||
<SessionList />
|
||||
<RecentActivity limit={100} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
packages/shade-dashboard/src/Login.tsx
Normal file
127
packages/shade-dashboard/src/Login.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface LoginProps {
|
||||
defaultUrl: string;
|
||||
onLogin: (url: string, token: string) => void;
|
||||
}
|
||||
|
||||
export function Login({ defaultUrl, onLogin }: LoginProps): React.ReactElement {
|
||||
const [url, setUrl] = useState(defaultUrl);
|
||||
const [token, setToken] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
if (!url.trim() || !token.trim()) {
|
||||
setError('Both fields are required');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${url.replace(/\/$/, '')}/api/state`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) {
|
||||
setError(`Auth failed: HTTP ${res.status}`);
|
||||
return;
|
||||
}
|
||||
onLogin(url.trim(), token.trim());
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
style={{
|
||||
background: '#161616',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: 8,
|
||||
padding: 32,
|
||||
width: '100%',
|
||||
maxWidth: 420,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<h1 style={{ margin: 0, color: '#f7c948', fontSize: 22 }}>Shade Observer</h1>
|
||||
<p style={{ margin: '4px 0 0', color: '#a3a3a3', fontSize: 13 }}>
|
||||
Connect to your observer endpoint
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ fontSize: 11, color: '#a3a3a3', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
Observer URL
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://shade.example.com/shade-observer"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ fontSize: 11, color: '#a3a3a3', textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
Bearer token
|
||||
</span>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="SHADE_OBSERVER_TOKEN value"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && (
|
||||
<div style={{ color: '#ef4444', fontSize: 12, padding: '8px 10px', background: 'rgba(239, 68, 68, 0.1)', borderRadius: 4 }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
style={{
|
||||
background: '#f7c948',
|
||||
color: '#0a0a0a',
|
||||
border: 'none',
|
||||
padding: '10px 16px',
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const inputStyle: React.CSSProperties = {
|
||||
background: '#0a0a0a',
|
||||
border: '1px solid #262626',
|
||||
borderRadius: 6,
|
||||
padding: '10px 12px',
|
||||
fontSize: 13,
|
||||
color: '#e5e5e5',
|
||||
fontFamily: 'system-ui, -apple-system, sans-serif',
|
||||
outline: 'none',
|
||||
};
|
||||
12
packages/shade-dashboard/src/main.tsx
Normal file
12
packages/shade-dashboard/src/main.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { App } from './App.js';
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) throw new Error('Missing #root');
|
||||
|
||||
createRoot(container).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
12
packages/shade-dashboard/tsconfig.json
Normal file
12
packages/shade-dashboard/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler"
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
12
packages/shade-dashboard/vite.config.ts
Normal file
12
packages/shade-dashboard/vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
base: '/dashboard/',
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
target: 'es2022',
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user