Initial commit: Claude Code Monitor v1.0.0

Complete real-time monitoring dashboard for Claude Code

Features:
- FastAPI backend with REST API and WebSocket
- React + TypeScript frontend with dark theme
- SQLite database for event storage
- 6 hook scripts for Claude Code events
- Docker deployment setup
- Real-time event tracking and statistics
- Event filtering (ALL, TOOLS, AGENTS, PROMPTS, SESSIONS)
- Connection status indicator
- Reset stats functionality

Tech Stack:
- Backend: Python 3.11, FastAPI, SQLAlchemy, WebSockets
- Frontend: React 18, TypeScript, Vite
- Database: SQLite
- Deployment: Docker, Docker Compose

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
felix.zoesch
2025-12-15 09:49:06 +01:00
commit f6ef7ff5d3
48 changed files with 3375 additions and 0 deletions

26
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm ci
# Copy source code
COPY . .
# Build application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built files
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Claude Code Monitor</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

26
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,26 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
}

29
frontend/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "claude-code-monitor-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"axios": "^1.6.2"
},
"devDependencies": {
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.55.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.3.3",
"vite": "^5.0.8"
}
}

77
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,77 @@
// Main App component
import { useState, useCallback } from 'react';
import Header from './components/Header';
import StatisticsCards from './components/StatisticsCards';
import EventFeed from './components/EventFeed';
import { useWebSocket } from './hooks/useWebSocket';
import { useEvents } from './hooks/useEvents';
import { useStatistics } from './hooks/useStatistics';
import type { FilterType, Event, Statistics } from './types';
import { EVENT_FILTER_MAP } from './types';
import './styles/index.css';
function App() {
const [activeFilter, setActiveFilter] = useState<FilterType>('ALL');
// Get event types for current filter
const eventTypes = EVENT_FILTER_MAP[activeFilter];
// Hooks
const { statistics, updateStatistics, reset: resetStats } = useStatistics();
const { events, addEvent, clearEvents, hasMore, loadMore } = useEvents(eventTypes);
// WebSocket connection
const handleNewEvent = useCallback(
(event: Event) => {
// Only add event if it matches current filter
if (eventTypes.includes(event.event_type)) {
addEvent(event);
}
},
[eventTypes, addEvent]
);
const handleStatsUpdate = useCallback(
(stats: Statistics) => {
updateStatistics(stats);
},
[updateStatistics]
);
const { isConnected } = useWebSocket(handleNewEvent, handleStatsUpdate);
// Handle reset stats
const handleResetStats = useCallback(async () => {
if (confirm('Are you sure you want to reset all statistics? This will delete all events.')) {
await resetStats();
clearEvents();
}
}, [resetStats, clearEvents]);
// Handle filter change
const handleFilterChange = useCallback((filter: FilterType) => {
setActiveFilter(filter);
}, []);
return (
<div className="app">
<Header isConnected={isConnected} onResetStats={handleResetStats} />
<main className="main-content">
<StatisticsCards statistics={statistics} />
<EventFeed
events={events}
loading={false}
activeFilter={activeFilter}
onFilterChange={handleFilterChange}
onLoadMore={loadMore}
hasMore={hasMore}
/>
</main>
</div>
);
}
export default App;

View File

@@ -0,0 +1,54 @@
// HTTP API client using axios
import axios from 'axios';
import type { Event, Statistics, EventListResponse } from '../types';
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000';
const apiClient = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json',
},
timeout: 10000,
});
// API functions
export const fetchEvents = async (
page: number = 1,
pageSize: number = 50,
eventTypes?: string[]
): Promise<EventListResponse> => {
const params: any = {
skip: (page - 1) * pageSize,
limit: pageSize,
};
if (eventTypes && eventTypes.length > 0) {
params.event_type = eventTypes.join(',');
}
const response = await apiClient.get<EventListResponse>('/api/events', { params });
return response.data;
};
export const fetchStatistics = async (): Promise<Statistics> => {
const response = await apiClient.get<Statistics>('/api/statistics');
return response.data;
};
export const resetStatistics = async (): Promise<void> => {
await apiClient.delete('/api/events/reset');
};
export const checkHealth = async (): Promise<boolean> => {
try {
await apiClient.get('/health');
return true;
} catch {
return false;
}
};
export default apiClient;

View File

