mirror of
https://github.com/ferdzo/iotDashboard.git
synced 2026-04-05 17:16:26 +00:00
Introduced GPT/AI service, added dashboard widgets, drag and drop, export import.
This commit is contained in:
144
frontend/src/components/widgets/AiInsightWidget.tsx
Normal file
144
frontend/src/components/widgets/AiInsightWidget.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { telemetryApi } from '../../api'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
|
||||
interface AiInsightWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
export default function AiInsightWidget({ config }: AiInsightWidgetProps) {
|
||||
const { deviceIds, metricIds, timeframe, title } = config
|
||||
const [promptType, setPromptType] = useState<'trend_summary' | 'anomaly_detection'>('trend_summary')
|
||||
const [showAnalysis, setShowAnalysis] = useState(false)
|
||||
|
||||
const deviceId = deviceIds[0]
|
||||
const metric = metricIds[0]
|
||||
|
||||
const {
|
||||
data: analysis,
|
||||
isLoading,
|
||||
refetch,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['ai-insight', deviceId, metric, promptType, timeframe],
|
||||
queryFn: async () => {
|
||||
const response = await telemetryApi.analyze({
|
||||
device_id: deviceId,
|
||||
metric,
|
||||
hours: timeframe.hours || 240,
|
||||
limit: 200,
|
||||
prompt_type: promptType,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
enabled: false, // Manual trigger
|
||||
})
|
||||
|
||||
const handleAnalyze = () => {
|
||||
setShowAnalysis(true)
|
||||
refetch()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="card-title text-base">{title || 'AI Environmental Insights'}</h3>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="select select-bordered select-sm"
|
||||
value={promptType}
|
||||
onChange={(e) => setPromptType(e.target.value as 'trend_summary' | 'anomaly_detection')}
|
||||
>
|
||||
<option value="trend_summary">Trend Summary</option>
|
||||
<option value="anomaly_detection">Anomaly Detection</option>
|
||||
</select>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleAnalyze}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<span className="loading loading-spinner loading-xs"></span>
|
||||
Analyzing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
Analyze
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center space-y-4">
|
||||
<span className="loading loading-spinner loading-lg text-primary"></span>
|
||||
<p className="text-sm text-base-content/60">Analyzing environmental data...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-error mt-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="font-bold">Analysis Failed</div>
|
||||
<div className="text-sm">{(error as Error)?.message || 'Could not connect to GPT service'}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis && showAnalysis && !isLoading && (
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="badge badge-primary badge-lg">
|
||||
{promptType === 'trend_summary' ? 'Trend Analysis' : 'Anomaly Detection'}
|
||||
</div>
|
||||
<div className="text-xs text-base-content/50">
|
||||
{analysis.data_points_analyzed} data points analyzed
|
||||
</div>
|
||||
</div>
|
||||
<div className="divider my-2"></div>
|
||||
<div className="prose max-w-none">
|
||||
<div className="whitespace-pre-wrap text-sm leading-relaxed max-h-96 overflow-y-auto">
|
||||
{analysis.analysis}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setShowAnalysis(false)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh Analysis
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showAnalysis && (
|
||||
<div className="text-center py-8 text-base-content/60">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-12 w-12 mx-auto mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<p className="text-sm">Click Analyze to get AI-powered environmental insights</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
103
frontend/src/components/widgets/GaugeWidget.tsx
Normal file
103
frontend/src/components/widgets/GaugeWidget.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useTelemetrySeries } from '../../hooks'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
|
||||
interface GaugeWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
export default function GaugeWidget({ config }: GaugeWidgetProps) {
|
||||
const { deviceIds, metricIds, timeframe, title } = config
|
||||
|
||||
const deviceId = deviceIds[0]
|
||||
const metric = metricIds[0]
|
||||
|
||||
const { data, isLoading, error } = useTelemetrySeries({
|
||||
deviceId,
|
||||
metric,
|
||||
hours: timeframe.hours,
|
||||
startTime: timeframe.startTime,
|
||||
endTime: timeframe.endTime,
|
||||
limit: 1,
|
||||
})
|
||||
|
||||
const latest = data[0]
|
||||
const value = latest?.value || 0
|
||||
|
||||
// Simple gauge ranges (could be configured per metric)
|
||||
const ranges = {
|
||||
low: 33,
|
||||
medium: 66,
|
||||
high: 100,
|
||||
}
|
||||
|
||||
const percentage = Math.min(100, Math.max(0, value))
|
||||
let color = 'text-success'
|
||||
if (percentage > ranges.medium) color = 'text-error'
|
||||
else if (percentage > ranges.low) color = 'text-warning'
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="card bg-base-200 animate-pulse">
|
||||
<div className="card-body h-48"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card bg-error/10">
|
||||
<div className="card-body">
|
||||
<p className="text-error text-sm">Error: {error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body items-center text-center">
|
||||
<h3 className="card-title text-base">{title || metric}</h3>
|
||||
<div className="relative w-32 h-32 mt-4">
|
||||
<svg className="w-full h-full transform -rotate-90">
|
||||
<circle
|
||||
cx="64"
|
||||
cy="64"
|
||||
r="56"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
fill="none"
|
||||
className="text-base-300"
|
||||
/>
|
||||
<circle
|
||||
cx="64"
|
||||
cy="64"
|
||||
r="56"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
fill="none"
|
||||
strokeDasharray={`${2 * Math.PI * 56}`}
|
||||
strokeDashoffset={`${2 * Math.PI * 56 * (1 - percentage / 100)}`}
|
||||
className={color}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div>
|
||||
<div className={`text-3xl font-bold ${color}`}>
|
||||
{value.toFixed(1)}
|
||||
</div>
|
||||
{latest?.unit && (
|
||||
<div className="text-sm text-base-content/60">{latest.unit}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{latest && (
|
||||
<div className="text-xs text-base-content/50 mt-4">
|
||||
Updated {new Date(latest.time).toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
235
frontend/src/components/widgets/LineChartWidget.tsx
Normal file
235
frontend/src/components/widgets/LineChartWidget.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { useMemo } from 'react'
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { useTelemetrySeries } from '../../hooks'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
import { formatMetricName } from '../../utils/formatters'
|
||||
|
||||
interface LineChartWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
// Helper component to fetch data for a single metric
|
||||
function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: WidgetConfig['timeframe']) {
|
||||
// Fetch data for each metric (React allows hooks in arrays when count is stable)
|
||||
const metric1 = useTelemetrySeries({
|
||||
deviceId,
|
||||
metric: metricIds[0] || '',
|
||||
hours: timeframe.hours,
|
||||
startTime: timeframe.startTime,
|
||||
endTime: timeframe.endTime,
|
||||
})
|
||||
|
||||
const metric2 = useTelemetrySeries({
|
||||
deviceId,
|
||||
metric: metricIds[1] || '',
|
||||
hours: timeframe.hours,
|
||||
startTime: timeframe.startTime,
|
||||
endTime: timeframe.endTime,
|
||||
})
|
||||
|
||||
const metric3 = useTelemetrySeries({
|
||||
deviceId,
|
||||
metric: metricIds[2] || '',
|
||||
hours: timeframe.hours,
|
||||
startTime: timeframe.startTime,
|
||||
endTime: timeframe.endTime,
|
||||
})
|
||||
|
||||
const metric4 = useTelemetrySeries({
|
||||
deviceId,
|
||||
metric: metricIds[3] || '',
|
||||
hours: timeframe.hours,
|
||||
startTime: timeframe.startTime,
|
||||
endTime: timeframe.endTime,
|
||||
})
|
||||
|
||||
const metric5 = useTelemetrySeries({
|
||||
deviceId,
|
||||
metric: metricIds[4] || '',
|
||||
hours: timeframe.hours,
|
||||
startTime: timeframe.startTime,
|
||||
endTime: timeframe.endTime,
|
||||
})
|
||||
|
||||
const queries = [metric1, metric2, metric3, metric4, metric5].slice(0, metricIds.length)
|
||||
|
||||
return { queries, metricIds }
|
||||
}
|
||||
|
||||
export default function LineChartWidget({ config }: LineChartWidgetProps) {
|
||||
const { deviceIds, metricIds, timeframe, visualization } = config
|
||||
const deviceId = deviceIds[0]
|
||||
|
||||
const { queries } = useMultiMetricData(deviceId, metricIds, timeframe)
|
||||
|
||||
const isLoading = queries.some((q) => q.isLoading)
|
||||
const error = queries.find((q) => q.error)?.error
|
||||
|
||||
// Combine data from all metrics into a single chart dataset
|
||||
const chartData = useMemo(() => {
|
||||
if (queries.length === 0 || !queries[0]?.data || queries[0].data.length === 0) return []
|
||||
|
||||
// Create a map of time -> { time, fullDateTime, metric1, metric2, ... }
|
||||
const timeMap = new Map<string, Record<string, number | string>>()
|
||||
|
||||
queries.forEach((query, index) => {
|
||||
const metric = metricIds[index]
|
||||
if (!query.data) return
|
||||
|
||||
query.data.forEach((point) => {
|
||||
const date = new Date(point.time)
|
||||
|
||||
// Short time for X-axis display (just time)
|
||||
const timeStr = date.toLocaleString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
// Full date/time for tooltip
|
||||
const fullDateTime = date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
const key = date.getTime().toString()
|
||||
|
||||
if (!timeMap.has(key)) {
|
||||
timeMap.set(key, {
|
||||
time: timeStr,
|
||||
fullDateTime: fullDateTime,
|
||||
timestamp: date.getTime()
|
||||
})
|
||||
}
|
||||
|
||||
const entry = timeMap.get(key)!
|
||||
entry[metric] = point.value
|
||||
})
|
||||
})
|
||||
|
||||
// Sort by timestamp
|
||||
return Array.from(timeMap.values()).sort((a, b) => {
|
||||
return (a.timestamp as number) - (b.timestamp as number)
|
||||
})
|
||||
}, [queries, metricIds])
|
||||
|
||||
const colors = visualization?.colors || [
|
||||
'#3b82f6', // blue
|
||||
'#10b981', // green
|
||||
'#f59e0b', // amber
|
||||
'#ef4444', // red
|
||||
'#8b5cf6', // purple
|
||||
'#ec4899', // pink
|
||||
]
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="alert alert-error">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="stroke-current shrink-0 h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span>Error loading data: {error.message}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div className="alert alert-info">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="stroke-current shrink-0 w-6 h-6"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
></path>
|
||||
</svg>
|
||||
<span>No data available for this metric</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card bg-base-100 h-full overflow-hidden">
|
||||
<div className="card-body p-4">
|
||||
<h3 className="card-title text-sm mb-2">
|
||||
{config.title || metricIds.map(formatMetricName).join(' & ')}
|
||||
</h3>
|
||||
<ResponsiveContainer width="100%" height={visualization?.height || 280}>
|
||||
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 0, bottom: 50 }}>
|
||||
{visualization?.showGrid !== false && (
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
)}
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 11 }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={50}
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 12 }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||
border: 'none',
|
||||
borderRadius: '8px',
|
||||
color: 'white',
|
||||
}}
|
||||
labelFormatter={(label, payload) => {
|
||||
// Use fullDateTime from the data point for tooltip
|
||||
return payload && payload[0] ? payload[0].payload.fullDateTime : label
|
||||
}}
|
||||
formatter={(value: number) => [value.toFixed(2)]}
|
||||
/>
|
||||
{visualization?.showLegend !== false && <Legend />}
|
||||
{metricIds.map((metric, index) => (
|
||||
<Line
|
||||
key={metric}
|
||||
type="monotone"
|
||||
dataKey={metric}
|
||||
stroke={colors[index % colors.length]}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
name={formatMetricName(metric)}
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
78
frontend/src/components/widgets/StatWidget.tsx
Normal file
78
frontend/src/components/widgets/StatWidget.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useTelemetrySeries } from '../../hooks'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
import { formatMetricName } from '../../utils/formatters'
|
||||
|
||||
interface StatWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
export default function StatWidgetProps({ config }: StatWidgetProps) {
|
||||
const { deviceIds, metricIds, timeframe, title } = config
|
||||
|
||||
const deviceId = deviceIds[0]
|
||||
const metric = metricIds[0]
|
||||
|
||||
const { data, isLoading, error } = useTelemetrySeries({
|
||||
deviceId,
|
||||
metric,
|
||||
hours: timeframe.hours,
|
||||
startTime: timeframe.startTime,
|
||||
endTime: timeframe.endTime,
|
||||
limit: 100,
|
||||
})
|
||||
|
||||
const latest = data[0]
|
||||
const values = data.map((d) => d.value)
|
||||
const average = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0
|
||||
const min = values.length > 0 ? Math.min(...values) : 0
|
||||
const max = values.length > 0 ? Math.max(...values) : 0
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="card bg-base-200 animate-pulse">
|
||||
<div className="card-body h-32"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="card bg-error/10">
|
||||
<div className="card-body">
|
||||
<p className="text-error text-sm">Error: {error.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<div className="text-sm uppercase tracking-wide text-base-content/60">
|
||||
{title || formatMetricName(metric)}
|
||||
</div>
|
||||
<div className="text-4xl font-bold text-primary">
|
||||
{latest ? latest.value.toFixed(1) : '—'}
|
||||
{latest?.unit && <span className="text-xl ml-2">{latest.unit}</span>}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 mt-4 text-sm">
|
||||
<div>
|
||||
<div className="text-base-content/60">Min</div>
|
||||
<div className="font-semibold">{min.toFixed(1)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base-content/60">Avg</div>
|
||||
<div className="font-semibold">{average.toFixed(1)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base-content/60">Max</div>
|
||||
<div className="font-semibold">{max.toFixed(1)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-base-content/50 mt-2">
|
||||
{data.length} readings in last {timeframe.hours || 24}h
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
98
frontend/src/components/widgets/WidgetContainer.tsx
Normal file
98
frontend/src/components/widgets/WidgetContainer.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
import { widgetRegistry } from './registry'
|
||||
|
||||
interface WidgetContainerProps {
|
||||
config: WidgetConfig
|
||||
onRemove?: (id: string) => void
|
||||
onEdit?: (id: string) => void
|
||||
}
|
||||
|
||||
export default function WidgetContainer({ config, onRemove, onEdit }: WidgetContainerProps) {
|
||||
const WidgetComponent = widgetRegistry[config.type]
|
||||
|
||||
if (!WidgetComponent) {
|
||||
return (
|
||||
<div className="card bg-error/10">
|
||||
<div className="card-body">
|
||||
<p className="text-error">Unknown widget type: {config.type}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative group h-full">
|
||||
{/* Drag handle and actions */}
|
||||
<div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between p-2 opacity-0 group-hover:opacity-100 transition-opacity bg-gradient-to-b from-base-300/90 to-transparent">
|
||||
<div className="drag-handle cursor-move flex items-center gap-1 px-2 py-1 rounded bg-base-100/80 text-xs">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 8h16M4 16h16"
|
||||
/>
|
||||
</svg>
|
||||
Drag
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{onEdit && (
|
||||
<button
|
||||
className="btn btn-xs btn-circle btn-ghost bg-base-100/80"
|
||||
onClick={() => onEdit(config.id)}
|
||||
title="Edit widget"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{onRemove && (
|
||||
<button
|
||||
className="btn btn-xs btn-circle btn-ghost bg-base-100/80"
|
||||
onClick={() => onRemove(config.id)}
|
||||
title="Remove widget"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actual widget */}
|
||||
<div className="h-full">
|
||||
<WidgetComponent config={config} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
frontend/src/components/widgets/index.ts
Normal file
6
frontend/src/components/widgets/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { default as WidgetContainer } from './WidgetContainer'
|
||||
export { default as LineChartWidget } from './LineChartWidget'
|
||||
export { default as StatWidget } from './StatWidget'
|
||||
export { default as GaugeWidget } from './GaugeWidget'
|
||||
export { default as AiInsightWidget } from './AiInsightWidget'
|
||||
export { widgetRegistry } from './registry'
|
||||
19
frontend/src/components/widgets/registry.ts
Normal file
19
frontend/src/components/widgets/registry.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import type { WidgetConfig, WidgetType } from '../../hooks'
|
||||
|
||||
import LineChartWidget from './LineChartWidget'
|
||||
import StatWidget from './StatWidget'
|
||||
import GaugeWidget from './GaugeWidget'
|
||||
import AiInsightWidget from './AiInsightWidget'
|
||||
|
||||
interface WidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
export const widgetRegistry: Record<WidgetType, ComponentType<WidgetProps>> = {
|
||||
'line-chart': LineChartWidget,
|
||||
'stat': StatWidget,
|
||||
'gauge': GaugeWidget,
|
||||
'ai-insight': AiInsightWidget,
|
||||
'bar-chart': LineChartWidget, // Placeholder - implement later
|
||||
}
|
||||
Reference in New Issue
Block a user