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:
26
frontend/Dockerfile
Normal file
26
frontend/Dockerfile
Normal 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
13
frontend/index.html
Normal 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
26
frontend/nginx.conf
Normal 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
29
frontend/package.json
Normal 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
77
frontend/src/App.tsx
Normal 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;
|
||||
54
frontend/src/api/client.ts
Normal file
54
frontend/src/api/client.ts
Normal 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;
|
||||
156
frontend/src/api/websocket.ts
Normal file
156
frontend/src/api/websocket.ts
Normal 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;
|
||||
92
frontend/src/components/EventCard.tsx
Normal file
92
frontend/src/components/EventCard.tsx
Normal 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;
|
||||
85
frontend/src/components/EventFeed.tsx
Normal file
85
frontend/src/components/EventFeed.tsx
Normal 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;
|
||||
29
frontend/src/components/FilterButtons.tsx
Normal file
29
frontend/src/components/FilterButtons.tsx
Normal 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;
|
||||
30
frontend/src/components/Header.tsx
Normal file
30
frontend/src/components/Header.tsx
Normal 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;
|
||||
43
frontend/src/components/StatisticsCards.tsx
Normal file
43
frontend/src/components/StatisticsCards.tsx
Normal 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;
|
||||
87
frontend/src/hooks/useEvents.ts
Normal file
87
frontend/src/hooks/useEvents.ts
Normal 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;
|
||||
80
frontend/src/hooks/useStatistics.ts
Normal file
80
frontend/src/hooks/useStatistics.ts
Normal 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;
|
||||
106
frontend/src/hooks/useWebSocket.ts
Normal file
106
frontend/src/hooks/useWebSocket.ts
Normal 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
9
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
492
frontend/src/styles/index.css
Normal file
492
frontend/src/styles/index.css
Normal 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);
|
||||
}
|
||||
57
frontend/src/types/index.ts
Normal file
57
frontend/src/types/index.ts
Normal 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
10
frontend/src/vite-env.d.ts
vendored
Normal 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
25
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
20
frontend/vite.config.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user