@@ -0,0 +1,156 @@
// WebSocket client for real-time updates
import type { WebSocketMessage } from '../types';
const WS_URL = import.meta.env.VITE_WS_URL || 'ws://localhost:8000/ws';
type MessageHandler = (message: WebSocketMessage) => void;
type ConnectionHandler = (connected: boolean) => void;
export class WebSocketClient {
private ws: WebSocket | null = null;
private messageHandlers: MessageHandler[] = [];
private connectionHandlers: ConnectionHandler[] = [];
private reconnectAttempts = 0;
private maxReconnectAttempts = 10;
private reconnectDelay = 1000; // Start with 1 second
private heartbeatInterval: number | null = null;
constructor() {
this.connect();
}
private connect() {
try {
this.ws = new WebSocket(WS_URL);
this.ws.onopen = () => {
console.log('WebSocket connected');
this.reconnectAttempts = 0;
this.reconnectDelay = 1000;
this.notifyConnectionHandlers(true);
this.startHeartbeat();
};
this.ws.onmessage = (event) => {
try {
const message: WebSocketMessage = JSON.parse(event.data);
this.notifyMessageHandlers(message);
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
this.ws.onerror = (error) => {
console.error('WebSocket error:', error);
};
this.ws.onclose = () => {
console.log('WebSocket disconnected');
this.stopHeartbeat();
this.notifyConnectionHandlers(false);
this.attemptReconnect();
};
} catch (error) {
console.error('Error creating WebSocket:', error);
this.attemptReconnect();
}
}
private attemptReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('Max reconnect attempts reached');
return;
}
this.reconnectAttempts++;
const delay = Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), 30000);
console.log(`Reconnecting in ${delay}ms... (attempt ${this.reconnectAttempts})`);
setTimeout(() => {
this.connect();
}, delay);
}
private startHeartbeat() {
this.heartbeatInterval = window.setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.send({ type: 'ping', timestamp: Date.now() });
}
}, 30000); // Send ping every 30 seconds
}
private stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
private notifyMessageHandlers(message: WebSocketMessage) {
this.messageHandlers.forEach((handler) => {
try {
handler(message);
} catch (error) {
console.error('Error in message handler:', error);
}
});
}
private notifyConnectionHandlers(connected: boolean) {
this.connectionHandlers.forEach((handler) => {
try {
handler(connected);
} catch (error) {
console.error('Error in connection handler:', error);
}
});
}
public onMessage(handler: MessageHandler): () => void {
this.messageHandlers.push(handler);
// Return unsubscribe function
return () => {
this.messageHandlers = this.messageHandlers.filter((h) => h !== handler);
};
}
public onConnectionChange(handler: ConnectionHandler): () => void {
this.connectionHandlers.push(handler);
// Return unsubscribe function
return () => {
this.connectionHandlers = this.connectionHandlers.filter((h) => h !== handler);
};
}
public send(message: any) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(message));
}
}
public disconnect() {
this.stopHeartbeat();
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
public isConnected(): boolean {
return this.ws?.readyState === WebSocket.OPEN;
}
}
// Singleton instance
let wsClient: WebSocketClient | null = null;
export const getWebSocketClient = (): WebSocketClient => {
if (!wsClient) {
wsClient = new WebSocketClient();
}
return wsClient;
};
export default getWebSocketClient;

View File

@@ -0,0 +1,92 @@
// Event card component displaying a single event
import React from 'react';
import type { Event } from '../types';
interface EventCardProps {
event: Event;
}
const formatTimestamp = (timestamp: number): string => {
const date = new Date(timestamp * 1000);
return date.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
fractionalSecondDigits: 3,
hour12: false,
});
};
const getEventColor = (eventType: string): string => {
switch (eventType) {
case 'PreToolUse':
return 'cyan';
case 'PostToolUse':
return 'green';
case 'SessionStart':
return 'blue';
case 'SessionEnd':
return 'blue';
case 'SubagentStop':
return 'magenta';
case 'UserPromptSubmit':
return 'yellow';
default:
return 'gray';
}
};
const getSuccessIcon = (success?: boolean | null): string => {
if (success === null || success === undefined) return '▶';
return success ? '✓' : '✗';
};
const formatToolInput = (toolInput: any): string => {
if (!toolInput) return '';
if (typeof toolInput === 'string') return toolInput;
if (typeof toolInput === 'object') {
// For Bash commands, show the command
if (toolInput.command) return `$ ${toolInput.command}`;
return JSON.stringify(toolInput, null, 2);
}
return String(toolInput);
};
export const EventCard: React.FC<EventCardProps> = ({ event }) => {
const color = getEventColor(event.event_type);
const successIcon = getSuccessIcon(event.success);
const formattedInput = formatToolInput(event.tool_input);
return (
<div className={`event-card ${color}-border`}>
<div className="event-header">
<span className={`event-type ${color}`}>{event.event_type}</span>
{event.tool_name && <span className="event-tool">{event.tool_name}</span>}
<span className="event-timestamp">{formatTimestamp(event.timestamp)}</span>
</div>
<div className="event-body">
<div className="event-status">
<span className={`status-icon ${event.success ? 'success' : event.success === false ? 'error' : 'pending'}`}>
{successIcon}
</span>
</div>
{formattedInput && (
<div className="event-command">
<code>{formattedInput}</code>
</div>
)}
{event.description && (
<div className="event-description">
{event.description}
</div>
)}
</div>
</div>
);
};
export default EventCard;

