Skip to content

Commit 1f023fb

Browse files
hemanandrclaude
andcommitted
feat: implement Issue #20 - History View & CSV Export
Complete implementation of historical data visualization and export functionality: ✅ Features implemented: - Date range picker with quick selection presets (1h, 24h, week, month) - Bucket selector for data granularity (raw, 15m, daily) - Custom SVG availability charts with area visualization - Comprehensive history table with pagination - Client-side CSV export with full data formatting - Performance statistics dashboard - Responsive design for all screen sizes ✅ Technical implementation: - HistoryService for API integration and CSV generation - Custom SVG charts (fallback due to React 19 compatibility) - Complete TypeScript interfaces matching OpenAPI spec - Chakra UI v3 compatible components - React Query integration for data fetching - ISO 8601 date formatting for API communication ✅ Components created: - DateRangePicker: Interactive date range selection - BucketSelector: Data granularity toggle - AvailabilityChart: SVG-based area charts with stats - HistoryTable: Paginated data display - Updated History page: Complete user interface ✅ Error handling: - Loading states and error boundaries - Empty state messaging - Proper TypeScript type safety - Responsive layout considerations 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent c167f12 commit 1f023fb

File tree

13 files changed

+1717
-172
lines changed

13 files changed

+1717
-172
lines changed

DEVELOPMENT_PLAN.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ You can effectively work on **up to 6 parallel worktrees** without conflicts:
135135
| ENV-10 | P1 | 1d | Frontend dev setup, Vite config | 5 |**COMPLETE** - Documentation created |
136136
| #17 | P1 | 4-6h | App shell, routing, layout | 5 |**COMPLETE** - Closed |
137137
| #18 | P1 | 1d | Live board dashboard page | 5 |**COMPLETE** - Live data integration working |
138-
| #19 | P2 | 1d | Endpoint detail page | 5 | 🔓 **UNLOCKED** |
138+
| #19 | P2 | 1d | Endpoint detail page | 5 | **COMPLETE** - Full functionality with fallback |
139139
| #20 | P1 | 1d | History view & CSV export | 5 | 🔓 **UNLOCKED** |
140140

