htmlgraph 0.27.6__py3-none-any.whl → 0.28.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- htmlgraph/__init__.py +9 -1
- htmlgraph/api/broadcast.py +316 -0
- htmlgraph/api/broadcast_routes.py +357 -0
- htmlgraph/api/broadcast_websocket.py +115 -0
- htmlgraph/api/cost_alerts_websocket.py +7 -16
- htmlgraph/api/main.py +110 -1
- htmlgraph/api/offline.py +776 -0
- htmlgraph/api/presence.py +446 -0
- htmlgraph/api/reactive.py +455 -0
- htmlgraph/api/reactive_routes.py +195 -0
- htmlgraph/api/static/broadcast-demo.html +393 -0
- htmlgraph/api/sync_routes.py +184 -0
- htmlgraph/api/websocket.py +112 -37
- htmlgraph/broadcast_integration.py +227 -0
- htmlgraph/cli_commands/sync.py +207 -0
- htmlgraph/db/schema.py +214 -0
- htmlgraph/hooks/event_tracker.py +53 -2
- htmlgraph/reactive_integration.py +148 -0
- htmlgraph/session_context.py +1669 -0
- htmlgraph/session_manager.py +70 -0
- htmlgraph/sync/__init__.py +21 -0
- htmlgraph/sync/git_sync.py +458 -0
- {htmlgraph-0.27.6.dist-info → htmlgraph-0.28.0.dist-info}/METADATA +1 -1
- {htmlgraph-0.27.6.dist-info → htmlgraph-0.28.0.dist-info}/RECORD +31 -16
- {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/dashboard.html +0 -0
- {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/styles.css +0 -0
- {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/templates/AGENTS.md.template +0 -0
- {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/templates/CLAUDE.md.template +0 -0
- {htmlgraph-0.27.6.data → htmlgraph-0.28.0.data}/data/htmlgraph/templates/GEMINI.md.template +0 -0
- {htmlgraph-0.27.6.dist-info → htmlgraph-0.28.0.dist-info}/WHEEL +0 -0
- {htmlgraph-0.27.6.dist-info → htmlgraph-0.28.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Cross-Session Broadcast Demo</title>
|
|
7
|
+
<style>
|
|
8
|
+
body {
|
|
9
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
10
|
+
max-width: 1200px;
|
|
11
|
+
margin: 0 auto;
|
|
12
|
+
padding: 20px;
|
|
13
|
+
background: #f5f5f5;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
.container {
|
|
17
|
+
display: grid;
|
|
18
|
+
grid-template-columns: 1fr 1fr;
|
|
19
|
+
gap: 20px;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
.panel {
|
|
23
|
+
background: white;
|
|
24
|
+
border-radius: 8px;
|
|
25
|
+
padding: 20px;
|
|
26
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
h2 {
|
|
30
|
+
margin-top: 0;
|
|
31
|
+
color: #333;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.status {
|
|
35
|
+
display: inline-block;
|
|
36
|
+
padding: 4px 12px;
|
|
37
|
+
border-radius: 12px;
|
|
38
|
+
font-size: 12px;
|
|
39
|
+
font-weight: 600;
|
|
40
|
+
margin-left: 10px;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.status.connected {
|
|
44
|
+
background: #10b981;
|
|
45
|
+
color: white;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.status.disconnected {
|
|
49
|
+
background: #ef4444;
|
|
50
|
+
color: white;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.event {
|
|
54
|
+
padding: 12px;
|
|
55
|
+
margin: 8px 0;
|
|
56
|
+
border-radius: 4px;
|
|
57
|
+
border-left: 4px solid #3b82f6;
|
|
58
|
+
background: #eff6ff;
|
|
59
|
+
animation: slideIn 0.3s ease;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@keyframes slideIn {
|
|
63
|
+
from {
|
|
64
|
+
transform: translateX(-20px);
|
|
65
|
+
opacity: 0;
|
|
66
|
+
}
|
|
67
|
+
to {
|
|
68
|
+
transform: translateX(0);
|
|
69
|
+
opacity: 1;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.event-type {
|
|
74
|
+
font-weight: 600;
|
|
75
|
+
color: #1e40af;
|
|
76
|
+
margin-bottom: 4px;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.event-details {
|
|
80
|
+
font-size: 14px;
|
|
81
|
+
color: #64748b;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.timestamp {
|
|
85
|
+
font-size: 12px;
|
|
86
|
+
color: #94a3b8;
|
|
87
|
+
margin-top: 4px;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.feature-card {
|
|
91
|
+
padding: 16px;
|
|
92
|
+
margin: 12px 0;
|
|
93
|
+
border-radius: 8px;
|
|
94
|
+
border: 1px solid #e2e8f0;
|
|
95
|
+
background: white;
|
|
96
|
+
transition: all 0.3s ease;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
.feature-card.updated {
|
|
100
|
+
border-color: #10b981;
|
|
101
|
+
background: #ecfdf5;
|
|
102
|
+
animation: pulse 0.5s ease;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@keyframes pulse {
|
|
106
|
+
0%, 100% { transform: scale(1); }
|
|
107
|
+
50% { transform: scale(1.02); }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
.feature-title {
|
|
111
|
+
font-weight: 600;
|
|
112
|
+
margin-bottom: 8px;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.feature-status {
|
|
116
|
+
display: inline-block;
|
|
117
|
+
padding: 4px 8px;
|
|
118
|
+
border-radius: 4px;
|
|
119
|
+
font-size: 12px;
|
|
120
|
+
font-weight: 500;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.feature-status.todo { background: #e0e7ff; color: #4338ca; }
|
|
124
|
+
.feature-status.in_progress { background: #fef3c7; color: #92400e; }
|
|
125
|
+
.feature-status.done { background: #d1fae5; color: #065f46; }
|
|
126
|
+
.feature-status.blocked { background: #fee2e2; color: #991b1b; }
|
|
127
|
+
|
|
128
|
+
button {
|
|
129
|
+
padding: 8px 16px;
|
|
130
|
+
background: #3b82f6;
|
|
131
|
+
color: white;
|
|
132
|
+
border: none;
|
|
133
|
+
border-radius: 4px;
|
|
134
|
+
cursor: pointer;
|
|
135
|
+
font-size: 14px;
|
|
136
|
+
margin: 4px;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
button:hover {
|
|
140
|
+
background: #2563eb;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
.controls {
|
|
144
|
+
margin: 20px 0;
|
|
145
|
+
padding: 15px;
|
|
146
|
+
background: #f8fafc;
|
|
147
|
+
border-radius: 4px;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
.notification {
|
|
151
|
+
position: fixed;
|
|
152
|
+
top: 20px;
|
|
153
|
+
right: 20px;
|
|
154
|
+
background: #10b981;
|
|
155
|
+
color: white;
|
|
156
|
+
padding: 16px 24px;
|
|
157
|
+
border-radius: 8px;
|
|
158
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
159
|
+
animation: slideInRight 0.3s ease;
|
|
160
|
+
z-index: 1000;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
@keyframes slideInRight {
|
|
164
|
+
from {
|
|
165
|
+
transform: translateX(400px);
|
|
166
|
+
opacity: 0;
|
|
167
|
+
}
|
|
168
|
+
to {
|
|
169
|
+
transform: translateX(0);
|
|
170
|
+
opacity: 1;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
.event-feed {
|
|
175
|
+
max-height: 500px;
|
|
176
|
+
overflow-y: auto;
|
|
177
|
+
}
|
|
178
|
+
</style>
|
|
179
|
+
</head>
|
|
180
|
+
<body>
|
|
181
|
+
<h1>Cross-Session Broadcast Demo</h1>
|
|
182
|
+
<p>Demonstrates real-time feature updates across multiple sessions</p>
|
|
183
|
+
|
|
184
|
+
<div class="controls">
|
|
185
|
+
<strong>Simulate Updates:</strong>
|
|
186
|
+
<button onclick="simulateFeatureUpdate()">Update Feature</button>
|
|
187
|
+
<button onclick="simulateStatusChange()">Change Status</button>
|
|
188
|
+
<button onclick="simulateLinkAdd()">Add Link</button>
|
|
189
|
+
<button onclick="simulateCreate()">Create Feature</button>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<div class="container">
|
|
193
|
+
<div class="panel">
|
|
194
|
+
<h2>
|
|
195
|
+
Live Features
|
|
196
|
+
<span class="status" id="ws-status">Disconnected</span>
|
|
197
|
+
</h2>
|
|
198
|
+
<div id="features"></div>
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<div class="panel">
|
|
202
|
+
<h2>Broadcast Events</h2>
|
|
203
|
+
<div class="event-feed" id="event-feed"></div>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
<script>
|
|
208
|
+
let ws = null;
|
|
209
|
+
let features = new Map();
|
|
210
|
+
const maxEvents = 20;
|
|
211
|
+
|
|
212
|
+
// Initialize features
|
|
213
|
+
features.set('feat-123', { title: 'User Authentication', status: 'in_progress' });
|
|
214
|
+
features.set('feat-456', { title: 'Dashboard API', status: 'todo' });
|
|
215
|
+
features.set('feat-789', { title: 'WebSocket Support', status: 'done' });
|
|
216
|
+
|
|
217
|
+
function connectWebSocket() {
|
|
218
|
+
// Connect to broadcast WebSocket endpoint
|
|
219
|
+
ws = new WebSocket('ws://localhost:8000/ws/broadcasts');
|
|
220
|
+
|
|
221
|
+
ws.onopen = () => {
|
|
222
|
+
console.log('WebSocket connected');
|
|
223
|
+
document.getElementById('ws-status').className = 'status connected';
|
|
224
|
+
document.getElementById('ws-status').textContent = 'Connected';
|
|
225
|
+
|
|
226
|
+
// Send subscribe message
|
|
227
|
+
ws.send('subscribe');
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
ws.onmessage = (event) => {
|
|
231
|
+
const msg = JSON.parse(event.data);
|
|
232
|
+
console.log('Received message:', msg);
|
|
233
|
+
|
|
234
|
+
if (msg.type === 'broadcast_event') {
|
|
235
|
+
handleBroadcastEvent(msg);
|
|
236
|
+
} else if (msg.type === 'subscribed') {
|
|
237
|
+
addEventToFeed('info', 'Connected to broadcast channel', msg.message);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
ws.onclose = () => {
|
|
242
|
+
console.log('WebSocket disconnected');
|
|
243
|
+
document.getElementById('ws-status').className = 'status disconnected';
|
|
244
|
+
document.getElementById('ws-status').textContent = 'Disconnected';
|
|
245
|
+
|
|
246
|
+
// Reconnect after 3 seconds
|
|
247
|
+
setTimeout(connectWebSocket, 3000);
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
ws.onerror = (error) => {
|
|
251
|
+
console.error('WebSocket error:', error);
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function handleBroadcastEvent(msg) {
|
|
256
|
+
const eventType = msg.event_type;
|
|
257
|
+
const resourceId = msg.resource_id;
|
|
258
|
+
const agentId = msg.agent_id;
|
|
259
|
+
const payload = msg.payload;
|
|
260
|
+
|
|
261
|
+
console.log(`Broadcast: ${eventType} for ${resourceId} by ${agentId}`);
|
|
262
|
+
|
|
263
|
+
// Update UI based on event type
|
|
264
|
+
if (eventType === 'feature_updated') {
|
|
265
|
+
updateFeature(resourceId, payload);
|
|
266
|
+
showNotification(`Feature ${resourceId} updated by ${agentId}`);
|
|
267
|
+
addEventToFeed('update', `Feature Updated: ${resourceId}`,
|
|
268
|
+
`Agent: ${agentId}, Changes: ${JSON.stringify(payload)}`);
|
|
269
|
+
} else if (eventType === 'feature_created') {
|
|
270
|
+
createFeature(resourceId, payload);
|
|
271
|
+
showNotification(`New feature ${resourceId} created by ${agentId}`);
|
|
272
|
+
addEventToFeed('create', `Feature Created: ${resourceId}`,
|
|
273
|
+
`Agent: ${agentId}, Title: ${payload.title}`);
|
|
274
|
+
} else if (eventType === 'status_changed') {
|
|
275
|
+
updateFeatureStatus(resourceId, payload.new_status);
|
|
276
|
+
showNotification(`Status changed: ${payload.old_status} → ${payload.new_status}`);
|
|
277
|
+
addEventToFeed('status', `Status Changed: ${resourceId}`,
|
|
278
|
+
`${payload.old_status} → ${payload.new_status} by ${agentId}`);
|
|
279
|
+
} else if (eventType === 'link_added') {
|
|
280
|
+
showNotification(`Link added: ${resourceId} → ${payload.linked_feature_id}`);
|
|
281
|
+
addEventToFeed('link', `Link Added`,
|
|
282
|
+
`${resourceId} ${payload.link_type} ${payload.linked_feature_id}`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
renderFeatures();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function updateFeature(featureId, payload) {
|
|
289
|
+
if (!features.has(featureId)) {
|
|
290
|
+
features.set(featureId, {});
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const feature = features.get(featureId);
|
|
294
|
+
Object.assign(feature, payload);
|
|
295
|
+
|
|
296
|
+
// Highlight updated card
|
|
297
|
+
setTimeout(() => {
|
|
298
|
+
const card = document.querySelector(`[data-feature-id="${featureId}"]`);
|
|
299
|
+
if (card) {
|
|
300
|
+
card.classList.add('updated');
|
|
301
|
+
setTimeout(() => card.classList.remove('updated'), 1000);
|
|
302
|
+
}
|
|
303
|
+
}, 100);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function createFeature(featureId, payload) {
|
|
307
|
+
features.set(featureId, payload);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function updateFeatureStatus(featureId, newStatus) {
|
|
311
|
+
if (features.has(featureId)) {
|
|
312
|
+
features.get(featureId).status = newStatus;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function renderFeatures() {
|
|
317
|
+
const container = document.getElementById('features');
|
|
318
|
+
container.innerHTML = '';
|
|
319
|
+
|
|
320
|
+
features.forEach((feature, id) => {
|
|
321
|
+
const card = document.createElement('div');
|
|
322
|
+
card.className = 'feature-card';
|
|
323
|
+
card.setAttribute('data-feature-id', id);
|
|
324
|
+
|
|
325
|
+
card.innerHTML = `
|
|
326
|
+
<div class="feature-title">${feature.title || id}</div>
|
|
327
|
+
<span class="feature-status ${feature.status}">${feature.status || 'todo'}</span>
|
|
328
|
+
`;
|
|
329
|
+
|
|
330
|
+
container.appendChild(card);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function addEventToFeed(type, title, details) {
|
|
335
|
+
const feed = document.getElementById('event-feed');
|
|
336
|
+
|
|
337
|
+
const event = document.createElement('div');
|
|
338
|
+
event.className = 'event';
|
|
339
|
+
event.innerHTML = `
|
|
340
|
+
<div class="event-type">${title}</div>
|
|
341
|
+
<div class="event-details">${details}</div>
|
|
342
|
+
<div class="timestamp">${new Date().toLocaleTimeString()}</div>
|
|
343
|
+
`;
|
|
344
|
+
|
|
345
|
+
feed.insertBefore(event, feed.firstChild);
|
|
346
|
+
|
|
347
|
+
// Keep only last N events
|
|
348
|
+
while (feed.children.length > maxEvents) {
|
|
349
|
+
feed.removeChild(feed.lastChild);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function showNotification(message) {
|
|
354
|
+
const notification = document.createElement('div');
|
|
355
|
+
notification.className = 'notification';
|
|
356
|
+
notification.textContent = message;
|
|
357
|
+
document.body.appendChild(notification);
|
|
358
|
+
|
|
359
|
+
setTimeout(() => notification.remove(), 3000);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Simulation functions for demo
|
|
363
|
+
function simulateFeatureUpdate() {
|
|
364
|
+
const featureId = 'feat-123';
|
|
365
|
+
updateFeature(featureId, { title: 'User Authentication (Updated)', status: 'in_progress' });
|
|
366
|
+
addEventToFeed('update', 'Simulated Feature Update', `Updated ${featureId}`);
|
|
367
|
+
renderFeatures();
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function simulateStatusChange() {
|
|
371
|
+
const featureId = 'feat-456';
|
|
372
|
+
updateFeatureStatus(featureId, 'in_progress');
|
|
373
|
+
addEventToFeed('status', 'Simulated Status Change', `${featureId}: todo → in_progress`);
|
|
374
|
+
renderFeatures();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function simulateLinkAdd() {
|
|
378
|
+
addEventToFeed('link', 'Simulated Link Add', 'feat-123 depends_on feat-456');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function simulateCreate() {
|
|
382
|
+
const newId = `feat-${Math.random().toString(36).substr(2, 9)}`;
|
|
383
|
+
createFeature(newId, { title: 'New Feature', status: 'todo' });
|
|
384
|
+
addEventToFeed('create', 'Simulated Feature Create', `Created ${newId}`);
|
|
385
|
+
renderFeatures();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Initialize
|
|
389
|
+
renderFeatures();
|
|
390
|
+
connectWebSocket();
|
|
391
|
+
</script>
|
|
392
|
+
</body>
|
|
393
|
+
</html>
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""
|
|
2
|
+
API routes for Git sync management.
|
|
3
|
+
|
|
4
|
+
Provides REST endpoints for manual sync triggers, status queries, and configuration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, HTTPException
|
|
10
|
+
|
|
11
|
+
from htmlgraph.sync import GitSyncManager, SyncStrategy
|
|
12
|
+
|
|
13
|
+
router = APIRouter(prefix="/api/sync", tags=["sync"])
|
|
14
|
+
|
|
15
|
+
# Global sync manager (initialized by server startup)
|
|
16
|
+
sync_manager: GitSyncManager | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def init_sync_manager(manager: GitSyncManager) -> None:
|
|
20
|
+
"""Initialize the global sync manager."""
|
|
21
|
+
global sync_manager
|
|
22
|
+
sync_manager = manager
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.post("/push")
|
|
26
|
+
async def trigger_push(force: bool = False) -> dict[str, Any]:
|
|
27
|
+
"""
|
|
28
|
+
Manually trigger push to remote.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
force: Force push even if recently pushed
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Sync result dictionary
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
HTTPException: If sync manager not initialized
|
|
38
|
+
"""
|
|
39
|
+
if sync_manager is None:
|
|
40
|
+
raise HTTPException(status_code=503, detail="Sync manager not initialized")
|
|
41
|
+
|
|
42
|
+
result = await sync_manager.push(force=force)
|
|
43
|
+
return result.to_dict()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@router.post("/pull")
|
|
47
|
+
async def trigger_pull(force: bool = False) -> dict[str, Any]:
|
|
48
|
+
"""
|
|
49
|
+
Manually trigger pull from remote.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
force: Force pull even if recently pulled
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
Sync result dictionary
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
HTTPException: If sync manager not initialized
|
|
59
|
+
"""
|
|
60
|
+
if sync_manager is None:
|
|
61
|
+
raise HTTPException(status_code=503, detail="Sync manager not initialized")
|
|
62
|
+
|
|
63
|
+
result = await sync_manager.pull(force=force)
|
|
64
|
+
return result.to_dict()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@router.get("/status")
|
|
68
|
+
async def get_sync_status() -> dict[str, Any]:
|
|
69
|
+
"""
|
|
70
|
+
Get current sync status.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Status dictionary with sync state and config
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
HTTPException: If sync manager not initialized
|
|
77
|
+
"""
|
|
78
|
+
if sync_manager is None:
|
|
79
|
+
raise HTTPException(status_code=503, detail="Sync manager not initialized")
|
|
80
|
+
|
|
81
|
+
return sync_manager.get_status()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@router.get("/history")
|
|
85
|
+
async def get_sync_history(limit: int = 50) -> dict[str, Any]:
|
|
86
|
+
"""
|
|
87
|
+
Get sync operation history.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
limit: Maximum number of results
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Dictionary with history list
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
HTTPException: If sync manager not initialized
|
|
97
|
+
"""
|
|
98
|
+
if sync_manager is None:
|
|
99
|
+
raise HTTPException(status_code=503, detail="Sync manager not initialized")
|
|
100
|
+
|
|
101
|
+
return {"history": sync_manager.get_sync_history(limit)}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@router.post("/config")
|
|
105
|
+
async def update_sync_config(
|
|
106
|
+
push_interval: int | None = None,
|
|
107
|
+
pull_interval: int | None = None,
|
|
108
|
+
conflict_strategy: str | None = None,
|
|
109
|
+
) -> dict[str, Any]:
|
|
110
|
+
"""
|
|
111
|
+
Update sync configuration.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
push_interval: Push interval in seconds
|
|
115
|
+
pull_interval: Pull interval in seconds
|
|
116
|
+
conflict_strategy: Conflict resolution strategy
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
Success status and updated config
|
|
120
|
+
|
|
121
|
+
Raises:
|
|
122
|
+
HTTPException: If sync manager not initialized or invalid parameters
|
|
123
|
+
"""
|
|
124
|
+
if sync_manager is None:
|
|
125
|
+
raise HTTPException(status_code=503, detail="Sync manager not initialized")
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
if push_interval is not None:
|
|
129
|
+
if push_interval < 10:
|
|
130
|
+
raise ValueError("Push interval must be >= 10 seconds")
|
|
131
|
+
sync_manager.config.push_interval_seconds = push_interval
|
|
132
|
+
|
|
133
|
+
if pull_interval is not None:
|
|
134
|
+
if pull_interval < 10:
|
|
135
|
+
raise ValueError("Pull interval must be >= 10 seconds")
|
|
136
|
+
sync_manager.config.pull_interval_seconds = pull_interval
|
|
137
|
+
|
|
138
|
+
if conflict_strategy is not None:
|
|
139
|
+
sync_manager.config.conflict_strategy = SyncStrategy(conflict_strategy)
|
|
140
|
+
|
|
141
|
+
return {"success": True, "config": sync_manager.get_status()["config"]}
|
|
142
|
+
except ValueError as e:
|
|
143
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@router.post("/start")
|
|
147
|
+
async def start_background_sync() -> dict[str, Any]:
|
|
148
|
+
"""
|
|
149
|
+
Start background sync service.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Success status
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
HTTPException: If sync manager not initialized
|
|
156
|
+
"""
|
|
157
|
+
if sync_manager is None:
|
|
158
|
+
raise HTTPException(status_code=503, detail="Sync manager not initialized")
|
|
159
|
+
|
|
160
|
+
# Start in background (don't await)
|
|
161
|
+
import asyncio
|
|
162
|
+
|
|
163
|
+
asyncio.create_task(sync_manager.start_background_sync())
|
|
164
|
+
|
|
165
|
+
return {"success": True, "message": "Background sync started"}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@router.post("/stop")
|
|
169
|
+
async def stop_background_sync() -> dict[str, Any]:
|
|
170
|
+
"""
|
|
171
|
+
Stop background sync service.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Success status
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
HTTPException: If sync manager not initialized
|
|
178
|
+
"""
|
|
179
|
+
if sync_manager is None:
|
|
180
|
+
raise HTTPException(status_code=503, detail="Sync manager not initialized")
|
|
181
|
+
|
|
182
|
+
await sync_manager.stop_background_sync()
|
|
183
|
+
|
|
184
|
+
return {"success": True, "message": "Background sync stopped"}
|