View File

@@ -0,0 +1,85 @@
// Event feed component displaying a list of events
import React, { useEffect } from 'react';
import EventCard from './EventCard';
import FilterButtons from './FilterButtons';
import type { Event, FilterType, EventType } from '../types';
import { EVENT_FILTER_MAP } from '../types';
interface EventFeedProps {
events: Event[];
loading: boolean;
activeFilter: FilterType;
onFilterChange: (filter: FilterType) => void;
onLoadMore?: () => void;
hasMore?: boolean;
}
export const EventFeed: React.FC<EventFeedProps> = ({
events,
loading,
activeFilter,
onFilterChange,
onLoadMore,
hasMore,
}) => {
// Filter events by active filter
const filteredEvents = events.filter((event) => {
const allowedTypes: EventType[] = EVENT_FILTER_MAP[activeFilter];
return allowedTypes.includes(event.event_type);
});
// Scroll handler for infinite scroll
useEffect(() => {
const handleScroll = () => {
if (!onLoadMore || !hasMore || loading) return;
const feedElement = document.querySelector('.event-feed');
if (!feedElement) return;
const scrollPosition = feedElement.scrollTop + feedElement.clientHeight;
const scrollThreshold = feedElement.scrollHeight - 100;
if (scrollPosition >= scrollThreshold) {
onLoadMore();
}
};
const feedElement = document.querySelector('.event-feed');
if (feedElement) {
feedElement.addEventListener('scroll', handleScroll);
return () => feedElement.removeEventListener('scroll', handleScroll);
}
}, [onLoadMore, hasMore, loading]);
return (
<div className="event-feed-container">
<div className="tabs">
<button className="tab active">📋 EVENT FEED</button>
<button className="tab">🌐 AGENTS GRAPH</button>
</div>
<FilterButtons activeFilter={activeFilter} onFilterChange={onFilterChange} />
<div className="event-feed">
{loading && events.length === 0 ? (
<div className="loading">Loading events...</div>
) : filteredEvents.length === 0 ? (
<div className="empty-state">
<p>No events yet</p>
<p className="empty-hint">Start using Claude Code to see events appear here in real-time</p>
</div>
) : (
<>
{filteredEvents.map((event) => (
<EventCard key={event.id} event={event} />
))}
{loading && <div className="loading-more">Loading more...</div>}
</>
)}
</div>
</div>
);
};
export default EventFeed;

View File

@@ -0,0 +1,29 @@
// Filter buttons component for filtering events by category
import React from 'react';
import type { FilterType } from '../types';
interface FilterButtonsProps {
activeFilter: FilterType;
onFilterChange: (filter: FilterType) => void;
}
const FILTERS: FilterType[] = ['ALL', 'TOOLS', 'AGENTS', 'PROMPTS', 'SESSIONS'];
export const FilterButtons: React.FC<FilterButtonsProps> = ({ activeFilter, onFilterChange }) => {
return (
<div className="filter-buttons">
{FILTERS.map((filter) => (
<button
key={filter}
className={`filter-button ${activeFilter === filter ? 'active' : ''}`}
onClick={() => onFilterChange(filter)}
>
{filter}
</button>
))}
</div>
);
};
export default FilterButtons;

View File

