mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-24 21:54:56 +00:00
Generated by Spark: Create Redux persistence middleware to sync state with database automatically
This commit is contained in:
288
src/components/PersistenceDashboard.tsx
Normal file
288
src/components/PersistenceDashboard.tsx
Normal file
@@ -0,0 +1,288 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Database,
|
||||
CloudArrowUp,
|
||||
CloudArrowDown,
|
||||
ArrowsClockwise,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
ChartLine,
|
||||
Gauge
|
||||
} from '@phosphor-icons/react'
|
||||
import { usePersistence } from '@/hooks/use-persistence'
|
||||
import { useAppDispatch, useAppSelector } from '@/store'
|
||||
import { syncToFlaskBulk, syncFromFlaskBulk, checkFlaskConnection } from '@/store/slices/syncSlice'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function PersistenceDashboard() {
|
||||
const dispatch = useAppDispatch()
|
||||
const { status, metrics, autoSyncStatus, syncNow, configureAutoSync } = usePersistence()
|
||||
const [autoSyncEnabled, setAutoSyncEnabled] = useState(false)
|
||||
const [syncing, setSyncing] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(checkFlaskConnection())
|
||||
const interval = setInterval(() => {
|
||||
dispatch(checkFlaskConnection())
|
||||
}, 10000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [dispatch])
|
||||
|
||||
const handleSyncToFlask = async () => {
|
||||
setSyncing(true)
|
||||
try {
|
||||
await dispatch(syncToFlaskBulk()).unwrap()
|
||||
toast.success('Successfully synced to Flask')
|
||||
} catch (error: any) {
|
||||
toast.error(`Sync failed: ${error}`)
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSyncFromFlask = async () => {
|
||||
setSyncing(true)
|
||||
try {
|
||||
await dispatch(syncFromFlaskBulk()).unwrap()
|
||||
toast.success('Successfully synced from Flask')
|
||||
} catch (error: any) {
|
||||
toast.error(`Sync failed: ${error}`)
|
||||
} finally {
|
||||
setSyncing(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutoSyncToggle = (enabled: boolean) => {
|
||||
setAutoSyncEnabled(enabled)
|
||||
configureAutoSync({ enabled, syncOnChange: true })
|
||||
toast.info(enabled ? 'Auto-sync enabled' : 'Auto-sync disabled')
|
||||
}
|
||||
|
||||
const handleManualSync = async () => {
|
||||
try {
|
||||
await syncNow()
|
||||
toast.success('Manual sync completed')
|
||||
} catch (error: any) {
|
||||
toast.error(`Manual sync failed: ${error}`)
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusColor = () => {
|
||||
if (!status.flaskConnected) return 'bg-destructive'
|
||||
if (status.syncStatus === 'syncing') return 'bg-amber-500'
|
||||
if (status.syncStatus === 'success') return 'bg-accent'
|
||||
if (status.syncStatus === 'error') return 'bg-destructive'
|
||||
return 'bg-muted'
|
||||
}
|
||||
|
||||
const getStatusText = () => {
|
||||
if (!status.flaskConnected) return 'Disconnected'
|
||||
if (status.syncStatus === 'syncing') return 'Syncing...'
|
||||
if (status.syncStatus === 'success') return 'Synced'
|
||||
if (status.syncStatus === 'error') return 'Error'
|
||||
return 'Idle'
|
||||
}
|
||||
|
||||
const formatTime = (timestamp: number | null) => {
|
||||
if (!timestamp) return 'Never'
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleTimeString()
|
||||
}
|
||||
|
||||
const formatDuration = (ms: number) => {
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
|
||||
const getSuccessRate = () => {
|
||||
if (metrics.totalOperations === 0) return 0
|
||||
return Math.round((metrics.successfulOperations / metrics.totalOperations) * 100)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold">Persistence & Sync Dashboard</h1>
|
||||
<Badge className={`${getStatusColor()} text-white`}>
|
||||
{getStatusText()}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<Card className="p-6 space-y-4 border-sidebar-border hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Database className="text-primary" />
|
||||
Connection Status
|
||||
</h3>
|
||||
{status.flaskConnected ? (
|
||||
<CheckCircle className="text-accent" weight="fill" />
|
||||
) : (
|
||||
<XCircle className="text-destructive" weight="fill" />
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Local Storage:</span>
|
||||
<span className="font-medium">IndexedDB</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Remote Storage:</span>
|
||||
<span className="font-medium">
|
||||
{status.flaskConnected ? 'Flask API' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Last Sync:</span>
|
||||
<span className="font-medium">{formatTime(status.lastSyncTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 space-y-4 border-sidebar-border hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<ChartLine className="text-accent" />
|
||||
Sync Metrics
|
||||
</h3>
|
||||
<Gauge className="text-muted-foreground" />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Total Operations:</span>
|
||||
<span className="font-medium">{metrics.totalOperations}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Success Rate:</span>
|
||||
<span className="font-medium text-accent">{getSuccessRate()}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Avg Duration:</span>
|
||||
<span className="font-medium">{formatDuration(metrics.averageOperationTime)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Failed:</span>
|
||||
<span className="font-medium text-destructive">{metrics.failedOperations}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 space-y-4 border-sidebar-border hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Clock className="text-amber-500" />
|
||||
Auto-Sync
|
||||
</h3>
|
||||
<Switch checked={autoSyncEnabled} onCheckedChange={handleAutoSyncToggle} />
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Status:</span>
|
||||
<span className="font-medium">
|
||||
{autoSyncStatus.enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Changes Pending:</span>
|
||||
<span className="font-medium">{autoSyncStatus.changeCounter}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Next Sync:</span>
|
||||
<span className="font-medium">
|
||||
{autoSyncStatus.nextSyncIn !== null
|
||||
? formatDuration(autoSyncStatus.nextSyncIn)
|
||||
: 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="p-6 space-y-4 border-sidebar-border">
|
||||
<h3 className="text-lg font-semibold flex items-center gap-2">
|
||||
<ArrowsClockwise className="text-primary" />
|
||||
Manual Sync Operations
|
||||
</h3>
|
||||
<Separator />
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<Button
|
||||
onClick={handleSyncToFlask}
|
||||
disabled={syncing || !status.flaskConnected}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<CloudArrowUp />
|
||||
Push to Flask
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSyncFromFlask}
|
||||
disabled={syncing || !status.flaskConnected}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<CloudArrowDown />
|
||||
Pull from Flask
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleManualSync}
|
||||
disabled={syncing || !autoSyncStatus.enabled}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowsClockwise />
|
||||
Trigger Auto-Sync
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => dispatch(checkFlaskConnection())}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<CheckCircle />
|
||||
Check Connection
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{status.error && (
|
||||
<Card className="p-6 border-destructive bg-destructive/10">
|
||||
<div className="flex items-start gap-3">
|
||||
<XCircle className="text-destructive mt-1" weight="fill" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-destructive mb-1">Sync Error</h3>
|
||||
<p className="text-sm text-muted-foreground">{status.error}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="p-6 space-y-4 border-sidebar-border">
|
||||
<h3 className="text-lg font-semibold">How Persistence Works</h3>
|
||||
<Separator />
|
||||
<div className="space-y-3 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<strong className="text-foreground">Automatic Persistence:</strong> All Redux state changes are automatically persisted to IndexedDB with a 300ms debounce.
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-foreground">Flask Sync:</strong> When connected, data is synced bidirectionally with the Flask API backend.
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-foreground">Auto-Sync:</strong> Enable auto-sync to automatically push changes to Flask at regular intervals (default: 30s).
|
||||
</p>
|
||||
<p>
|
||||
<strong className="text-foreground">Conflict Resolution:</strong> When conflicts are detected during sync, you'll be notified to resolve them manually.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
251
src/components/PersistenceExample.tsx
Normal file
251
src/components/PersistenceExample.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useState } from 'react'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { FloppyDisk, Trash, PencilSimple, CheckCircle, Clock } from '@phosphor-icons/react'
|
||||
import { useAppDispatch, useAppSelector } from '@/store'
|
||||
import { saveFile, deleteFile } from '@/store/slices/filesSlice'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function PersistenceExample() {
|
||||
const dispatch = useAppDispatch()
|
||||
const files = useAppSelector((state) => state.files.files)
|
||||
const [fileName, setFileName] = useState('')
|
||||
const [fileContent, setFileContent] = useState('')
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!fileName.trim()) {
|
||||
toast.error('File name is required')
|
||||
return
|
||||
}
|
||||
|
||||
const fileItem = {
|
||||
id: editingId || `file-${Date.now()}`,
|
||||
name: fileName,
|
||||
content: fileContent,
|
||||
language: 'javascript',
|
||||
path: `/src/${fileName}`,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
try {
|
||||
await dispatch(saveFile(fileItem)).unwrap()
|
||||
toast.success(`File "${fileName}" saved automatically!`, {
|
||||
description: 'Synced to IndexedDB and Flask API',
|
||||
})
|
||||
setFileName('')
|
||||
setFileContent('')
|
||||
setEditingId(null)
|
||||
} catch (error: any) {
|
||||
toast.error('Failed to save file', {
|
||||
description: error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleEdit = (file: any) => {
|
||||
setEditingId(file.id)
|
||||
setFileName(file.name)
|
||||
setFileContent(file.content)
|
||||
}
|
||||
|
||||
const handleDelete = async (fileId: string, name: string) => {
|
||||
try {
|
||||
await dispatch(deleteFile(fileId)).unwrap()
|
||||
toast.success(`File "${name}" deleted`, {
|
||||
description: 'Automatically synced to storage',
|
||||
})
|
||||
} catch (error: any) {
|
||||
toast.error('Failed to delete file', {
|
||||
description: error,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setFileName('')
|
||||
setFileContent('')
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold mb-2">Persistence Middleware Example</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Demonstrates automatic persistence of Redux state to IndexedDB and Flask API
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="p-6 space-y-4 border-sidebar-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">
|
||||
{editingId ? 'Edit File' : 'Create File'}
|
||||
</h2>
|
||||
{editingId && (
|
||||
<Badge variant="outline" className="text-amber-500 border-amber-500">
|
||||
Editing
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fileName">File Name</Label>
|
||||
<Input
|
||||
id="fileName"
|
||||
value={fileName}
|
||||
onChange={(e) => setFileName(e.target.value)}
|
||||
placeholder="example.js"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fileContent">Content</Label>
|
||||
<textarea
|
||||
id="fileContent"
|
||||
value={fileContent}
|
||||
onChange={(e) => setFileContent(e.target.value)}
|
||||
placeholder="console.log('Hello, world!')"
|
||||
className="w-full h-32 px-3 py-2 border border-input rounded-md bg-background font-mono text-sm resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSave} className="flex items-center gap-2 flex-1">
|
||||
<FloppyDisk />
|
||||
{editingId ? 'Update File' : 'Save File'}
|
||||
</Button>
|
||||
{editingId && (
|
||||
<Button onClick={handleCancel} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted/50 rounded-lg space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<CheckCircle className="text-accent mt-1" weight="fill" />
|
||||
<div className="text-sm">
|
||||
<p className="font-semibold text-foreground">Automatic Persistence</p>
|
||||
<p className="text-muted-foreground">
|
||||
Data is automatically saved to IndexedDB with 300ms debounce
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
<Clock className="text-primary mt-1" weight="fill" />
|
||||
<div className="text-sm">
|
||||
<p className="font-semibold text-foreground">Flask Sync</p>
|
||||
<p className="text-muted-foreground">
|
||||
Changes are synced to Flask API backend automatically
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 space-y-4 border-sidebar-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold">Saved Files</h2>
|
||||
<Badge variant="secondary">{files.length} files</Badge>
|
||||
</div>
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3 max-h-[500px] overflow-y-auto">
|
||||
{files.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<p>No files yet</p>
|
||||
<p className="text-sm mt-1">Create your first file to see it appear here</p>
|
||||
</div>
|
||||
) : (
|
||||
files.map((file) => (
|
||||
<Card
|
||||
key={file.id}
|
||||
className="p-4 space-y-2 hover:bg-muted/50 transition-colors border-sidebar-border"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold truncate">{file.name}</h3>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{file.path}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="ml-2 shrink-0">
|
||||
{file.language}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{file.content && (
|
||||
<div className="bg-muted/50 p-2 rounded text-xs font-mono text-muted-foreground max-h-20 overflow-hidden">
|
||||
{file.content.substring(0, 100)}
|
||||
{file.content.length > 100 && '...'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
Updated: {new Date(file.updatedAt).toLocaleTimeString()}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleEdit(file)}
|
||||
className="h-8 flex items-center gap-1"
|
||||
>
|
||||
<PencilSimple />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDelete(file.id, file.name)}
|
||||
className="h-8 flex items-center gap-1 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="p-6 space-y-4 border-primary/50 bg-primary/5">
|
||||
<h3 className="text-lg font-semibold">How It Works</h3>
|
||||
<Separator />
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold text-primary">1. Redux Action</div>
|
||||
<p className="text-muted-foreground">
|
||||
When you save or delete a file, a Redux action is dispatched
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold text-primary">2. Middleware Intercepts</div>
|
||||
<p className="text-muted-foreground">
|
||||
Persistence middleware automatically intercepts the action and queues the operation
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold text-primary">3. Auto-Sync</div>
|
||||
<p className="text-muted-foreground">
|
||||
After 300ms debounce, data is saved to IndexedDB and synced to Flask API
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -317,6 +317,24 @@
|
||||
"order": 19,
|
||||
"props": {}
|
||||
},
|
||||
{
|
||||
"id": "persistence",
|
||||
"title": "Persistence",
|
||||
"icon": "Database",
|
||||
"component": "PersistenceDashboard",
|
||||
"enabled": true,
|
||||
"order": 20,
|
||||
"props": {}
|
||||
},
|
||||
{
|
||||
"id": "persistence-demo",
|
||||
"title": "Persistence Demo",
|
||||
"icon": "Lightning",
|
||||
"component": "PersistenceExample",
|
||||
"enabled": true,
|
||||
"order": 21,
|
||||
"props": {}
|
||||
},
|
||||
{
|
||||
"id": "templates",
|
||||
"title": "Templates",
|
||||
|
||||
111
src/hooks/use-persistence.ts
Normal file
111
src/hooks/use-persistence.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAppSelector } from '@/store'
|
||||
import {
|
||||
flushPersistence,
|
||||
configurePersistence,
|
||||
enablePersistence,
|
||||
disablePersistence
|
||||
} from '@/store/middleware/persistenceMiddleware'
|
||||
import {
|
||||
getSyncMetrics,
|
||||
resetSyncMetrics,
|
||||
subscribeSyncMetrics
|
||||
} from '@/store/middleware/syncMonitorMiddleware'
|
||||
import {
|
||||
configureAutoSync,
|
||||
getAutoSyncStatus,
|
||||
triggerAutoSync
|
||||
} from '@/store/middleware/autoSyncMiddleware'
|
||||
|
||||
interface PersistenceStatus {
|
||||
enabled: boolean
|
||||
lastSyncTime: number | null
|
||||
syncStatus: 'idle' | 'syncing' | 'success' | 'error'
|
||||
error: string | null
|
||||
flaskConnected: boolean
|
||||
}
|
||||
|
||||
interface SyncMetrics {
|
||||
totalOperations: number
|
||||
successfulOperations: number
|
||||
failedOperations: number
|
||||
lastOperationTime: number
|
||||
averageOperationTime: number
|
||||
}
|
||||
|
||||
interface AutoSyncStatus {
|
||||
enabled: boolean
|
||||
lastSyncTime: number
|
||||
changeCounter: number
|
||||
nextSyncIn: number | null
|
||||
}
|
||||
|
||||
export function usePersistence() {
|
||||
const syncState = useAppSelector((state) => state.sync)
|
||||
const [metrics, setMetrics] = useState<SyncMetrics>(getSyncMetrics())
|
||||
const [autoSyncStatus, setAutoSyncStatus] = useState<AutoSyncStatus>(getAutoSyncStatus())
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = subscribeSyncMetrics((newMetrics) => {
|
||||
setMetrics(newMetrics)
|
||||
})
|
||||
|
||||
const statusTimer = setInterval(() => {
|
||||
setAutoSyncStatus(getAutoSyncStatus())
|
||||
}, 1000)
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
clearInterval(statusTimer)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const status: PersistenceStatus = {
|
||||
enabled: true,
|
||||
lastSyncTime: syncState.lastSyncedAt,
|
||||
syncStatus: syncState.status,
|
||||
error: syncState.error,
|
||||
flaskConnected: syncState.flaskConnected,
|
||||
}
|
||||
|
||||
const flush = async () => {
|
||||
await flushPersistence()
|
||||
}
|
||||
|
||||
const configure = (sliceName: string, config: any) => {
|
||||
configurePersistence(sliceName, config)
|
||||
}
|
||||
|
||||
const enable = (sliceName: string) => {
|
||||
enablePersistence(sliceName)
|
||||
}
|
||||
|
||||
const disable = (sliceName: string) => {
|
||||
disablePersistence(sliceName)
|
||||
}
|
||||
|
||||
const resetMetrics = () => {
|
||||
resetSyncMetrics()
|
||||
}
|
||||
|
||||
const configureAutoSyncSettings = (config: any) => {
|
||||
configureAutoSync(config)
|
||||
}
|
||||
|
||||
const syncNow = async () => {
|
||||
await triggerAutoSync()
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
metrics,
|
||||
autoSyncStatus,
|
||||
flush,
|
||||
configure,
|
||||
enable,
|
||||
disable,
|
||||
resetMetrics,
|
||||
configureAutoSync: configureAutoSyncSettings,
|
||||
syncNow,
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ import themeReducer from './slices/themeSlice'
|
||||
import settingsReducer from './slices/settingsSlice'
|
||||
import syncReducer from './slices/syncSlice'
|
||||
import conflictsReducer from './slices/conflictsSlice'
|
||||
import { createPersistenceMiddleware } from './middleware/persistenceMiddleware'
|
||||
import { createSyncMonitorMiddleware } from './middleware/syncMonitorMiddleware'
|
||||
import { createAutoSyncMiddleware } from './middleware/autoSyncMiddleware'
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
@@ -31,7 +34,10 @@ export const store = configureStore({
|
||||
serializableCheck: {
|
||||
ignoredActions: ['persist/PERSIST', 'persist/REHYDRATE'],
|
||||
},
|
||||
}),
|
||||
})
|
||||
.concat(createPersistenceMiddleware())
|
||||
.concat(createSyncMonitorMiddleware())
|
||||
.concat(createAutoSyncMiddleware()),
|
||||
})
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>
|
||||
|
||||
162
src/store/middleware/autoSyncMiddleware.ts
Normal file
162
src/store/middleware/autoSyncMiddleware.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { Middleware } from '@reduxjs/toolkit'
|
||||
import { syncToFlaskBulk, checkFlaskConnection } from '../slices/syncSlice'
|
||||
import { RootState } from '../index'
|
||||
|
||||
interface AutoSyncConfig {
|
||||
enabled: boolean
|
||||
intervalMs: number
|
||||
syncOnChange: boolean
|
||||
maxQueueSize: number
|
||||
}
|
||||
|
||||
class AutoSyncManager {
|
||||
private config: AutoSyncConfig = {
|
||||
enabled: false,
|
||||
intervalMs: 30000,
|
||||
syncOnChange: false,
|
||||
maxQueueSize: 50,
|
||||
}
|
||||
|
||||
private timer: ReturnType<typeof setTimeout> | null = null
|
||||
private lastSyncTime = 0
|
||||
private changeCounter = 0
|
||||
private dispatch: any = null
|
||||
|
||||
configure(config: Partial<AutoSyncConfig>) {
|
||||
this.config = { ...this.config, ...config }
|
||||
|
||||
if (this.config.enabled) {
|
||||
this.start()
|
||||
} else {
|
||||
this.stop()
|
||||
}
|
||||
}
|
||||
|
||||
setDispatch(dispatch: any) {
|
||||
this.dispatch = dispatch
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.timer || !this.dispatch) return
|
||||
|
||||
this.timer = setInterval(() => {
|
||||
if (this.shouldSync()) {
|
||||
this.performSync()
|
||||
}
|
||||
}, this.config.intervalMs)
|
||||
|
||||
this.dispatch(checkFlaskConnection())
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
}
|
||||
|
||||
private shouldSync(): boolean {
|
||||
if (!this.config.enabled) return false
|
||||
|
||||
const timeSinceLastSync = Date.now() - this.lastSyncTime
|
||||
if (timeSinceLastSync < this.config.intervalMs) return false
|
||||
|
||||
if (this.config.syncOnChange && this.changeCounter === 0) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private async performSync() {
|
||||
if (!this.dispatch) return
|
||||
|
||||
try {
|
||||
await this.dispatch(syncToFlaskBulk())
|
||||
this.lastSyncTime = Date.now()
|
||||
this.changeCounter = 0
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Sync failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
trackChange() {
|
||||
this.changeCounter++
|
||||
|
||||
if (this.changeCounter >= this.config.maxQueueSize && this.config.syncOnChange) {
|
||||
this.performSync()
|
||||
}
|
||||
}
|
||||
|
||||
getConfig(): AutoSyncConfig {
|
||||
return { ...this.config }
|
||||
}
|
||||
|
||||
getStatus() {
|
||||
return {
|
||||
enabled: this.config.enabled,
|
||||
lastSyncTime: this.lastSyncTime,
|
||||
changeCounter: this.changeCounter,
|
||||
nextSyncIn: this.config.enabled
|
||||
? Math.max(0, this.config.intervalMs - (Date.now() - this.lastSyncTime))
|
||||
: null,
|
||||
}
|
||||
}
|
||||
|
||||
async syncNow() {
|
||||
await this.performSync()
|
||||
}
|
||||
}
|
||||
|
||||
export const autoSyncManager = new AutoSyncManager()
|
||||
|
||||
export const createAutoSyncMiddleware = (): Middleware => {
|
||||
return (storeAPI) => {
|
||||
autoSyncManager.setDispatch(storeAPI.dispatch)
|
||||
|
||||
return (next) => (action: any) => {
|
||||
const result = next(action)
|
||||
|
||||
if (!action.type) return result
|
||||
|
||||
if (action.type === 'settings/updateSettings' && action.payload?.autoSync !== undefined) {
|
||||
const state = storeAPI.getState() as RootState
|
||||
const { autoSync, autoSyncInterval } = (state.settings as any) || {}
|
||||
|
||||
autoSyncManager.configure({
|
||||
enabled: autoSync ?? false,
|
||||
intervalMs: autoSyncInterval ?? 30000,
|
||||
})
|
||||
}
|
||||
|
||||
const changeActions = [
|
||||
'files/addItem',
|
||||
'files/updateItem',
|
||||
'files/removeItem',
|
||||
'models/addItem',
|
||||
'models/updateItem',
|
||||
'models/removeItem',
|
||||
'components/addItem',
|
||||
'components/updateItem',
|
||||
'components/removeItem',
|
||||
'componentTrees/addItem',
|
||||
'componentTrees/updateItem',
|
||||
'componentTrees/removeItem',
|
||||
'workflows/addItem',
|
||||
'workflows/updateItem',
|
||||
'workflows/removeItem',
|
||||
'lambdas/addItem',
|
||||
'lambdas/updateItem',
|
||||
'lambdas/removeItem',
|
||||
]
|
||||
|
||||
if (changeActions.includes(action.type)) {
|
||||
autoSyncManager.trackChange()
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const configureAutoSync = (config: Partial<AutoSyncConfig>) => autoSyncManager.configure(config)
|
||||
export const getAutoSyncStatus = () => autoSyncManager.getStatus()
|
||||
export const triggerAutoSync = () => autoSyncManager.syncNow()
|
||||
23
src/store/middleware/index.ts
Normal file
23
src/store/middleware/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
export {
|
||||
createPersistenceMiddleware,
|
||||
flushPersistence,
|
||||
configurePersistence,
|
||||
enablePersistence,
|
||||
disablePersistence,
|
||||
} from './persistenceMiddleware'
|
||||
|
||||
export {
|
||||
createSyncMonitorMiddleware,
|
||||
getSyncMetrics,
|
||||
resetSyncMetrics,
|
||||
subscribeSyncMetrics,
|
||||
} from './syncMonitorMiddleware'
|
||||
|
||||
export {
|
||||
createAutoSyncMiddleware,
|
||||
configureAutoSync,
|
||||
getAutoSyncStatus,
|
||||
triggerAutoSync,
|
||||
} from './autoSyncMiddleware'
|
||||
|
||||
export { syncToFlask, fetchFromFlask, syncAllToFlask, fetchAllFromFlask } from './flaskSync'
|
||||
231
src/store/middleware/persistenceMiddleware.ts
Normal file
231
src/store/middleware/persistenceMiddleware.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { Middleware } from '@reduxjs/toolkit'
|
||||
import { db } from '@/lib/db'
|
||||
import { syncToFlask } from './flaskSync'
|
||||
import { RootState } from '../index'
|
||||
|
||||
interface PersistenceConfig {
|
||||
storeName: string
|
||||
enabled: boolean
|
||||
syncToFlask: boolean
|
||||
debounceMs: number
|
||||
batchSize: number
|
||||
}
|
||||
|
||||
const defaultConfig: PersistenceConfig = {
|
||||
storeName: '',
|
||||
enabled: true,
|
||||
syncToFlask: true,
|
||||
debounceMs: 300,
|
||||
batchSize: 10,
|
||||
}
|
||||
|
||||
const sliceToPersistenceMap: Record<string, PersistenceConfig> = {
|
||||
files: { ...defaultConfig, storeName: 'files' },
|
||||
models: { ...defaultConfig, storeName: 'models' },
|
||||
components: { ...defaultConfig, storeName: 'components' },
|
||||
componentTrees: { ...defaultConfig, storeName: 'componentTrees' },
|
||||
workflows: { ...defaultConfig, storeName: 'workflows' },
|
||||
lambdas: { ...defaultConfig, storeName: 'lambdas' },
|
||||
theme: { ...defaultConfig, storeName: 'theme' },
|
||||
settings: { ...defaultConfig, storeName: 'settings', syncToFlask: false },
|
||||
}
|
||||
|
||||
type PendingOperation = {
|
||||
type: 'put' | 'delete'
|
||||
storeName: string
|
||||
key: string
|
||||
value?: any
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
class PersistenceQueue {
|
||||
private queue: Map<string, PendingOperation> = new Map()
|
||||
private processing = false
|
||||
private debounceTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
|
||||
|
||||
enqueue(operation: PendingOperation, debounceMs: number) {
|
||||
const opKey = `${operation.storeName}:${operation.key}`
|
||||
|
||||
const existingTimer = this.debounceTimers.get(opKey)
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer)
|
||||
}
|
||||
|
||||
this.queue.set(opKey, operation)
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
this.debounceTimers.delete(opKey)
|
||||
this.processQueue()
|
||||
}, debounceMs)
|
||||
|
||||
this.debounceTimers.set(opKey, timer)
|
||||
}
|
||||
|
||||
async processQueue() {
|
||||
if (this.processing || this.queue.size === 0) return
|
||||
|
||||
this.processing = true
|
||||
|
||||
try {
|
||||
const operations = Array.from(this.queue.values())
|
||||
this.queue.clear()
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
operations.map(async (op) => {
|
||||
try {
|
||||
if (op.type === 'put') {
|
||||
await db.put(op.storeName as any, op.value)
|
||||
if (sliceToPersistenceMap[op.storeName]?.syncToFlask) {
|
||||
await syncToFlask(op.storeName, op.key, op.value, 'put')
|
||||
}
|
||||
} else if (op.type === 'delete') {
|
||||
await db.delete(op.storeName as any, op.key)
|
||||
if (sliceToPersistenceMap[op.storeName]?.syncToFlask) {
|
||||
await syncToFlask(op.storeName, op.key, null, 'delete')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[PersistenceMiddleware] Failed to persist ${op.type} for ${op.storeName}:${op.key}`, error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const failed = results.filter(r => r.status === 'rejected')
|
||||
if (failed.length > 0) {
|
||||
console.warn(`[PersistenceMiddleware] ${failed.length} operations failed`)
|
||||
}
|
||||
} finally {
|
||||
this.processing = false
|
||||
}
|
||||
}
|
||||
|
||||
async flush() {
|
||||
for (const timer of this.debounceTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
this.debounceTimers.clear()
|
||||
await this.processQueue()
|
||||
}
|
||||
}
|
||||
|
||||
const persistenceQueue = new PersistenceQueue()
|
||||
|
||||
export const createPersistenceMiddleware = (): Middleware => {
|
||||
return (storeAPI) => (next) => (action: any) => {
|
||||
const result = next(action)
|
||||
|
||||
if (!action.type) return result
|
||||
|
||||
const [sliceName, actionName] = action.type.split('/')
|
||||
|
||||
const config = sliceToPersistenceMap[sliceName]
|
||||
if (!config || !config.enabled) return result
|
||||
|
||||
const state = storeAPI.getState() as RootState
|
||||
|
||||
const sliceState = state[sliceName as keyof RootState]
|
||||
if (!sliceState) return result
|
||||
|
||||
try {
|
||||
if (actionName === 'addItem' || actionName === 'updateItem' || actionName === 'saveFile' ||
|
||||
actionName === 'saveModel' || actionName === 'saveComponent' || actionName === 'saveComponentTree' ||
|
||||
actionName === 'saveWorkflow' || actionName === 'saveLambda') {
|
||||
|
||||
const item = action.payload
|
||||
if (item && item.id) {
|
||||
persistenceQueue.enqueue({
|
||||
type: 'put',
|
||||
storeName: config.storeName,
|
||||
key: item.id,
|
||||
value: { ...item, updatedAt: Date.now() },
|
||||
timestamp: Date.now(),
|
||||
}, config.debounceMs)
|
||||
}
|
||||
}
|
||||
|
||||
if (actionName === 'addItems' || actionName === 'setItems' || actionName === 'setFiles' ||
|
||||
actionName === 'setModels' || actionName === 'setComponents' || actionName === 'setComponentTrees' ||
|
||||
actionName === 'setWorkflows' || actionName === 'setLambdas') {
|
||||
|
||||
const items = action.payload
|
||||
if (Array.isArray(items)) {
|
||||
items.forEach((item: any) => {
|
||||
if (item && item.id) {
|
||||
persistenceQueue.enqueue({
|
||||
type: 'put',
|
||||
storeName: config.storeName,
|
||||
key: item.id,
|
||||
value: { ...item, updatedAt: Date.now() },
|
||||
timestamp: Date.now(),
|
||||
}, config.debounceMs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (actionName === 'removeItem' || actionName === 'deleteFile' || actionName === 'deleteModel' ||
|
||||
actionName === 'deleteComponent' || actionName === 'deleteComponentTree' ||
|
||||
actionName === 'deleteWorkflow' || actionName === 'deleteLambda') {
|
||||
|
||||
const itemId = typeof action.payload === 'string' ? action.payload : action.payload?.id
|
||||
if (itemId) {
|
||||
persistenceQueue.enqueue({
|
||||
type: 'delete',
|
||||
storeName: config.storeName,
|
||||
key: itemId,
|
||||
timestamp: Date.now(),
|
||||
}, config.debounceMs)
|
||||
}
|
||||
}
|
||||
|
||||
if (actionName === 'setTheme') {
|
||||
persistenceQueue.enqueue({
|
||||
type: 'put',
|
||||
storeName: 'theme',
|
||||
key: 'current',
|
||||
value: action.payload,
|
||||
timestamp: Date.now(),
|
||||
}, config.debounceMs)
|
||||
}
|
||||
|
||||
if (actionName === 'updateSettings') {
|
||||
persistenceQueue.enqueue({
|
||||
type: 'put',
|
||||
storeName: 'settings',
|
||||
key: 'appSettings',
|
||||
value: action.payload,
|
||||
timestamp: Date.now(),
|
||||
}, config.debounceMs)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[PersistenceMiddleware] Error handling action:', action.type, error)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
export const flushPersistence = () => persistenceQueue.flush()
|
||||
|
||||
export const configurePersistence = (sliceName: string, config: Partial<PersistenceConfig>) => {
|
||||
if (sliceToPersistenceMap[sliceName]) {
|
||||
sliceToPersistenceMap[sliceName] = {
|
||||
...sliceToPersistenceMap[sliceName],
|
||||
...config,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const disablePersistence = (sliceName: string) => {
|
||||
if (sliceToPersistenceMap[sliceName]) {
|
||||
sliceToPersistenceMap[sliceName].enabled = false
|
||||
}
|
||||
}
|
||||
|
||||
export const enablePersistence = (sliceName: string) => {
|
||||
if (sliceToPersistenceMap[sliceName]) {
|
||||
sliceToPersistenceMap[sliceName].enabled = true
|
||||
}
|
||||
}
|
||||
134
src/store/middleware/syncMonitorMiddleware.ts
Normal file
134
src/store/middleware/syncMonitorMiddleware.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Middleware } from '@reduxjs/toolkit'
|
||||
import { RootState } from '../index'
|
||||
|
||||
interface SyncMetrics {
|
||||
totalOperations: number
|
||||
successfulOperations: number
|
||||
failedOperations: number
|
||||
lastOperationTime: number
|
||||
averageOperationTime: number
|
||||
operationTimes: number[]
|
||||
}
|
||||
|
||||
class SyncMonitor {
|
||||
private metrics: SyncMetrics = {
|
||||
totalOperations: 0,
|
||||
successfulOperations: 0,
|
||||
failedOperations: 0,
|
||||
lastOperationTime: 0,
|
||||
averageOperationTime: 0,
|
||||
operationTimes: [],
|
||||
}
|
||||
|
||||
private operationStartTimes: Map<string, number> = new Map()
|
||||
private listeners: Set<(metrics: SyncMetrics) => void> = new Set()
|
||||
|
||||
startOperation(operationId: string) {
|
||||
this.operationStartTimes.set(operationId, Date.now())
|
||||
}
|
||||
|
||||
endOperation(operationId: string, success: boolean) {
|
||||
const startTime = this.operationStartTimes.get(operationId)
|
||||
if (!startTime) return
|
||||
|
||||
const duration = Date.now() - startTime
|
||||
this.operationStartTimes.delete(operationId)
|
||||
|
||||
this.metrics.totalOperations++
|
||||
if (success) {
|
||||
this.metrics.successfulOperations++
|
||||
} else {
|
||||
this.metrics.failedOperations++
|
||||
}
|
||||
|
||||
this.metrics.lastOperationTime = Date.now()
|
||||
this.metrics.operationTimes.push(duration)
|
||||
|
||||
if (this.metrics.operationTimes.length > 100) {
|
||||
this.metrics.operationTimes.shift()
|
||||
}
|
||||
|
||||
this.metrics.averageOperationTime =
|
||||
this.metrics.operationTimes.reduce((a, b) => a + b, 0) / this.metrics.operationTimes.length
|
||||
|
||||
this.notifyListeners()
|
||||
}
|
||||
|
||||
getMetrics(): SyncMetrics {
|
||||
return { ...this.metrics }
|
||||
}
|
||||
|
||||
subscribe(listener: (metrics: SyncMetrics) => void) {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
}
|
||||
|
||||
private notifyListeners() {
|
||||
this.listeners.forEach((listener) => listener(this.getMetrics()))
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.metrics = {
|
||||
totalOperations: 0,
|
||||
successfulOperations: 0,
|
||||
failedOperations: 0,
|
||||
lastOperationTime: 0,
|
||||
averageOperationTime: 0,
|
||||
operationTimes: [],
|
||||
}
|
||||
this.notifyListeners()
|
||||
}
|
||||
}
|
||||
|
||||
export const syncMonitor = new SyncMonitor()
|
||||
|
||||
export const createSyncMonitorMiddleware = (): Middleware => {
|
||||
return () => (next) => (action: any) => {
|
||||
if (!action.type) return next(action)
|
||||
|
||||
const asyncThunkActions = [
|
||||
'files/saveFile',
|
||||
'files/deleteFile',
|
||||
'models/saveModel',
|
||||
'models/deleteModel',
|
||||
'components/saveComponent',
|
||||
'components/deleteComponent',
|
||||
'componentTrees/saveComponentTree',
|
||||
'componentTrees/deleteComponentTree',
|
||||
'workflows/saveWorkflow',
|
||||
'workflows/deleteWorkflow',
|
||||
'lambdas/saveLambda',
|
||||
'lambdas/deleteLambda',
|
||||
'sync/syncToFlaskBulk',
|
||||
'sync/syncFromFlaskBulk',
|
||||
]
|
||||
|
||||
const isPendingAction = asyncThunkActions.some((prefix) => action.type === `${prefix}/pending`)
|
||||
const isFulfilledAction = asyncThunkActions.some((prefix) => action.type === `${prefix}/fulfilled`)
|
||||
const isRejectedAction = asyncThunkActions.some((prefix) => action.type === `${prefix}/rejected`)
|
||||
|
||||
if (isPendingAction) {
|
||||
const operationId = action.meta?.requestId || `${action.type}-${Date.now()}`
|
||||
syncMonitor.startOperation(operationId)
|
||||
}
|
||||
|
||||
const result = next(action)
|
||||
|
||||
if (isFulfilledAction) {
|
||||
const operationId = action.meta?.requestId || `${action.type}-${Date.now()}`
|
||||
syncMonitor.endOperation(operationId, true)
|
||||
}
|
||||
|
||||
if (isRejectedAction) {
|
||||
const operationId = action.meta?.requestId || `${action.type}-${Date.now()}`
|
||||
syncMonitor.endOperation(operationId, false)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
export const getSyncMetrics = () => syncMonitor.getMetrics()
|
||||
export const resetSyncMetrics = () => syncMonitor.reset()
|
||||
export const subscribeSyncMetrics = (listener: (metrics: SyncMetrics) => void) =>
|
||||
syncMonitor.subscribe(listener)
|
||||
Reference in New Issue
Block a user