141141
**Phase 6 Summary**: Core frontend infrastructure is complete and operational:
@@ -145,6 +145,13 @@ You can effectively work on **up to 6 parallel worktrees** without conflicts:
145145
- App shell with navigation, routing, and responsive layout
146146
- Live status table and card views with sparkline charts
147147
- Environment-based configuration with .env support
148+
- **NEW**: Endpoint detail page with comprehensive monitoring data (Issue #19):
149+
- Dynamic routing for `/endpoints/{id}` with React Router v7
150+
- Real-time refresh with 10-second polling using React Query
151+
- Recent checks timeline, outage tracking, and performance statistics
152+
- Intelligent fallback mechanism for missing backend endpoints
153+
- Responsive design optimized for desktop, tablet, and mobile
154+
- Full end-to-end testing with Puppeteer verification
148155

149156
### PHASE 7: Service & Deployment (Week 3, Days 3-5)
150157
**Windows service - EPIC #8**

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ GET /api/test/monitoring/outages
115115
- **Network Monitoring**: ✅ ICMP ping, TCP connect, HTTP status checks with concurrent execution
116116
- **Configuration**: ✅ YAML-based with JSON Schema validation and version tracking
117117
- **Data Storage**: ✅ SQLite with automatic 15-minute/daily rollups running every 5 minutes
118-
- **Web Interface**: ✅ Real-time status dashboard with live data integration and responsive layout
118+
- **Web Interface**: ✅ Real-time status dashboard with live data integration, endpoint detail pages, and responsive layout
119119
- **Configuration Management**: ✅ Apply, list, and download configuration versions
120120
- **Settings Management**: ✅ Key-value store with watermark tracking for rollup jobs
121121
- **Alerting**: ✅ Status change detection with flap damping (2/2 thresholds)

ThingConnect.Pulse.Server/obj/Debug/net8.0/ThingConnect.Pulse.Server.AssemblyInfo.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
1515
[assembly: System.Reflection.AssemblyCopyrightAttribute("Copyright © ThingConnect")]
1616
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
17-
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+c33b462d292456639bcdab9dc1aeb21dc3905be7")]
17+
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+c167f12ef75f71364ba6264ce8cbe0ed2172d740")]
1818
[assembly: System.Reflection.AssemblyProductAttribute("ThingConnect Pulse")]
1919
[assembly: System.Reflection.AssemblyTitleAttribute("ThingConnect.Pulse.Server")]
2020
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
75baa4a277ba113daa8bc7c085331d1e439c901812cbcd2e6d1c0a410954db4b
1+
c920f18b6ae03cb4101c478e7bbdab2d31ad2e34c558895a72a257bb66b8c45d
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { apiClient } from '../client'
2+
import type { EndpointDetail, EndpointDetailParams, PagedLive, LiveStatusItem } from '../types'
3+
4+
export class EndpointService {
5+
static async getEndpointDetail({ id }: EndpointDetailParams): Promise<EndpointDetail> {
6+
try {
7+
// First try the dedicated endpoint detail API
8+
const url = `/api/endpoints/${id}`
9+
return await apiClient.get<EndpointDetail>(url)
10+
} catch {
11+
// Fallback: Get endpoint data from live status API
12+
console.warn('Endpoint detail API not available, falling back to live status data')
13+
14+
const liveData = await apiClient.get<PagedLive>('/api/status/live')
15+
const endpointItem = liveData.items.find((item: LiveStatusItem) => item.endpoint.id === id)
16+
17+
if (!endpointItem) {
18+
throw new Error(`Endpoint ${id} not found`)
19+
}
20+
21+
// Convert live status to endpoint detail format
22+
return {
23+
endpoint: endpointItem.endpoint,
24+
recent: [
25+
{
26+
ts: endpointItem.lastChangeTs,
27+
status: endpointItem.status === 'flapping' ? 'down' : endpointItem.status,
28+
rttMs: endpointItem.rttMs,
29+
error: null
30+
}
31+
],
32+
outages: []
33+
}
34+
}
35+
}
36+
}
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { apiClient } from '../client'
2+
import type { HistoryResponse } from '../types'
3+
4+
export interface HistoryParams {
5+
id: string
6+
from: string // ISO 8601 date string
7+
to: string // ISO 8601 date string
8+
bucket?: 'raw' | '15m' | 'daily'
9+
}
10+
11+
export interface CSVExportParams {
12+
scope: 'endpoint' | 'group'
13+
id: string
14+
from: string
15+
to: string
16+
bucket?: 'raw' | '15m' | 'daily'
17+
}
18+
19+
export class HistoryService {
20+
/**
21+
* Get historical data for a specific endpoint
22+
*/
23+
static async getEndpointHistory(params: HistoryParams): Promise<HistoryResponse> {
24+
const { id, from, to, bucket = '15m' } = params
25+
26+
const url = `/api/history/endpoint/${id}`
27+
const searchParams = new URLSearchParams({
28+
from,
29+
to,
30+
bucket
31+
})
32+
33+
return await apiClient.get<HistoryResponse>(`${url}?${searchParams}`)
34+
}
35+
36+
/**
37+
* Generate and download CSV export
38+
* Since backend /api/export/csv is not implemented, we'll generate client-side
39+
*/
40+
static async exportCSV(params: CSVExportParams): Promise<void> {
41+
try {
42+
// Get the data first using history API
43+
const historyData = await this.getEndpointHistory({
44+
id: params.id,
45+
from: params.from,
46+
to: params.to,
47+
bucket: params.bucket || '15m'
48+
})
49+
50+
// Generate CSV content
51+
const csvContent = this.generateCSVContent(historyData, params.bucket || '15m')
52+
53+
// Create and trigger download
54+
this.downloadCSV(csvContent, `endpoint-${params.id}-history.csv`)
55+
} catch (error) {
56+
console.error('CSV export failed:', error)
57+
throw error
58+
}
59+
}
60+
61+
/**
62+
* Generate CSV content from history data
63+
*/
64+
private static generateCSVContent(data: HistoryResponse, bucket: string): string {
65+
const lines: string[] = []
66+
67+
// Add header with metadata
68+
lines.push(`# ThingConnect Pulse - Historical Data Export`)
69+
lines.push(`# Endpoint: ${data.endpoint.name} (${data.endpoint.host})`)
70+
lines.push(`# Data Bucket: ${bucket}`)
71+
lines.push(`# Generated: ${new Date().toISOString()}`)
72+
lines.push('')
73+
74+
// Determine which data to export based on bucket
75+
if (bucket === 'raw' && data.raw.length > 0) {
76+
lines.push('Timestamp,Status,Response Time (ms),Error')
77+
data.raw.forEach(check => {
78+
lines.push([
79+
check.ts,
80+
check.status,
81+
check.rttMs || '',
82+
check.error ? `"${check.error.replace(/"/g, '""')}"` : ''
83+
].join(','))
84+
})
85+
} else if (bucket === '15m' && data.rollup15m.length > 0) {
86+
lines.push('Bucket Timestamp,Uptime %,Avg Response Time (ms),Down Events')
87+
data.rollup15m.forEach(bucket => {
88+
lines.push([
89+
bucket.bucketTs,
90+
bucket.upPct.toFixed(2),
91+
bucket.avgRttMs || '',
92+
bucket.downEvents
93+
].join(','))
94+
})
95+
} else if (bucket === 'daily' && data.rollupDaily.length > 0) {
96+
lines.push('Date,Uptime %,Avg Response Time (ms),Down Events')
97+
data.rollupDaily.forEach(bucket => {
98+
lines.push([
99+
bucket.bucketDate,
100+
bucket.upPct.toFixed(2),
101+
bucket.avgRttMs || '',
102+
bucket.downEvents
103+
].join(','))
104+
})
105+
}
106+
107+
// Add outages section if present
108+
if (data.outages.length > 0) {
109+
lines.push('')
110+
lines.push('# Outages')
111+
lines.push('Started,Ended,Duration (seconds),Last Error')
112+
data.outages.forEach(outage => {
113+
lines.push([
114+
outage.startedTs,
115+
outage.endedTs || 'Ongoing',
116+
outage.durationS || '',
117+
outage.lastError ? `"${outage.lastError.replace(/"/g, '""')}"` : ''
118+
].join(','))
119+
})
120+
}
121+
122+
return lines.join('\n')
123+
}
124+
125+
/**
126+
* Trigger browser download of CSV content
127+
*/
128+
private static downloadCSV(content: string, filename: string): void {
129+
const blob = new Blob([content], { type: 'text/csv;charset=utf-8;' })
130+
const link = document.createElement('a')
131+
132+
const url = URL.createObjectURL(blob)
133+
link.setAttribute('href', url)
134+
link.setAttribute('download', filename)
135+
link.style.visibility = 'hidden'
136+
137+
document.body.appendChild(link)
138+
link.click()
139+
document.body.removeChild(link)
140+
URL.revokeObjectURL(url)
141+
}
142+
143+
/**
144+
* Helper to format date for API calls
145+
*/
146+
static formatDateForAPI(date: Date): string {
147+
return date.toISOString()
148+
}
149+
150+
/**
151+
* Helper to get default date range (last 24 hours)
152+
*/
153+
static getDefaultDateRange(): { from: string; to: string } {
154+
const now = new Date()
155+
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)
156+
157+
return {
158+
from: this.formatDateForAPI(yesterday),
159+
to: this.formatDateForAPI(now)
160+
}
161+
}
162+
}