@@ -0,0 +1,30 @@
// Header component with title, reset button, and connection status
import React from 'react';
interface HeaderProps {
isConnected: boolean;
onResetStats: () => void;
}
export const Header: React.FC<HeaderProps> = ({ isConnected, onResetStats }) => {
return (
<header className="header">
<div className="header-left">
<div className="logo"></div>
<h1 className="title">CLAUDE CODE MONITOR</h1>
</div>
<div className="header-right">
<button className="reset-button" onClick={onResetStats}>
🗑 RESET STATS
</button>
<div className={`connection-status ${isConnected ? 'connected' : 'disconnected'}`}>
<span className="status-dot"></span>
<span className="status-text">{isConnected ? 'CONNECTED' : 'DISCONNECTED'}</span>
</div>
</div>
</header>
);
};
export default Header;

View File

@@ -0,0 +1,43 @@
// Statistics cards component displaying total events, tools, agents, and sessions
import React from 'react';
import type { Statistics } from '../types';
interface StatisticsCardsProps {
statistics: Statistics | null;
}
export const StatisticsCards: React.FC<StatisticsCardsProps> = ({ statistics }) => {
const stats = statistics || {
total_events: 0,
total_tools_used: 0,
total_agents: 0,
total_sessions: 0,
};
return (
<div className="statistics-grid">
<div className="stat-card">
<div className="stat-label">TOTAL EVENTS</div>
<div className="stat-value cyan">{stats.total_events}</div>
</div>
<div className="stat-card">
<div className="stat-label">TOOLS USED</div>
<div className="stat-value green">{stats.total_tools_used}</div>
</div>
<div className="stat-card">
<div className="stat-label">AGENTS</div>
<div className="stat-value magenta">{stats.total_agents}</div>
</div>
<div className="stat-card">
<div className="stat-label">SESSIONS</div>
<div className="stat-value yellow">{stats.total_sessions}</div>
</div>
</div>
);
};
export default StatisticsCards;

View File

