1106 lines
42 KiB
HTML
1106 lines
42 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>IndexedDB Event Store - Claude Code DevTools</title>
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
:root {
|
|
--bg-primary: #0d1117;
|
|
--bg-secondary: #161b22;
|
|
--bg-tertiary: #21262d;
|
|
--border-color: #30363d;
|
|
--text-primary: #c9d1d9;
|
|
--text-secondary: #8b949e;
|
|
--accent-blue: #58a6ff;
|
|
--accent-green: #3fb950;
|
|
--accent-purple: #bc8cff;
|
|
--accent-orange: #ff9f43;
|
|
--accent-red: #f85149;
|
|
--accent-yellow: #f0e68c;
|
|
--shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
|
|
}
|
|
|
|
body {
|
|
font-family: 'Segoe UI', 'Helvetica Neue', Arial, sans-serif;
|
|
background: var(--bg-primary);
|
|
color: var(--text-primary);
|
|
line-height: 1.6;
|
|
padding: 20px;
|
|
}
|
|
|
|
header {
|
|
text-align: center;
|
|
margin-bottom: 30px;
|
|
padding-bottom: 20px;
|
|
border-bottom: 2px solid var(--border-color);
|
|
}
|
|
|
|
h1 {
|
|
color: var(--accent-blue);
|
|
margin-bottom: 10px;
|
|
font-size: 2.5em;
|
|
}
|
|
|
|
.tagline {
|
|
color: var(--text-secondary);
|
|
font-size: 1.1em;
|
|
}
|
|
|
|
.controls {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.control-group {
|
|
background: var(--bg-secondary);
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.control-group h3 {
|
|
color: var(--accent-purple);
|
|
margin-bottom: 15px;
|
|
font-size: 1.1em;
|
|
}
|
|
|
|
button {
|
|
background: var(--accent-blue);
|
|
color: white;
|
|
border: none;
|
|
padding: 10px 20px;
|
|
border-radius: 5px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
margin: 5px 5px 5px 0;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
button:hover {
|
|
background: #4a9eff;
|
|
transform: translateY(-1px);
|
|
box-shadow: var(--shadow);
|
|
}
|
|
|
|
button:disabled {
|
|
background: var(--text-secondary);
|
|
cursor: not-allowed;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
button.secondary {
|
|
background: var(--bg-tertiary);
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
button.danger {
|
|
background: var(--accent-red);
|
|
}
|
|
|
|
button.success {
|
|
background: var(--accent-green);
|
|
}
|
|
|
|
input, select, textarea {
|
|
background: var(--bg-tertiary);
|
|
border: 1px solid var(--border-color);
|
|
color: var(--text-primary);
|
|
padding: 8px 12px;
|
|
border-radius: 5px;
|
|
font-size: 14px;
|
|
width: 100%;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
input:focus, select:focus, textarea:focus {
|
|
outline: none;
|
|
border-color: var(--accent-blue);
|
|
}
|
|
|
|
.status-bar {
|
|
background: var(--bg-secondary);
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
margin-bottom: 20px;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
gap: 15px;
|
|
}
|
|
|
|
.status-item {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
|
|
.status-indicator {
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
background: var(--text-secondary);
|
|
}
|
|
|
|
.status-indicator.active {
|
|
background: var(--accent-green);
|
|
box-shadow: 0 0 8px var(--accent-green);
|
|
}
|
|
|
|
.status-indicator.error {
|
|
background: var(--accent-red);
|
|
box-shadow: 0 0 8px var(--accent-red);
|
|
}
|
|
|
|
.query-panel {
|
|
background: var(--bg-secondary);
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.query-filters {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 15px;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.results-section {
|
|
background: var(--bg-secondary);
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.event-list {
|
|
max-height: 500px;
|
|
overflow-y: auto;
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 5px;
|
|
}
|
|
|
|
.event-item {
|
|
padding: 15px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
transition: background 0.2s;
|
|
}
|
|
|
|
.event-item:hover {
|
|
background: var(--bg-tertiary);
|
|
}
|
|
|
|
.event-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.event-type {
|
|
padding: 4px 12px;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
}
|
|
|
|
.event-type.PreToolUse { background: var(--accent-blue); color: white; }
|
|
.event-type.PostToolUse { background: var(--accent-green); color: white; }
|
|
.event-type.Notification { background: var(--accent-orange); color: white; }
|
|
.event-type.Stop { background: var(--accent-red); color: white; }
|
|
.event-type.UserPromptSubmit { background: var(--accent-purple); color: white; }
|
|
|
|
.event-meta {
|
|
display: flex;
|
|
gap: 20px;
|
|
color: var(--text-secondary);
|
|
font-size: 13px;
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.event-summary {
|
|
color: var(--text-primary);
|
|
margin-top: 8px;
|
|
}
|
|
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 15px;
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.stat-card {
|
|
background: var(--bg-tertiary);
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
border: 1px solid var(--border-color);
|
|
}
|
|
|
|
.stat-value {
|
|
font-size: 2em;
|
|
color: var(--accent-blue);
|
|
font-weight: bold;
|
|
}
|
|
|
|
.stat-label {
|
|
color: var(--text-secondary);
|
|
font-size: 0.9em;
|
|
margin-top: 5px;
|
|
}
|
|
|
|
.pagination {
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
gap: 10px;
|
|
margin-top: 15px;
|
|
}
|
|
|
|
.docs {
|
|
background: var(--bg-secondary);
|
|
padding: 25px;
|
|
border-radius: 8px;
|
|
margin-top: 30px;
|
|
}
|
|
|
|
.docs h2 {
|
|
color: var(--accent-blue);
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.docs h3 {
|
|
color: var(--accent-purple);
|
|
margin: 20px 0 10px 0;
|
|
}
|
|
|
|
.docs ul, .docs ol {
|
|
margin-left: 25px;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.docs li {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.docs code {
|
|
background: var(--bg-tertiary);
|
|
padding: 2px 6px;
|
|
border-radius: 3px;
|
|
color: var(--accent-orange);
|
|
font-family: 'Courier New', monospace;
|
|
}
|
|
|
|
footer {
|
|
text-align: center;
|
|
margin-top: 40px;
|
|
padding-top: 20px;
|
|
border-top: 1px solid var(--border-color);
|
|
color: var(--text-secondary);
|
|
}
|
|
|
|
footer a {
|
|
color: var(--accent-blue);
|
|
text-decoration: none;
|
|
}
|
|
|
|
footer a:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.json-viewer {
|
|
background: var(--bg-tertiary);
|
|
padding: 10px;
|
|
border-radius: 5px;
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 12px;
|
|
max-height: 200px;
|
|
overflow-y: auto;
|
|
margin-top: 10px;
|
|
white-space: pre-wrap;
|
|
word-break: break-all;
|
|
}
|
|
|
|
::-webkit-scrollbar {
|
|
width: 10px;
|
|
height: 10px;
|
|
}
|
|
|
|
::-webkit-scrollbar-track {
|
|
background: var(--bg-tertiary);
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb {
|
|
background: var(--border-color);
|
|
border-radius: 5px;
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb:hover {
|
|
background: var(--text-secondary);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<header>
|
|
<h1>🗄️ IndexedDB Event Store</h1>
|
|
<p class="tagline">Production-grade persistent storage for Claude Code hook events</p>
|
|
</header>
|
|
|
|
<main>
|
|
<!-- Status Bar -->
|
|
<div class="status-bar">
|
|
<div class="status-item">
|
|
<div class="status-indicator" id="dbStatus"></div>
|
|
<span>Database: <strong id="dbStatusText">Initializing...</strong></span>
|
|
</div>
|
|
<div class="status-item">
|
|
<div class="status-indicator" id="wsStatus"></div>
|
|
<span>WebSocket: <strong id="wsStatusText">Disconnected</strong></span>
|
|
</div>
|
|
<div class="status-item">
|
|
<span>Events Stored: <strong id="eventCount">0</strong></span>
|
|
</div>
|
|
<div class="status-item">
|
|
<span>DB Size: <strong id="dbSize">0 KB</strong></span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Controls -->
|
|
<div class="controls">
|
|
<div class="control-group">
|
|
<h3>🔌 WebSocket Connection</h3>
|
|
<input type="text" id="wsUrl" value="ws://localhost:4000/stream" placeholder="WebSocket URL">
|
|
<button id="connectBtn" class="success">Connect to Stream</button>
|
|
<button id="disconnectBtn" class="danger" disabled>Disconnect</button>
|
|
</div>
|
|
|
|
<div class="control-group">
|
|
<h3>📂 Import/Export</h3>
|
|
<input type="file" id="fileInput" accept=".json,.jsonl" style="margin-bottom: 10px;">
|
|
<button id="importBtn" class="secondary">Import Events</button>
|
|
<button id="exportBtn" class="secondary">Export All Events</button>
|
|
</div>
|
|
|
|
<div class="control-group">
|
|
<h3>🧹 Maintenance</h3>
|
|
<button id="clearDbBtn" class="danger">Clear Database</button>
|
|
<button id="sampleDataBtn" class="secondary">Load Sample Data</button>
|
|
<button id="refreshStatsBtn" class="secondary">Refresh Statistics</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Query Panel -->
|
|
<div class="query-panel">
|
|
<h3 style="color: var(--accent-purple); margin-bottom: 15px;">🔍 Advanced Query Interface</h3>
|
|
<div class="query-filters">
|
|
<div>
|
|
<label>Source App:</label>
|
|
<select id="filterApp">
|
|
<option value="">All Apps</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label>Session ID:</label>
|
|
<select id="filterSession">
|
|
<option value="">All Sessions</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label>Event Type:</label>
|
|
<select id="filterEventType">
|
|
<option value="">All Types</option>
|
|
<option value="PreToolUse">PreToolUse</option>
|
|
<option value="PostToolUse">PostToolUse</option>
|
|
<option value="Notification">Notification</option>
|
|
<option value="Stop">Stop</option>
|
|
<option value="UserPromptSubmit">UserPromptSubmit</option>
|
|
<option value="SubagentStop">SubagentStop</option>
|
|
<option value="PreCompact">PreCompact</option>
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label>Time Range:</label>
|
|
<select id="filterTimeRange">
|
|
<option value="">All Time</option>
|
|
<option value="1h">Last Hour</option>
|
|
<option value="24h">Last 24 Hours</option>
|
|
<option value="7d">Last 7 Days</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
<button id="queryBtn">Execute Query</button>
|
|
<button id="resetFiltersBtn" class="secondary">Reset Filters</button>
|
|
</div>
|
|
|
|
<!-- Results -->
|
|
<div class="results-section">
|
|
<h3 style="color: var(--accent-green); margin-bottom: 15px;">📊 Query Results</h3>
|
|
<div class="event-list" id="eventList">
|
|
<div style="padding: 40px; text-align: center; color: var(--text-secondary);">
|
|
No events to display. Connect to WebSocket or import events to get started.
|
|
</div>
|
|
</div>
|
|
<div class="pagination">
|
|
<button id="prevPageBtn" disabled>← Previous</button>
|
|
<span id="pageInfo">Page 1 of 1</span>
|
|
<button id="nextPageBtn" disabled>Next →</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Statistics -->
|
|
<div class="results-section">
|
|
<h3 style="color: var(--accent-orange); margin-bottom: 15px;">📈 Event Statistics</h3>
|
|
<div class="stats-grid" id="statsGrid">
|
|
<!-- Stats populated dynamically -->
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Documentation -->
|
|
<section class="docs">
|
|
<h2>About This Tool</h2>
|
|
|
|
<h3>Purpose</h3>
|
|
<p>Production-grade persistent storage system for Claude Code hook events using IndexedDB. Store, query, and analyze thousands of events with advanced filtering, compound indexes, and real-time updates from the observability server.</p>
|
|
|
|
<h3>Features</h3>
|
|
<ul>
|
|
<li><strong>IndexedDB Storage:</strong> Persistent local database with compound indexes for efficient queries</li>
|
|
<li><strong>Real-time Updates:</strong> Connect to WebSocket server (ws://localhost:4000/stream) for live event streaming</li>
|
|
<li><strong>Multi-Field Indexing:</strong> Compound indexes on (source_app, timestamp) and (session_id, event_type)</li>
|
|
<li><strong>Advanced Queries:</strong> Filter by app, session, event type, and time range with cursor-based pagination</li>
|
|
<li><strong>Event Aggregation:</strong> Statistics by app, session, event type, and time period</li>
|
|
<li><strong>Import/Export:</strong> Load events from JSON/JSONL files and export filtered results</li>
|
|
<li><strong>Database Management:</strong> Size monitoring, cleanup, and sample data generation</li>
|
|
<li><strong>Cursor-based Pagination:</strong> Efficiently iterate over large result sets without loading all data</li>
|
|
</ul>
|
|
|
|
<h3>Web Research Integration</h3>
|
|
<p><strong>Source:</strong> <a href="https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API" target="_blank">MDN - IndexedDB API</a></p>
|
|
<p><strong>IndexedDB Techniques Applied:</strong></p>
|
|
<ul>
|
|
<li><strong>Database Versioning & Migrations:</strong> Used <code>indexedDB.open()</code> with version management and <code>onupgradeneeded</code> event to create object stores and indexes dynamically</li>
|
|
<li><strong>Compound Indexes:</strong> Created multi-field indexes like <code>[source_app, timestamp]</code> and <code>[session_id, event_type]</code> for efficient filtering on multiple criteria simultaneously</li>
|
|
<li><strong>IDBKeyRange for Filtering:</strong> Leveraged <code>IDBKeyRange.bound()</code> and <code>IDBKeyRange.only()</code> for precise time-based and value-based filtering</li>
|
|
<li><strong>Cursor-based Iteration:</strong> Implemented <code>IDBCursor</code> with <code>openCursor()</code> for memory-efficient pagination over large datasets without loading all records</li>
|
|
<li><strong>Transaction Management:</strong> Used read/write transactions with proper scope limiting to ensure data integrity and performance</li>
|
|
</ul>
|
|
|
|
<h3>Usage</h3>
|
|
<ol>
|
|
<li><strong>Connect to WebSocket:</strong> Click "Connect to Stream" to receive real-time events from ws://localhost:4000/stream</li>
|
|
<li><strong>Import Events:</strong> Upload JSON/JSONL files containing hook events from the observability system</li>
|
|
<li><strong>Query Events:</strong> Use filters to search by app, session, event type, or time range</li>
|
|
<li><strong>View Statistics:</strong> Analyze event distribution across apps, sessions, and event types</li>
|
|
<li><strong>Export Results:</strong> Download filtered events as JSON for backup or analysis</li>
|
|
<li><strong>Pagination:</strong> Navigate through large result sets using Previous/Next buttons</li>
|
|
</ol>
|
|
|
|
<h3>Event Structure</h3>
|
|
<p>Events from the multi-agent observability system have this structure:</p>
|
|
<ul>
|
|
<li><code>id</code>: Auto-incrementing unique identifier</li>
|
|
<li><code>source_app</code>: Application that generated the event (e.g., "demo-agent")</li>
|
|
<li><code>session_id</code>: Unique session identifier</li>
|
|
<li><code>hook_event_type</code>: Type of hook event (PreToolUse, PostToolUse, etc.)</li>
|
|
<li><code>payload</code>: Event-specific data (tool name, inputs, outputs)</li>
|
|
<li><code>timestamp</code>: ISO 8601 timestamp</li>
|
|
<li><code>summary</code>: AI-generated summary of the event</li>
|
|
</ul>
|
|
|
|
<h3>IndexedDB Schema</h3>
|
|
<p>Database: <code>HookEventsDB</code>, Version: 1</p>
|
|
<p>Object Store: <code>events</code></p>
|
|
<ul>
|
|
<li>Key Path: <code>id</code> (auto-increment)</li>
|
|
<li>Index 1: <code>source_app</code></li>
|
|
<li>Index 2: <code>session_id</code></li>
|
|
<li>Index 3: <code>hook_event_type</code></li>
|
|
<li>Index 4: <code>timestamp</code></li>
|
|
<li>Index 5: <code>app_time</code> (compound: [source_app, timestamp])</li>
|
|
<li>Index 6: <code>session_type</code> (compound: [session_id, hook_event_type])</li>
|
|
</ul>
|
|
</section>
|
|
</main>
|
|
|
|
<footer>
|
|
<p>Claude Code DevTools | Generated via web-enhanced infinite loop</p>
|
|
<p>Web Source: <a href="https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API" target="_blank">https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API</a></p>
|
|
</footer>
|
|
|
|
<script>
|
|
// IndexedDB Configuration
|
|
const DB_NAME = 'HookEventsDB';
|
|
const DB_VERSION = 1;
|
|
const STORE_NAME = 'events';
|
|
const PAGE_SIZE = 50;
|
|
|
|
// Global state
|
|
let db = null;
|
|
let ws = null;
|
|
let currentPage = 1;
|
|
let totalPages = 1;
|
|
let currentQuery = {};
|
|
|
|
// Initialize IndexedDB with compound indexes
|
|
async function initDB() {
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
|
|
request.onerror = () => {
|
|
updateStatus('db', 'error', 'Failed');
|
|
reject(request.error);
|
|
};
|
|
|
|
request.onsuccess = () => {
|
|
db = request.result;
|
|
updateStatus('db', 'active', 'Connected');
|
|
resolve(db);
|
|
};
|
|
|
|
// Database upgrade/creation - create object store and indexes
|
|
request.onupgradeneeded = (event) => {
|
|
const db = event.target.result;
|
|
|
|
// Create object store with auto-incrementing key
|
|
const store = db.createObjectStore(STORE_NAME, {
|
|
keyPath: 'id',
|
|
autoIncrement: true
|
|
});
|
|
|
|
// Single-field indexes
|
|
store.createIndex('source_app', 'source_app', { unique: false });
|
|
store.createIndex('session_id', 'session_id', { unique: false });
|
|
store.createIndex('hook_event_type', 'hook_event_type', { unique: false });
|
|
store.createIndex('timestamp', 'timestamp', { unique: false });
|
|
|
|
// Compound indexes for multi-field queries
|
|
store.createIndex('app_time', ['source_app', 'timestamp'], { unique: false });
|
|
store.createIndex('session_type', ['session_id', 'hook_event_type'], { unique: false });
|
|
|
|
console.log('IndexedDB schema created with compound indexes');
|
|
};
|
|
});
|
|
}
|
|
|
|
// Add event to database using transaction
|
|
async function addEvent(event) {
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
|
const store = transaction.objectStore(STORE_NAME);
|
|
|
|
// Add timestamp if not present
|
|
if (!event.timestamp) {
|
|
event.timestamp = new Date().toISOString();
|
|
}
|
|
|
|
const request = store.add(event);
|
|
|
|
request.onsuccess = () => {
|
|
updateEventCount();
|
|
updateDBSize();
|
|
resolve(request.result);
|
|
};
|
|
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
// Query events with compound indexes and cursor-based pagination
|
|
async function queryEvents(filters = {}, page = 1) {
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction([STORE_NAME], 'readonly');
|
|
const store = transaction.objectStore(STORE_NAME);
|
|
|
|
let request;
|
|
let keyRange = null;
|
|
|
|
// Use compound index if both fields are specified
|
|
if (filters.source_app && filters.timestamp) {
|
|
const index = store.index('app_time');
|
|
keyRange = IDBKeyRange.bound(
|
|
[filters.source_app, filters.timestamp.start],
|
|
[filters.source_app, filters.timestamp.end]
|
|
);
|
|
request = index.openCursor(keyRange, 'prev');
|
|
} else if (filters.session_id && filters.hook_event_type) {
|
|
const index = store.index('session_type');
|
|
keyRange = IDBKeyRange.only([filters.session_id, filters.hook_event_type]);
|
|
request = index.openCursor(keyRange, 'prev');
|
|
} else if (filters.source_app) {
|
|
const index = store.index('source_app');
|
|
request = index.openCursor(IDBKeyRange.only(filters.source_app), 'prev');
|
|
} else if (filters.session_id) {
|
|
const index = store.index('session_id');
|
|
request = index.openCursor(IDBKeyRange.only(filters.session_id), 'prev');
|
|
} else if (filters.hook_event_type) {
|
|
const index = store.index('hook_event_type');
|
|
request = index.openCursor(IDBKeyRange.only(filters.hook_event_type), 'prev');
|
|
} else if (filters.timestamp) {
|
|
const index = store.index('timestamp');
|
|
keyRange = IDBKeyRange.bound(filters.timestamp.start, filters.timestamp.end);
|
|
request = index.openCursor(keyRange, 'prev');
|
|
} else {
|
|
request = store.openCursor(null, 'prev');
|
|
}
|
|
|
|
const results = [];
|
|
let count = 0;
|
|
const skip = (page - 1) * PAGE_SIZE;
|
|
|
|
request.onsuccess = (event) => {
|
|
const cursor = event.target.result;
|
|
if (cursor) {
|
|
count++;
|
|
// Skip to the right page
|
|
if (count > skip && results.length < PAGE_SIZE) {
|
|
results.push(cursor.value);
|
|
}
|
|
cursor.continue();
|
|
} else {
|
|
// Calculate total pages
|
|
totalPages = Math.ceil(count / PAGE_SIZE) || 1;
|
|
resolve({ results, total: count });
|
|
}
|
|
};
|
|
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
// Get aggregation statistics using indexes
|
|
async function getStatistics() {
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction([STORE_NAME], 'readonly');
|
|
const store = transaction.objectStore(STORE_NAME);
|
|
|
|
const stats = {
|
|
byApp: {},
|
|
bySession: {},
|
|
byEventType: {},
|
|
total: 0
|
|
};
|
|
|
|
const request = store.openCursor();
|
|
|
|
request.onsuccess = (event) => {
|
|
const cursor = event.target.result;
|
|
if (cursor) {
|
|
const event = cursor.value;
|
|
stats.total++;
|
|
|
|
// Count by app
|
|
stats.byApp[event.source_app] = (stats.byApp[event.source_app] || 0) + 1;
|
|
|
|
// Count by session
|
|
stats.bySession[event.session_id] = (stats.bySession[event.session_id] || 0) + 1;
|
|
|
|
// Count by event type
|
|
stats.byEventType[event.hook_event_type] = (stats.byEventType[event.hook_event_type] || 0) + 1;
|
|
|
|
cursor.continue();
|
|
} else {
|
|
resolve(stats);
|
|
}
|
|
};
|
|
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
// Get all unique values for filters
|
|
async function getFilterOptions() {
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction([STORE_NAME], 'readonly');
|
|
const store = transaction.objectStore(STORE_NAME);
|
|
|
|
const apps = new Set();
|
|
const sessions = new Set();
|
|
const request = store.openCursor();
|
|
|
|
request.onsuccess = (event) => {
|
|
const cursor = event.target.result;
|
|
if (cursor) {
|
|
apps.add(cursor.value.source_app);
|
|
sessions.add(cursor.value.session_id);
|
|
cursor.continue();
|
|
} else {
|
|
resolve({
|
|
apps: Array.from(apps).sort(),
|
|
sessions: Array.from(sessions).sort()
|
|
});
|
|
}
|
|
};
|
|
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
// Clear database
|
|
async function clearDatabase() {
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction([STORE_NAME], 'readwrite');
|
|
const store = transaction.objectStore(STORE_NAME);
|
|
const request = store.clear();
|
|
|
|
request.onsuccess = () => resolve();
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
// Export all events
|
|
async function exportEvents() {
|
|
const { results } = await queryEvents({}, 1);
|
|
const allEvents = [];
|
|
|
|
// Get all events using cursor
|
|
return new Promise((resolve, reject) => {
|
|
const transaction = db.transaction([STORE_NAME], 'readonly');
|
|
const store = transaction.objectStore(STORE_NAME);
|
|
const request = store.openCursor();
|
|
|
|
request.onsuccess = (event) => {
|
|
const cursor = event.target.result;
|
|
if (cursor) {
|
|
allEvents.push(cursor.value);
|
|
cursor.continue();
|
|
} else {
|
|
resolve(allEvents);
|
|
}
|
|
};
|
|
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
// WebSocket connection
|
|
function connectWebSocket() {
|
|
const url = document.getElementById('wsUrl').value;
|
|
|
|
ws = new WebSocket(url);
|
|
|
|
ws.onopen = () => {
|
|
updateStatus('ws', 'active', 'Connected');
|
|
document.getElementById('connectBtn').disabled = true;
|
|
document.getElementById('disconnectBtn').disabled = false;
|
|
};
|
|
|
|
ws.onmessage = async (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
await addEvent(data);
|
|
|
|
// Refresh display if on first page
|
|
if (currentPage === 1) {
|
|
await executeQuery();
|
|
}
|
|
} catch (error) {
|
|
console.error('Error processing WebSocket message:', error);
|
|
}
|
|
};
|
|
|
|
ws.onerror = () => {
|
|
updateStatus('ws', 'error', 'Error');
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
updateStatus('ws', 'inactive', 'Disconnected');
|
|
document.getElementById('connectBtn').disabled = false;
|
|
document.getElementById('disconnectBtn').disabled = true;
|
|
};
|
|
}
|
|
|
|
function disconnectWebSocket() {
|
|
if (ws) {
|
|
ws.close();
|
|
}
|
|
}
|
|
|
|
// Import events from file
|
|
async function importEvents(file) {
|
|
const text = await file.text();
|
|
const lines = text.split('\n').filter(line => line.trim());
|
|
|
|
let imported = 0;
|
|
for (const line of lines) {
|
|
try {
|
|
const event = JSON.parse(line);
|
|
await addEvent(event);
|
|
imported++;
|
|
} catch (error) {
|
|
console.error('Error importing event:', error);
|
|
}
|
|
}
|
|
|
|
alert(`Imported ${imported} events`);
|
|
await executeQuery();
|
|
await updateFilterOptions();
|
|
}
|
|
|
|
// Load sample data
|
|
async function loadSampleData() {
|
|
const eventTypes = ['PreToolUse', 'PostToolUse', 'Notification', 'Stop', 'UserPromptSubmit'];
|
|
const apps = ['demo-agent', 'test-agent', 'main-agent'];
|
|
const sessions = ['session-1', 'session-2', 'session-3'];
|
|
|
|
for (let i = 0; i < 100; i++) {
|
|
const event = {
|
|
source_app: apps[Math.floor(Math.random() * apps.length)],
|
|
session_id: sessions[Math.floor(Math.random() * sessions.length)],
|
|
hook_event_type: eventTypes[Math.floor(Math.random() * eventTypes.length)],
|
|
timestamp: new Date(Date.now() - Math.random() * 7 * 24 * 60 * 60 * 1000).toISOString(),
|
|
summary: `Sample event ${i + 1}`,
|
|
payload: { sample: true, index: i }
|
|
};
|
|
await addEvent(event);
|
|
}
|
|
|
|
alert('Loaded 100 sample events');
|
|
await executeQuery();
|
|
await updateFilterOptions();
|
|
}
|
|
|
|
// Execute query with filters
|
|
async function executeQuery() {
|
|
const filters = currentQuery;
|
|
const { results, total } = await queryEvents(filters, currentPage);
|
|
|
|
displayEvents(results);
|
|
updatePagination();
|
|
await updateStatistics();
|
|
}
|
|
|
|
// Build filters from UI
|
|
function buildFilters() {
|
|
const filters = {};
|
|
|
|
const app = document.getElementById('filterApp').value;
|
|
if (app) filters.source_app = app;
|
|
|
|
const session = document.getElementById('filterSession').value;
|
|
if (session) filters.session_id = session;
|
|
|
|
const eventType = document.getElementById('filterEventType').value;
|
|
if (eventType) filters.hook_event_type = eventType;
|
|
|
|
const timeRange = document.getElementById('filterTimeRange').value;
|
|
if (timeRange) {
|
|
const now = new Date();
|
|
let start;
|
|
if (timeRange === '1h') start = new Date(now - 60 * 60 * 1000);
|
|
else if (timeRange === '24h') start = new Date(now - 24 * 60 * 60 * 1000);
|
|
else if (timeRange === '7d') start = new Date(now - 7 * 24 * 60 * 60 * 1000);
|
|
|
|
if (start) {
|
|
filters.timestamp = {
|
|
start: start.toISOString(),
|
|
end: now.toISOString()
|
|
};
|
|
}
|
|
}
|
|
|
|
return filters;
|
|
}
|
|
|
|
// Display events
|
|
function displayEvents(events) {
|
|
const container = document.getElementById('eventList');
|
|
|
|
if (events.length === 0) {
|
|
container.innerHTML = '<div style="padding: 40px; text-align: center; color: var(--text-secondary);">No events found matching the filters.</div>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = events.map(event => `
|
|
<div class="event-item">
|
|
<div class="event-header">
|
|
<span class="event-type ${event.hook_event_type}">${event.hook_event_type}</span>
|
|
<span style="color: var(--text-secondary); font-size: 13px;">${new Date(event.timestamp).toLocaleString()}</span>
|
|
</div>
|
|
<div class="event-summary">${event.summary || 'No summary available'}</div>
|
|
<div class="event-meta">
|
|
<span>App: <strong>${event.source_app}</strong></span>
|
|
<span>Session: <strong>${event.session_id}</strong></span>
|
|
</div>
|
|
<div class="json-viewer" style="display: none;" id="payload-${event.id}">
|
|
${JSON.stringify(event.payload, null, 2)}
|
|
</div>
|
|
<button onclick="togglePayload(${event.id})" class="secondary" style="margin-top: 10px; font-size: 12px;">Toggle Payload</button>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
// Toggle payload visibility
|
|
function togglePayload(id) {
|
|
const element = document.getElementById(`payload-${id}`);
|
|
element.style.display = element.style.display === 'none' ? 'block' : 'none';
|
|
}
|
|
|
|
// Update pagination
|
|
function updatePagination() {
|
|
document.getElementById('pageInfo').textContent = `Page ${currentPage} of ${totalPages}`;
|
|
document.getElementById('prevPageBtn').disabled = currentPage === 1;
|
|
document.getElementById('nextPageBtn').disabled = currentPage === totalPages;
|
|
}
|
|
|
|
// Update statistics
|
|
async function updateStatistics() {
|
|
const stats = await getStatistics();
|
|
const grid = document.getElementById('statsGrid');
|
|
|
|
grid.innerHTML = `
|
|
<div class="stat-card">
|
|
<div class="stat-value">${stats.total}</div>
|
|
<div class="stat-label">Total Events</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">${Object.keys(stats.byApp).length}</div>
|
|
<div class="stat-label">Apps</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">${Object.keys(stats.bySession).length}</div>
|
|
<div class="stat-label">Sessions</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-value">${Object.keys(stats.byEventType).length}</div>
|
|
<div class="stat-label">Event Types</div>
|
|
</div>
|
|
${Object.entries(stats.byEventType).map(([type, count]) => `
|
|
<div class="stat-card">
|
|
<div class="stat-value">${count}</div>
|
|
<div class="stat-label">${type}</div>
|
|
</div>
|
|
`).join('')}
|
|
`;
|
|
}
|
|
|
|
// Update filter options
|
|
async function updateFilterOptions() {
|
|
const { apps, sessions } = await getFilterOptions();
|
|
|
|
const appSelect = document.getElementById('filterApp');
|
|
const sessionSelect = document.getElementById('filterSession');
|
|
|
|
appSelect.innerHTML = '<option value="">All Apps</option>' +
|
|
apps.map(app => `<option value="${app}">${app}</option>`).join('');
|
|
|
|
sessionSelect.innerHTML = '<option value="">All Sessions</option>' +
|
|
sessions.map(session => `<option value="${session}">${session}</option>`).join('');
|
|
}
|
|
|
|
// Update event count
|
|
async function updateEventCount() {
|
|
const transaction = db.transaction([STORE_NAME], 'readonly');
|
|
const store = transaction.objectStore(STORE_NAME);
|
|
const request = store.count();
|
|
|
|
request.onsuccess = () => {
|
|
document.getElementById('eventCount').textContent = request.result;
|
|
};
|
|
}
|
|
|
|
// Estimate database size
|
|
async function updateDBSize() {
|
|
if (navigator.storage && navigator.storage.estimate) {
|
|
const estimate = await navigator.storage.estimate();
|
|
const sizeMB = (estimate.usage / (1024 * 1024)).toFixed(2);
|
|
document.getElementById('dbSize').textContent = `${sizeMB} MB`;
|
|
}
|
|
}
|
|
|
|
// Update status indicators
|
|
function updateStatus(type, status, text) {
|
|
const indicator = document.getElementById(`${type}Status`);
|
|
const textElement = document.getElementById(`${type}StatusText`);
|
|
|
|
indicator.className = 'status-indicator ' + status;
|
|
textElement.textContent = text;
|
|
}
|
|
|
|
// Event listeners
|
|
document.getElementById('connectBtn').addEventListener('click', connectWebSocket);
|
|
document.getElementById('disconnectBtn').addEventListener('click', disconnectWebSocket);
|
|
|
|
document.getElementById('importBtn').addEventListener('click', () => {
|
|
const file = document.getElementById('fileInput').files[0];
|
|
if (file) importEvents(file);
|
|
});
|
|
|
|
document.getElementById('exportBtn').addEventListener('click', async () => {
|
|
const events = await exportEvents();
|
|
const blob = new Blob([JSON.stringify(events, null, 2)], { type: 'application/json' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `hook-events-${new Date().toISOString()}.json`;
|
|
a.click();
|
|
});
|
|
|
|
document.getElementById('clearDbBtn').addEventListener('click', async () => {
|
|
if (confirm('Clear all events from database?')) {
|
|
await clearDatabase();
|
|
await executeQuery();
|
|
await updateStatistics();
|
|
await updateFilterOptions();
|
|
}
|
|
});
|
|
|
|
document.getElementById('sampleDataBtn').addEventListener('click', loadSampleData);
|
|
document.getElementById('refreshStatsBtn').addEventListener('click', updateStatistics);
|
|
|
|
document.getElementById('queryBtn').addEventListener('click', () => {
|
|
currentPage = 1;
|
|
currentQuery = buildFilters();
|
|
executeQuery();
|
|
});
|
|
|
|
document.getElementById('resetFiltersBtn').addEventListener('click', () => {
|
|
document.getElementById('filterApp').value = '';
|
|
document.getElementById('filterSession').value = '';
|
|
document.getElementById('filterEventType').value = '';
|
|
document.getElementById('filterTimeRange').value = '';
|
|
currentPage = 1;
|
|
currentQuery = {};
|
|
executeQuery();
|
|
});
|
|
|
|
document.getElementById('prevPageBtn').addEventListener('click', () => {
|
|
if (currentPage > 1) {
|
|
currentPage--;
|
|
executeQuery();
|
|
}
|
|
});
|
|
|
|
document.getElementById('nextPageBtn').addEventListener('click', () => {
|
|
if (currentPage < totalPages) {
|
|
currentPage++;
|
|
executeQuery();
|
|
}
|
|
});
|
|
|
|
// Initialize on load
|
|
(async () => {
|
|
try {
|
|
await initDB();
|
|
await executeQuery();
|
|
await updateStatistics();
|
|
await updateFilterOptions();
|
|
await updateEventCount();
|
|
await updateDBSize();
|
|
} catch (error) {
|
|
console.error('Initialization error:', error);
|
|
alert('Failed to initialize database: ' + error.message);
|
|
}
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|