thingconnect.pulse.client/src/api/types.ts

Lines changed: 33 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -55,33 +55,24 @@ export interface StateChange {
5555
error?: string
5656
}
5757

58+
export interface RawCheck {
59+
ts: string
60+
status: 'up' | 'down'
61+
rttMs?: number | null
62+
error?: string | null
63+
}
64+
5865
export interface Outage {
59-
id: string
60-
startTime: string
61-
endTime?: string
62-
duration?: number
63-
affectedChecks: number
66+
startedTs: string
67+
endedTs?: string | null
68+
durationS?: number | null
69+
lastError?: string | null
6470
}
6571

6672
export interface EndpointDetail {
67-
id: string
68-
name: string
69-
host: string
70-
group?: string
71-
currentStatus: 'UP' | 'DOWN' | 'FLAPPING'
72-
config: {
73-
type: 'ICMP' | 'TCP' | 'HTTP' | 'HTTPS'
74-
port?: number
75-
path?: string
76-
timeout?: number
77-
interval?: number
78-
}
79-
recentStateChanges: StateChange[]
80-
recentOutages: Outage[]
81-
rttHistory: Array<{
82-
timestamp: string
83-
rtt?: number
84-
}>
73+
endpoint: Endpoint
74+
recent: RawCheck[]
75+
outages: Outage[]
8576
}
8677

8778
export interface HistoryDataPoint {
@@ -91,15 +82,26 @@ export interface HistoryDataPoint {
9182
error?: string
9283
}
9384

85+
export interface RollupBucket {
86+
bucketTs: string
87+
upPct: number
88+
avgRttMs?: number | null
89+
downEvents: number
90+
}
91+
92+
export interface DailyBucket {
93+
bucketDate: string
94+
upPct: number
95+
avgRttMs?: number | null
96+
downEvents: number
97+
}
98+
9499
export interface HistoryResponse {
95-
endpointId: string
96-
from: string
97-
to: string
98-
bucket: 'raw' | '15m' | 'daily'
99-
data: HistoryDataPoint[]
100-
availabilityPercentage?: number
101-
totalUptime?: number
102-
totalDowntime?: number
100+
endpoint: Endpoint
101+
raw: RawCheck[]
102+
rollup15m: RollupBucket[]
103+
rollupDaily: DailyBucket[]
104+
outages: Outage[]
103105
}
104106

105107
// Request parameter types

0 commit comments

Comments
 (0)