@@ -0,0 +1,87 @@
// React hook for fetching and managing events
import { useState, useEffect, useCallback } from 'react';
import { fetchEvents } from '../api/client';
import type { Event, EventType } from '../types';
interface UseEventsReturn {
events: Event[];
loading: boolean;
error: string | null;
hasMore: boolean;
loadMore: () => Promise<void>;
refresh: () => Promise<void>;
addEvent: (event: Event) => void;
clearEvents: () => void;
}
export const useEvents = (eventTypes?: EventType[]): UseEventsReturn => {
const [events, setEvents] = useState<Event[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const loadEvents = useCallback(
async (pageNum: number, append: boolean = false) => {
try {
setLoading(true);
setError(null);
const response = await fetchEvents(pageNum, 50, eventTypes);
if (append) {
setEvents((prev) => [...prev, ...response.events]);
} else {
setEvents(response.events);
}
setHasMore(response.has_more);
setPage(pageNum);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load events');
console.error('Error loading events:', err);
} finally {
setLoading(false);
}
},
[eventTypes]
);
// Initial load
useEffect(() => {
loadEvents(1, false);
}, [loadEvents]);
const loadMore = useCallback(async () => {
if (!hasMore || loading) return;
await loadEvents(page + 1, true);
}, [hasMore, loading, page, loadEvents]);
const refresh = useCallback(async () => {
await loadEvents(1, false);
}, [loadEvents]);
const addEvent = useCallback((event: Event) => {
setEvents((prev) => [event, ...prev]);
}, []);
const clearEvents = useCallback(() => {
setEvents([]);
setPage(1);
setHasMore(true);
}, []);
return {
events,
loading,
error,
hasMore,
loadMore,
refresh,
addEvent,
clearEvents,
};
};
export default useEvents;

View File

@@ -0,0 +1,80 @@
// React hook for fetching and managing statistics
import { useState, useEffect, useCallback } from 'react';
import { fetchStatistics, resetStatistics as resetStatsAPI } from '../api/client';
import type { Statistics } from '../types';
interface UseStatisticsReturn {
statistics: Statistics | null;
loading: boolean;
error: string | null;
refresh: () => Promise<void>;
reset: () => Promise<void>;
updateStatistics: (stats: Statistics) => void;
}
export const useStatistics = (): UseStatisticsReturn => {
const [statistics, setStatistics] = useState<Statistics | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadStatistics = useCallback(async () => {
try {
setLoading(true);
setError(null);
const stats = await fetchStatistics();
setStatistics(stats);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load statistics');
console.error('Error loading statistics:', err);
} finally {
setLoading(false);
}
}, []);
// Initial load
useEffect(() => {
loadStatistics();
}, [loadStatistics]);
const refresh = useCallback(async () => {
await loadStatistics();
}, [loadStatistics]);
const reset = useCallback(async () => {
try {
setLoading(true);
setError(null);
await resetStatsAPI();
// Reset local statistics
setStatistics({
total_events: 0,
total_tools_used: 0,
total_agents: 0,
total_sessions: 0,
last_updated: Date.now() / 1000,
});
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reset statistics');
console.error('Error resetting statistics:', err);
} finally {
setLoading(false);
}
}, []);
const updateStatistics = useCallback((stats: Statistics) => {
setStatistics(stats);
}, []);
return {
statistics,
loading,
error,
refresh,
reset,
updateStatistics,
};
};
export default useStatistics;

View File

@@ -0,0 +1,106 @@
// React hook for WebSocket connection
import { useEffect, useState, useCallback } from 'react';
import { getWebSocketClient } from '../api/websocket';
import type { WebSocketMessage, Event, Statistics } from '../types';
interface UseWebSocketReturn {
isConnected: boolean;
latestEvent: Event | null;
latestStatistics: Statistics | null;
send: (message: any) => void;
}
export const useWebSocket = (
onEvent?: (event: Event) => void,
onStats?: (stats: Statistics) => void
): UseWebSocketReturn => {
const [isConnected, setIsConnected] = useState(false);
const [latestEvent, setLatestEvent] = useState<Event | null>(null);
const [latestStatistics, setLatestStatistics] = useState<Statistics | null>(null);
useEffect(() => {
const wsClient = getWebSocketClient();
// Handle connection status changes
const unsubscribeConnection = wsClient.onConnectionChange((connected) => {
setIsConnected(connected);
});
// Handle incoming messages
const unsubscribeMessage = wsClient.onMessage((message: WebSocketMessage) => {
switch (message.type) {
case 'event_created':
if (message.event) {
setLatestEvent(message.event);
onEvent?.(message.event);
}
if (message.statistics) {
setLatestStatistics(message.statistics);
onStats?.(message.statistics);
}
break;
case 'stats_updated':
if (message.statistics) {
setLatestStatistics(message.statistics);
onStats?.(message.statistics);
}
break;
case 'stats_reset':
// Reset local state
setLatestEvent(null);
setLatestStatistics({
total_events: 0,
total_tools_used: 0,
total_agents: 0,
total_sessions: 0,
last_updated: Date.now() / 1000,
});
onStats?.({
total_events: 0,
total_tools_used: 0,
total_agents: 0,
total_sessions: 0,
last_updated: Date.now() / 1000,
});
break;
case 'connection_status':
console.log('Connection status:', message.message);
break;
case 'pong':
// Heartbeat response
break;
default:
console.log('Unknown message type:', message.type);
}
});
// Set initial connection status
setIsConnected(wsClient.isConnected());
// Cleanup
return () => {
unsubscribeConnection();
unsubscribeMessage();
};
}, [onEvent, onStats]);
const send = useCallback((message: any) => {
const wsClient = getWebSocketClient();
wsClient.send(message);
}, []);
return {
isConnected,
latestEvent,
latestStatistics,
send,
};
};
export default useWebSocket;

9
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,9 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,492 @@
/* Dark theme for Claude Code Monitor */
:root {
--bg-dark: #0a0e27;
--bg-card: #1a1e3f;
--bg-card-hover: #232850;
--text-primary: #e0e0e0;
--text-secondary: #a0a0a0;
--border-color: #2a2e4f;
--cyan: #00d9ff;
--green: #00ff88;
--magenta: #ff00ff;
--yellow: #ffdd00;
--blue: #4080ff;
--gray: #808080;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Courier New', monospace;
background-color: var(--bg-dark);
color: var(--text-primary);
line-height: 1.6;
}
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Header */
.header {
background-color: var(--bg-card);
padding: 1.5rem 2rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid var(--border-color);
}
.header-left {
display: flex;
align-items: center;
gap: 1rem;
}
.logo {
font-size: 2rem;
}
.title {
font-size: 1.8rem;
font-weight: bold;
letter-spacing: 0.1em;
background: linear-gradient(90deg, var(--cyan), var(--green));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.header-right {
display: flex;
align-items: center;
gap: 1.5rem;
}
.reset-button {
background-color: #2a3f5f;
color: var(--text-primary);
border: 1px solid var(--border-color);
padding: 0.6rem 1.2rem;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-size: 0.9rem;
font-weight: bold;
letter-spacing: 0.05em;
transition: all 0.2s;
}
.reset-button:hover {
background-color: #3a4f6f;
border-color: var(--cyan);
}
.connection-status {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1rem;
border-radius: 4px;
}
.connection-status.connected {
background-color: rgba(0, 255, 136, 0.1);
}
.connection-status.disconnected {
background-color: rgba(255, 68, 68, 0.1);
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
animation: pulse 2s infinite;
}
.connected .status-dot {
background-color: var(--green);
}
.disconnected .status-dot {
background-color: #ff4444;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.status-text {
font-size: 0.85rem;
font-weight: bold;
letter-spacing: 0.05em;
}
.connected .status-text {
color: var(--green);
}
.disconnected .status-text {
color: #ff4444;
}
/* Main Content */
.main-content {
flex: 1;
padding: 2rem;
max-width: 1800px;
margin: 0 auto;
width: 100%;
}
/* Statistics Grid */
.statistics-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1.5rem;
margin-bottom: 2rem;
}
@media (max-width: 1200px) {
.statistics-grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 600px) {
.statistics-grid {
grid-template-columns: 1fr;
}
}
.stat-card {
background-color: var(--bg-card);
border: 2px solid var(--border-color);
border-radius: 8px;
padding: 1.5rem;
text-align: center;
transition: all 0.3s;
}
.stat-card:hover {
background-color: var(--bg-card-hover);
transform: translateY(-2px);
}
.stat-label {
font-size: 0.85rem;
color: var(--text-secondary);
font-weight: bold;
letter-spacing: 0.1em;
margin-bottom: 0.8rem;
}
.stat-value {
font-size: 3rem;
font-weight: bold;
}
.stat-value.cyan {
color: var(--cyan);
}
.stat-value.green {
color: var(--green);
}
.stat-value.magenta {
color: var(--magenta);
}
.stat-value.yellow {
color: var(--yellow);
}
/* Event Feed Container */
.event-feed-container {
background-color: var(--bg-card);
border: 2px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
}
/* Tabs */
.tabs {
display: flex;
gap: 0.5rem;
padding: 1rem 1rem 0 1rem;
background-color: var(--bg-dark);
}
.tab {
background-color: transparent;
color: var(--text-secondary);
border: none;
border-bottom: 2px solid transparent;
padding: 0.8rem 1.5rem;
cursor: pointer;
font-family: inherit;
font-size: 0.9rem;
font-weight: bold;
letter-spacing: 0.05em;
transition: all 0.2s;
}
.tab.active {
color: var(--cyan);
border-bottom-color: var(--cyan);
background-color: var(--bg-card);
}
.tab:hover:not(.active) {
color: var(--text-primary);
}
/* Filter Buttons */
.filter-buttons {
display: flex;
gap: 0.5rem;
padding: 1rem;
background-color: var(--bg-dark);
border-top: 1px solid var(--border-color);
}
.filter-button {
background-color: transparent;
color: var(--text-secondary);
border: 1px solid var(--border-color);
padding: 0.5rem 1.2rem;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
font-size: 0.85rem;
font-weight: bold;
letter-spacing: 0.05em;
transition: all 0.2s;
}
.filter-button:hover {
border-color: var(--cyan);
color: var(--text-primary);
}
.filter-button.active {
background-color: var(--cyan);
color: var(--bg-dark);
border-color: var(--cyan);
}
/* Event Feed */
.event-feed {
height: 600px;
overflow-y: auto;
padding: 1rem;
}
.event-feed::-webkit-scrollbar {
width: 8px;
}
.event-feed::-webkit-scrollbar-track {
background: var(--bg-dark);
}
.event-feed::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
.event-feed::-webkit-scrollbar-thumb:hover {
background: var(--cyan);
}
/* Event Card */
.event-card {
background-color: var(--bg-dark);
border-left: 4px solid var(--border-color);
border-radius: 4px;
padding: 1rem;
margin-bottom: 0.8rem;
transition: all 0.2s;
}
.event-card:hover {
background-color: #121633;
transform: translateX(4px);
}
.event-card.cyan-border {
border-left-color: var(--cyan);
}
.event-card.green-border {
border-left-color: var(--green);
}
.event-card.blue-border {
border-left-color: var(--blue);
}
.event-card.magenta-border {
border-left-color: var(--magenta);
}
.event-card.yellow-border {
border-left-color: var(--yellow);
}
.event-card.gray-border {
border-left-color: var(--gray);
}
.event-header {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 0.8rem;
font-size: 0.85rem;
}
.event-type {
font-weight: bold;
padding: 0.2rem 0.6rem;
border-radius: 3px;
letter-spacing: 0.05em;
}
.event-type.cyan {
background-color: rgba(0, 217, 255, 0.15);
color: var(--cyan);
}
.event-type.green {
background-color: rgba(0, 255, 136, 0.15);
color: var(--green);
}
.event-type.blue {
background-color: rgba(64, 128, 255, 0.15);
color: var(--blue);
}
.event-type.magenta {
background-color: rgba(255, 0, 255, 0.15);
color: var(--magenta);
}
.event-type.yellow {
background-color: rgba(255, 221, 0, 0.15);
color: var(--yellow);
}
.event-tool {
background-color: rgba(128, 128, 128, 0.15);
color: var(--text-primary);
padding: 0.2rem 0.6rem;
border-radius: 3px;
font-weight: bold;
}
.event-timestamp {
margin-left: auto;
color: var(--text-secondary);
font-size: 0.8rem;
}
.event-body {
display: flex;
gap: 1rem;
align-items: flex-start;
}
.event-status {
flex-shrink: 0;
}
.status-icon {
display: inline-block;
width: 24px;
height: 24px;
line-height: 24px;
text-align: center;
border-radius: 50%;
font-weight: bold;
}
.status-icon.success {
background-color: rgba(0, 255, 136, 0.2);
color: var(--green);
}
.status-icon.error {
background-color: rgba(255, 68, 68, 0.2);
color: #ff4444;
}
.status-icon.pending {
background-color: rgba(0, 217, 255, 0.2);
color: var(--cyan);
}
.event-command {
flex: 1;
background-color: rgba(0, 0, 0, 0.3);
padding: 0.6rem;
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 0.85rem;
overflow-x: auto;
}
.event-command code {
color: var(--text-primary);
white-space: pre-wrap;
word-break: break-all;
}
.event-description {
flex: 1;
color: var(--text-secondary);
font-size: 0.9rem;
font-style: italic;
}
/* Loading and Empty States */
.loading,
.loading-more,
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-secondary);
}
.loading {
font-size: 1.1rem;
}
.loading-more {
font-size: 0.9rem;
padding: 1rem;
}
.empty-state {
padding: 4rem 2rem;
}
.empty-state p {
margin-bottom: 0.5rem;
}
.empty-hint {
font-size: 0.9rem;
color: var(--text-secondary);
}

View File

@@ -0,0 +1,57 @@
// Type definitions for Claude Code Monitor
export type EventType =
| 'PreToolUse'
| 'PostToolUse'
| 'SessionStart'
| 'SessionEnd'
| 'SubagentStop'
| 'UserPromptSubmit';
export type FilterType = 'ALL' | 'TOOLS' | 'AGENTS' | 'PROMPTS' | 'SESSIONS';
export interface Event {
id: number;
session_id: string;
event_type: EventType;
tool_name?: string | null;
tool_input?: any | null;
tool_output?: string | null;
success?: boolean | null;
description?: string | null;
timestamp: number;
created_at: string;
}
export interface Statistics {
total_events: number;
total_tools_used: number;
total_agents: number;
total_sessions: number;
last_updated: number;
}
export interface WebSocketMessage {
type: 'event_created' | 'stats_updated' | 'connection_status' | 'stats_reset' | 'pong';
event?: Event;
statistics?: Statistics;
message?: string;
timestamp?: number;
}
export interface EventListResponse {
events: Event[];
total: number;
page: number;
page_size: number;
has_more: boolean;
}
// Filter mapping for event types
export const EVENT_FILTER_MAP: Record<FilterType, EventType[]> = {
ALL: ['PreToolUse', 'PostToolUse', 'SessionStart', 'SessionEnd', 'SubagentStop', 'UserPromptSubmit'],
TOOLS: ['PreToolUse', 'PostToolUse'],
AGENTS: ['SubagentStop'],
PROMPTS: ['UserPromptSubmit'],
SESSIONS: ['SessionStart', 'SessionEnd'],
};

10
frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
readonly VITE_WS_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

25
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

20
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/ws': {
target: 'ws://localhost:8000',
ws: true,
},
},
},
})