hsync-cli 1.0.5 → 1.1.1
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.
- package/dist/bin.js +19802 -757
- package/dist/web/index.html +1400 -0
- package/package.json +6 -3
|
@@ -0,0 +1,1400 @@
|
|
|
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>hsync Sync Control Center</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
10
|
+
<style>
|
|
11
|
+
:root {
|
|
12
|
+
--bg: #090d16;
|
|
13
|
+
--surface: rgba(17, 24, 39, 0.65);
|
|
14
|
+
--surface-hover: rgba(30, 41, 59, 0.7);
|
|
15
|
+
--surface-border: rgba(255, 255, 255, 0.07);
|
|
16
|
+
--text: #f1f5f9;
|
|
17
|
+
--text-muted: #94a3b8;
|
|
18
|
+
--accent: #6366f1;
|
|
19
|
+
--accent-hover: #4f46e5;
|
|
20
|
+
--accent-glow: rgba(99, 102, 241, 0.15);
|
|
21
|
+
--success: #10b981;
|
|
22
|
+
--success-glow: rgba(16, 185, 129, 0.15);
|
|
23
|
+
--danger: #f43f5e;
|
|
24
|
+
--danger-hover: #e11d48;
|
|
25
|
+
--danger-glow: rgba(244, 63, 94, 0.15);
|
|
26
|
+
--warning: #f59e0b;
|
|
27
|
+
--input-bg: #030712;
|
|
28
|
+
--radius: 16px;
|
|
29
|
+
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
|
30
|
+
--font-mono: 'JetBrains Mono', monospace;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
* {
|
|
34
|
+
box-sizing: border-box;
|
|
35
|
+
margin: 0;
|
|
36
|
+
padding: 0;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
body {
|
|
40
|
+
background-color: var(--bg);
|
|
41
|
+
color: var(--text);
|
|
42
|
+
font-family: var(--font-sans);
|
|
43
|
+
min-height: 100vh;
|
|
44
|
+
display: flex;
|
|
45
|
+
flex-direction: column;
|
|
46
|
+
padding: 24px;
|
|
47
|
+
line-height: 1.5;
|
|
48
|
+
overflow-x: hidden;
|
|
49
|
+
background-image:
|
|
50
|
+
radial-gradient(circle at 10% 20%, rgba(99, 102, 241, 0.05) 0%, transparent 40%),
|
|
51
|
+
radial-gradient(circle at 90% 80%, rgba(16, 185, 129, 0.03) 0%, transparent 40%);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/* Container */
|
|
55
|
+
.container {
|
|
56
|
+
max-width: 1200px;
|
|
57
|
+
margin: 0 auto;
|
|
58
|
+
width: 100%;
|
|
59
|
+
display: flex;
|
|
60
|
+
flex-direction: column;
|
|
61
|
+
flex: 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/* Header */
|
|
65
|
+
header {
|
|
66
|
+
display: flex;
|
|
67
|
+
justify-content: space-between;
|
|
68
|
+
align-items: center;
|
|
69
|
+
padding-bottom: 20px;
|
|
70
|
+
border-bottom: 1px solid var(--surface-border);
|
|
71
|
+
margin-bottom: 28px;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.brand {
|
|
75
|
+
display: flex;
|
|
76
|
+
align-items: center;
|
|
77
|
+
gap: 14px;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.brand-icon {
|
|
81
|
+
padding: 10px;
|
|
82
|
+
border-radius: 12px;
|
|
83
|
+
background: var(--accent-glow);
|
|
84
|
+
border: 1px solid rgba(99, 102, 241, 0.2);
|
|
85
|
+
display: flex;
|
|
86
|
+
justify-content: center;
|
|
87
|
+
align-items: center;
|
|
88
|
+
color: var(--accent);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.brand-icon svg {
|
|
92
|
+
width: 22px;
|
|
93
|
+
height: 22px;
|
|
94
|
+
animation: spin 12s linear infinite;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
@keyframes spin {
|
|
98
|
+
100% { transform: rotate(360deg); }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.brand-title h1 {
|
|
102
|
+
font-size: 1.15rem;
|
|
103
|
+
font-weight: 800;
|
|
104
|
+
letter-spacing: -0.025em;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
.brand-server {
|
|
108
|
+
display: flex;
|
|
109
|
+
align-items: center;
|
|
110
|
+
gap: 6px;
|
|
111
|
+
margin-top: 2px;
|
|
112
|
+
font-size: 0.7rem;
|
|
113
|
+
font-weight: 700;
|
|
114
|
+
text-transform: uppercase;
|
|
115
|
+
letter-spacing: 0.05em;
|
|
116
|
+
color: var(--text-muted);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.brand-server span.url {
|
|
120
|
+
font-family: var(--font-mono);
|
|
121
|
+
color: var(--text);
|
|
122
|
+
text-transform: none;
|
|
123
|
+
letter-spacing: normal;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.header-actions {
|
|
127
|
+
display: flex;
|
|
128
|
+
align-items: center;
|
|
129
|
+
gap: 12px;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* Buttons */
|
|
133
|
+
.btn {
|
|
134
|
+
display: inline-flex;
|
|
135
|
+
align-items: center;
|
|
136
|
+
justify-content: center;
|
|
137
|
+
gap: 8px;
|
|
138
|
+
padding: 10px 18px;
|
|
139
|
+
font-size: 0.8rem;
|
|
140
|
+
font-weight: 700;
|
|
141
|
+
border-radius: 12px;
|
|
142
|
+
border: 1px solid transparent;
|
|
143
|
+
cursor: pointer;
|
|
144
|
+
transition: all 0.2s ease;
|
|
145
|
+
font-family: var(--font-sans);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.btn-primary {
|
|
149
|
+
background: var(--accent);
|
|
150
|
+
color: #fff;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.btn-primary:hover {
|
|
154
|
+
background: var(--accent-hover);
|
|
155
|
+
box-shadow: 0 0 16px rgba(99, 102, 241, 0.4);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.btn-secondary {
|
|
159
|
+
background: #0f172a;
|
|
160
|
+
border-color: var(--surface-border);
|
|
161
|
+
color: var(--text);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.btn-secondary:hover {
|
|
165
|
+
background: var(--surface-hover);
|
|
166
|
+
border-color: rgba(255, 255, 255, 0.15);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.btn-danger {
|
|
170
|
+
background: var(--danger-glow);
|
|
171
|
+
border-color: rgba(244, 63, 94, 0.2);
|
|
172
|
+
color: var(--danger);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.btn-danger:hover {
|
|
176
|
+
background: var(--danger);
|
|
177
|
+
color: #fff;
|
|
178
|
+
box-shadow: 0 0 16px rgba(244, 63, 94, 0.3);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
.btn-icon {
|
|
182
|
+
padding: 8px;
|
|
183
|
+
border-radius: 10px;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* Main Grid layout */
|
|
187
|
+
.dashboard-grid {
|
|
188
|
+
display: flex;
|
|
189
|
+
flex-direction: column;
|
|
190
|
+
gap: 28px;
|
|
191
|
+
flex: 1;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.profiles-section {
|
|
195
|
+
display: flex;
|
|
196
|
+
flex-direction: column;
|
|
197
|
+
gap: 16px;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.section-title {
|
|
201
|
+
font-size: 0.9rem;
|
|
202
|
+
font-weight: 800;
|
|
203
|
+
text-transform: uppercase;
|
|
204
|
+
letter-spacing: 0.05em;
|
|
205
|
+
color: var(--text-muted);
|
|
206
|
+
display: flex;
|
|
207
|
+
justify-content: space-between;
|
|
208
|
+
align-items: center;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/* Cards */
|
|
212
|
+
.grid-layout {
|
|
213
|
+
display: grid;
|
|
214
|
+
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
|
215
|
+
gap: 20px;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
.card {
|
|
219
|
+
background: var(--surface);
|
|
220
|
+
border: 1px solid var(--surface-border);
|
|
221
|
+
border-radius: var(--radius);
|
|
222
|
+
padding: 20px;
|
|
223
|
+
backdrop-filter: blur(12px);
|
|
224
|
+
-webkit-backdrop-filter: blur(12px);
|
|
225
|
+
display: flex;
|
|
226
|
+
flex-direction: column;
|
|
227
|
+
justify-content: space-between;
|
|
228
|
+
transition: transform 0.2s ease, border-color 0.2s ease;
|
|
229
|
+
position: relative;
|
|
230
|
+
overflow: hidden;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
.card:hover {
|
|
234
|
+
transform: translateY(-2px);
|
|
235
|
+
border-color: rgba(255, 255, 255, 0.12);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.card-header {
|
|
239
|
+
display: flex;
|
|
240
|
+
justify-content: space-between;
|
|
241
|
+
align-items: flex-start;
|
|
242
|
+
margin-bottom: 16px;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.profile-title {
|
|
246
|
+
font-size: 1rem;
|
|
247
|
+
font-weight: 700;
|
|
248
|
+
color: #fff;
|
|
249
|
+
letter-spacing: -0.01em;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
.status-badge {
|
|
253
|
+
display: inline-flex;
|
|
254
|
+
align-items: center;
|
|
255
|
+
gap: 6px;
|
|
256
|
+
font-size: 0.65rem;
|
|
257
|
+
font-weight: 800;
|
|
258
|
+
text-transform: uppercase;
|
|
259
|
+
letter-spacing: 0.05em;
|
|
260
|
+
padding: 4px 10px;
|
|
261
|
+
border-radius: 30px;
|
|
262
|
+
border: 1px solid transparent;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.status-active {
|
|
266
|
+
background: var(--success-glow);
|
|
267
|
+
color: var(--success);
|
|
268
|
+
border-color: rgba(16, 185, 129, 0.2);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
.status-active .pulse-dot {
|
|
272
|
+
width: 6px;
|
|
273
|
+
height: 6px;
|
|
274
|
+
background: var(--success);
|
|
275
|
+
border-radius: 50%;
|
|
276
|
+
animation: pulse 1.8s infinite;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
.status-inactive {
|
|
280
|
+
background: rgba(148, 163, 184, 0.08);
|
|
281
|
+
color: var(--text-muted);
|
|
282
|
+
border-color: rgba(148, 163, 184, 0.15);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.status-inactive .pulse-dot {
|
|
286
|
+
width: 6px;
|
|
287
|
+
height: 6px;
|
|
288
|
+
background: var(--text-muted);
|
|
289
|
+
border-radius: 50%;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
@keyframes pulse {
|
|
293
|
+
0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.7); }
|
|
294
|
+
70% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); }
|
|
295
|
+
100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.card-body {
|
|
299
|
+
display: flex;
|
|
300
|
+
flex-direction: column;
|
|
301
|
+
gap: 12px;
|
|
302
|
+
margin-bottom: 20px;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
.path-group {
|
|
306
|
+
display: flex;
|
|
307
|
+
flex-direction: column;
|
|
308
|
+
gap: 4px;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
.path-label {
|
|
312
|
+
font-size: 0.625rem;
|
|
313
|
+
font-weight: 700;
|
|
314
|
+
color: var(--text-muted);
|
|
315
|
+
text-transform: uppercase;
|
|
316
|
+
letter-spacing: 0.05em;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.path-value {
|
|
320
|
+
font-family: var(--font-mono);
|
|
321
|
+
font-size: 0.75rem;
|
|
322
|
+
background: rgba(0, 0, 0, 0.25);
|
|
323
|
+
padding: 6px 10px;
|
|
324
|
+
border-radius: 8px;
|
|
325
|
+
border: 1px solid rgba(255, 255, 255, 0.03);
|
|
326
|
+
overflow-x: auto;
|
|
327
|
+
white-space: nowrap;
|
|
328
|
+
color: #e2e8f0;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
.meta-row {
|
|
332
|
+
display: grid;
|
|
333
|
+
grid-template-columns: repeat(3, 1fr);
|
|
334
|
+
gap: 8px;
|
|
335
|
+
margin-top: 4px;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.meta-item {
|
|
339
|
+
display: flex;
|
|
340
|
+
flex-direction: column;
|
|
341
|
+
gap: 2px;
|
|
342
|
+
background: rgba(255, 255, 255, 0.02);
|
|
343
|
+
padding: 6px 8px;
|
|
344
|
+
border-radius: 8px;
|
|
345
|
+
border: 1px solid rgba(255, 255, 255, 0.02);
|
|
346
|
+
text-align: center;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
.meta-val {
|
|
350
|
+
font-size: 0.7rem;
|
|
351
|
+
font-weight: 700;
|
|
352
|
+
color: #fff;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
.card-actions {
|
|
356
|
+
display: flex;
|
|
357
|
+
justify-content: space-between;
|
|
358
|
+
align-items: center;
|
|
359
|
+
border-top: 1px solid var(--surface-border);
|
|
360
|
+
padding-top: 14px;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
.card-actions-left {
|
|
364
|
+
display: flex;
|
|
365
|
+
gap: 8px;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/* Toggle Switch */
|
|
369
|
+
.switch-label {
|
|
370
|
+
font-size: 0.7rem;
|
|
371
|
+
font-weight: 700;
|
|
372
|
+
color: var(--text-muted);
|
|
373
|
+
text-transform: uppercase;
|
|
374
|
+
letter-spacing: 0.05em;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/* Terminal Console */
|
|
378
|
+
.terminal-section {
|
|
379
|
+
display: flex;
|
|
380
|
+
flex-direction: column;
|
|
381
|
+
gap: 12px;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
.console-header {
|
|
385
|
+
display: flex;
|
|
386
|
+
justify-content: space-between;
|
|
387
|
+
align-items: center;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
.console-title {
|
|
391
|
+
font-size: 0.9rem;
|
|
392
|
+
font-weight: 800;
|
|
393
|
+
text-transform: uppercase;
|
|
394
|
+
letter-spacing: 0.05em;
|
|
395
|
+
color: var(--text-muted);
|
|
396
|
+
display: flex;
|
|
397
|
+
align-items: center;
|
|
398
|
+
gap: 8px;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
.console-title svg {
|
|
402
|
+
width: 16px;
|
|
403
|
+
height: 16px;
|
|
404
|
+
color: var(--accent);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
.console-window {
|
|
408
|
+
background: #020617;
|
|
409
|
+
border: 1px solid var(--surface-border);
|
|
410
|
+
border-radius: var(--radius);
|
|
411
|
+
padding: 16px;
|
|
412
|
+
height: 240px;
|
|
413
|
+
overflow-y: auto;
|
|
414
|
+
font-family: var(--font-mono);
|
|
415
|
+
font-size: 0.75rem;
|
|
416
|
+
line-height: 1.6;
|
|
417
|
+
color: #38bdf8;
|
|
418
|
+
box-shadow: inset 0 2px 10px rgba(0, 0, 0, 0.8);
|
|
419
|
+
position: relative;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
.console-line {
|
|
423
|
+
display: flex;
|
|
424
|
+
gap: 12px;
|
|
425
|
+
padding: 2px 0;
|
|
426
|
+
border-bottom: 1px solid rgba(255, 255, 255, 0.01);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
.console-timestamp {
|
|
430
|
+
color: var(--text-muted);
|
|
431
|
+
user-select: none;
|
|
432
|
+
flex-shrink: 0;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.console-message {
|
|
436
|
+
word-break: break-all;
|
|
437
|
+
color: #e2e8f0;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
.console-line.error .console-message {
|
|
441
|
+
color: var(--danger);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
.console-line.success .console-message {
|
|
445
|
+
color: var(--success);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
.console-line.system .console-message {
|
|
449
|
+
color: var(--accent);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
.console-empty {
|
|
453
|
+
display: flex;
|
|
454
|
+
flex-direction: column;
|
|
455
|
+
align-items: center;
|
|
456
|
+
justify-content: center;
|
|
457
|
+
height: 100%;
|
|
458
|
+
color: var(--text-muted);
|
|
459
|
+
gap: 12px;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
.console-empty svg {
|
|
463
|
+
width: 24px;
|
|
464
|
+
height: 24px;
|
|
465
|
+
opacity: 0.4;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/* Modal dialogs */
|
|
469
|
+
.modal-backdrop {
|
|
470
|
+
position: fixed;
|
|
471
|
+
top: 0;
|
|
472
|
+
left: 0;
|
|
473
|
+
width: 100vw;
|
|
474
|
+
height: 100vh;
|
|
475
|
+
background: rgba(0, 0, 0, 0.7);
|
|
476
|
+
backdrop-filter: blur(4px);
|
|
477
|
+
-webkit-backdrop-filter: blur(4px);
|
|
478
|
+
display: flex;
|
|
479
|
+
justify-content: center;
|
|
480
|
+
align-items: center;
|
|
481
|
+
z-index: 1000;
|
|
482
|
+
opacity: 0;
|
|
483
|
+
pointer-events: none;
|
|
484
|
+
transition: opacity 0.2s ease;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
.modal-backdrop.open {
|
|
488
|
+
opacity: 1;
|
|
489
|
+
pointer-events: all;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
.modal {
|
|
493
|
+
background: #0f172a;
|
|
494
|
+
border: 1px solid var(--surface-border);
|
|
495
|
+
border-radius: 20px;
|
|
496
|
+
width: 100%;
|
|
497
|
+
max-width: 480px;
|
|
498
|
+
padding: 24px;
|
|
499
|
+
position: relative;
|
|
500
|
+
transform: scale(0.95);
|
|
501
|
+
transition: transform 0.2s ease;
|
|
502
|
+
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
.modal-backdrop.open .modal {
|
|
506
|
+
transform: scale(1);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
.modal-header {
|
|
510
|
+
display: flex;
|
|
511
|
+
justify-content: space-between;
|
|
512
|
+
align-items: center;
|
|
513
|
+
margin-bottom: 20px;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
.modal-title {
|
|
517
|
+
font-size: 1rem;
|
|
518
|
+
font-weight: 800;
|
|
519
|
+
color: #fff;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
.modal-close {
|
|
523
|
+
background: none;
|
|
524
|
+
border: none;
|
|
525
|
+
color: var(--text-muted);
|
|
526
|
+
cursor: pointer;
|
|
527
|
+
display: flex;
|
|
528
|
+
align-items: center;
|
|
529
|
+
justify-content: center;
|
|
530
|
+
border-radius: 8px;
|
|
531
|
+
padding: 4px;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
.modal-close:hover {
|
|
535
|
+
background: rgba(255, 255, 255, 0.05);
|
|
536
|
+
color: #fff;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
.form-group {
|
|
540
|
+
display: flex;
|
|
541
|
+
flex-direction: column;
|
|
542
|
+
gap: 6px;
|
|
543
|
+
margin-bottom: 16px;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
.form-label {
|
|
547
|
+
font-size: 0.65rem;
|
|
548
|
+
font-weight: 700;
|
|
549
|
+
color: var(--text-muted);
|
|
550
|
+
text-transform: uppercase;
|
|
551
|
+
letter-spacing: 0.05em;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
.form-input {
|
|
555
|
+
background: var(--input-bg);
|
|
556
|
+
border: 1px solid var(--surface-border);
|
|
557
|
+
border-radius: 10px;
|
|
558
|
+
padding: 10px 12px;
|
|
559
|
+
color: #fff;
|
|
560
|
+
font-family: var(--font-sans);
|
|
561
|
+
font-size: 0.8rem;
|
|
562
|
+
width: 100%;
|
|
563
|
+
transition: border-color 0.2s ease;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
.form-input:focus {
|
|
567
|
+
outline: none;
|
|
568
|
+
border-color: var(--accent);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
.form-row {
|
|
572
|
+
display: grid;
|
|
573
|
+
grid-template-columns: repeat(2, 1fr);
|
|
574
|
+
gap: 12px;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
.checkbox-group {
|
|
578
|
+
display: flex;
|
|
579
|
+
align-items: center;
|
|
580
|
+
gap: 10px;
|
|
581
|
+
margin-top: 4px;
|
|
582
|
+
margin-bottom: 20px;
|
|
583
|
+
cursor: pointer;
|
|
584
|
+
user-select: none;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
.checkbox-group input {
|
|
588
|
+
width: 16px;
|
|
589
|
+
height: 16px;
|
|
590
|
+
accent-color: var(--accent);
|
|
591
|
+
cursor: pointer;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
.checkbox-label {
|
|
595
|
+
font-size: 0.75rem;
|
|
596
|
+
font-weight: 600;
|
|
597
|
+
color: var(--text);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
.modal-footer {
|
|
601
|
+
display: flex;
|
|
602
|
+
justify-content: flex-end;
|
|
603
|
+
gap: 10px;
|
|
604
|
+
margin-top: 24px;
|
|
605
|
+
border-top: 1px solid var(--surface-border);
|
|
606
|
+
padding-top: 16px;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/* Empty State */
|
|
610
|
+
.empty-state {
|
|
611
|
+
background: var(--surface);
|
|
612
|
+
border: 1px solid var(--surface-border);
|
|
613
|
+
border-radius: var(--radius);
|
|
614
|
+
padding: 60px 20px;
|
|
615
|
+
text-align: center;
|
|
616
|
+
max-width: 500px;
|
|
617
|
+
margin: 40px auto;
|
|
618
|
+
display: flex;
|
|
619
|
+
flex-direction: column;
|
|
620
|
+
align-items: center;
|
|
621
|
+
gap: 16px;
|
|
622
|
+
backdrop-filter: blur(12px);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
.empty-icon {
|
|
626
|
+
color: var(--text-muted);
|
|
627
|
+
opacity: 0.3;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
.empty-title {
|
|
631
|
+
font-size: 0.95rem;
|
|
632
|
+
font-weight: 700;
|
|
633
|
+
color: #fff;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
.empty-desc {
|
|
637
|
+
font-size: 0.75rem;
|
|
638
|
+
color: var(--text-muted);
|
|
639
|
+
line-height: 1.6;
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/* Autostart Card */
|
|
643
|
+
.settings-card {
|
|
644
|
+
background: var(--surface);
|
|
645
|
+
border: 1px solid var(--surface-border);
|
|
646
|
+
border-radius: var(--radius);
|
|
647
|
+
padding: 16px 20px;
|
|
648
|
+
display: flex;
|
|
649
|
+
justify-content: space-between;
|
|
650
|
+
align-items: center;
|
|
651
|
+
margin-top: 24px;
|
|
652
|
+
margin-bottom: 24px;
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
.settings-info {
|
|
656
|
+
display: flex;
|
|
657
|
+
flex-direction: column;
|
|
658
|
+
gap: 2px;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
.settings-title {
|
|
662
|
+
font-size: 0.85rem;
|
|
663
|
+
font-weight: 700;
|
|
664
|
+
color: #fff;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
.settings-desc {
|
|
668
|
+
font-size: 0.7rem;
|
|
669
|
+
color: var(--text-muted);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/* Spinner */
|
|
673
|
+
.loading-overlay {
|
|
674
|
+
display: flex;
|
|
675
|
+
flex-direction: column;
|
|
676
|
+
align-items: center;
|
|
677
|
+
justify-content: center;
|
|
678
|
+
gap: 12px;
|
|
679
|
+
padding: 80px 0;
|
|
680
|
+
color: var(--text-muted);
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
.spinner {
|
|
684
|
+
width: 28px;
|
|
685
|
+
height: 28px;
|
|
686
|
+
border: 2px solid rgba(255, 255, 255, 0.05);
|
|
687
|
+
border-top-color: var(--accent);
|
|
688
|
+
border-radius: 50%;
|
|
689
|
+
animation: spin-round 0.8s linear infinite;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
@keyframes spin-round {
|
|
693
|
+
100% { transform: rotate(360deg); }
|
|
694
|
+
}
|
|
695
|
+
</style>
|
|
696
|
+
</head>
|
|
697
|
+
<body>
|
|
698
|
+
<div class="container">
|
|
699
|
+
<!-- Header -->
|
|
700
|
+
<header>
|
|
701
|
+
<div class="brand">
|
|
702
|
+
<div class="brand-icon">
|
|
703
|
+
<!-- Refresh icon -->
|
|
704
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
705
|
+
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/>
|
|
706
|
+
</svg>
|
|
707
|
+
</div>
|
|
708
|
+
<div class="brand-title">
|
|
709
|
+
<h1>hsync Control Dashboard</h1>
|
|
710
|
+
<div class="brand-server">
|
|
711
|
+
<span>Server:</span>
|
|
712
|
+
<span class="url" id="serverUrlText">Loading...</span>
|
|
713
|
+
</div>
|
|
714
|
+
</div>
|
|
715
|
+
</div>
|
|
716
|
+
<div class="header-actions">
|
|
717
|
+
<button class="btn btn-secondary btn-icon" onclick="loadDashboardData()" title="Reload Data">
|
|
718
|
+
<!-- Refresh icon -->
|
|
719
|
+
<svg style="width: 16px; height: 16px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
720
|
+
<path d="M21.5 2v6h-6M21.34 15.57a10 10 0 1 1-.57-8.38l5.67-5.67"/>
|
|
721
|
+
</svg>
|
|
722
|
+
</button>
|
|
723
|
+
<button class="btn btn-primary" onclick="openAddModal()">
|
|
724
|
+
<!-- Plus icon -->
|
|
725
|
+
<svg style="width: 14px; height: 14px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
726
|
+
<line x1="12" y1="5" x2="12" y2="19"></line>
|
|
727
|
+
<line x1="5" y1="12" x2="19" y2="12"></line>
|
|
728
|
+
</svg>
|
|
729
|
+
Add Profile
|
|
730
|
+
</button>
|
|
731
|
+
</div>
|
|
732
|
+
</header>
|
|
733
|
+
|
|
734
|
+
<div class="dashboard-grid">
|
|
735
|
+
<!-- Profiles list -->
|
|
736
|
+
<div class="profiles-section">
|
|
737
|
+
<div class="section-title">
|
|
738
|
+
<span>Folder Sync Profiles</span>
|
|
739
|
+
</div>
|
|
740
|
+
|
|
741
|
+
<div id="loadingState" class="loading-overlay">
|
|
742
|
+
<div class="spinner"></div>
|
|
743
|
+
<span style="font-size: 0.75rem;">Loading configurations...</span>
|
|
744
|
+
</div>
|
|
745
|
+
|
|
746
|
+
<div id="emptyState" class="empty-state" style="display: none;">
|
|
747
|
+
<div class="empty-icon">
|
|
748
|
+
<svg style="width: 40px; height: 40px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
|
749
|
+
<rect x="2" y="2" width="20" height="20" rx="5" ry="5"/>
|
|
750
|
+
<path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z"/>
|
|
751
|
+
<line x1="17.5" y1="6.5" x2="17.51" y2="6.5"/>
|
|
752
|
+
</svg>
|
|
753
|
+
</div>
|
|
754
|
+
<div>
|
|
755
|
+
<h3 class="empty-title">No Profiles Registered</h3>
|
|
756
|
+
<p class="empty-desc">You do not have any folder sync profiles registered on this device yet. Click the "Add Profile" button to get started.</p>
|
|
757
|
+
</div>
|
|
758
|
+
<button class="btn btn-primary" onclick="openAddModal()">
|
|
759
|
+
<svg style="width: 14px; height: 14px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
760
|
+
<line x1="12" y1="5" x2="12" y2="19"></line>
|
|
761
|
+
<line x1="5" y1="12" x2="19" y2="12"></line>
|
|
762
|
+
</svg>
|
|
763
|
+
Add First Profile
|
|
764
|
+
</button>
|
|
765
|
+
</div>
|
|
766
|
+
|
|
767
|
+
<div id="profilesGrid" class="grid-layout" style="display: none;">
|
|
768
|
+
<!-- Dynamically populated -->
|
|
769
|
+
</div>
|
|
770
|
+
</div>
|
|
771
|
+
|
|
772
|
+
<!-- Autostart toggle block -->
|
|
773
|
+
<div class="settings-card">
|
|
774
|
+
<div class="settings-info">
|
|
775
|
+
<div class="settings-title">Launch at Login (Autostart)</div>
|
|
776
|
+
<div class="settings-desc" id="autostartStatusDesc">Checking system configuration...</div>
|
|
777
|
+
</div>
|
|
778
|
+
<div>
|
|
779
|
+
<button class="btn btn-secondary" id="autostartToggleBtn" onclick="toggleAutostart()" disabled>
|
|
780
|
+
Enable Autostart
|
|
781
|
+
</button>
|
|
782
|
+
</div>
|
|
783
|
+
</div>
|
|
784
|
+
|
|
785
|
+
<!-- Logs -->
|
|
786
|
+
<div class="terminal-section">
|
|
787
|
+
<div class="console-header">
|
|
788
|
+
<div class="console-title">
|
|
789
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
790
|
+
<polyline points="4 17 10 11 4 5"></polyline>
|
|
791
|
+
<line x1="12" y1="19" x2="20" y2="19"></line>
|
|
792
|
+
</svg>
|
|
793
|
+
<span>Sync Console Logs</span>
|
|
794
|
+
</div>
|
|
795
|
+
<button class="btn btn-secondary btn-icon" onclick="clearLogs()" title="Clear Log Feed" style="padding: 6px 12px; font-size: 0.7rem;">
|
|
796
|
+
Clear Feed
|
|
797
|
+
</button>
|
|
798
|
+
</div>
|
|
799
|
+
<div class="console-window" id="consoleLogs">
|
|
800
|
+
<div class="console-empty">
|
|
801
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
802
|
+
<circle cx="12" cy="12" r="10"></circle>
|
|
803
|
+
<line x1="12" y1="8" x2="12" y2="12"></line>
|
|
804
|
+
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
|
805
|
+
</svg>
|
|
806
|
+
<span>Console idle. Start a sync scheduler to view live output streams.</span>
|
|
807
|
+
</div>
|
|
808
|
+
</div>
|
|
809
|
+
</div>
|
|
810
|
+
</div>
|
|
811
|
+
</div>
|
|
812
|
+
|
|
813
|
+
<!-- Add/Edit Modal -->
|
|
814
|
+
<div class="modal-backdrop" id="jobModalBackdrop" onclick="closeModalOnBackdrop(event)">
|
|
815
|
+
<div class="modal">
|
|
816
|
+
<div class="modal-header">
|
|
817
|
+
<h3 class="modal-title" id="modalTitle">Register New Sync Profile</h3>
|
|
818
|
+
<button class="modal-close" onclick="closeModal()">
|
|
819
|
+
<svg style="width: 18px; height: 18px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
820
|
+
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
821
|
+
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
822
|
+
</svg>
|
|
823
|
+
</button>
|
|
824
|
+
</div>
|
|
825
|
+
<form id="jobForm" onsubmit="saveJob(event)">
|
|
826
|
+
<input type="hidden" id="editJobOriginalName">
|
|
827
|
+
|
|
828
|
+
<div class="form-group">
|
|
829
|
+
<label class="form-label" for="jobLocalPath">Local Folder Path *</label>
|
|
830
|
+
<input type="text" class="form-input" id="jobLocalPath" placeholder="/absolute/path/to/folder" required>
|
|
831
|
+
</div>
|
|
832
|
+
|
|
833
|
+
<div class="form-group">
|
|
834
|
+
<label class="form-label" for="jobName">Sync Profile ID / Name *</label>
|
|
835
|
+
<input type="text" class="form-input" id="jobName" placeholder="e.g. project-sync" required>
|
|
836
|
+
</div>
|
|
837
|
+
|
|
838
|
+
<div class="form-group">
|
|
839
|
+
<label class="form-label" for="jobRemoteTarget">Remote Destination Target *</label>
|
|
840
|
+
<input type="text" class="form-input" id="jobRemoteTarget" placeholder="bucket-slug:remote-path" required>
|
|
841
|
+
</div>
|
|
842
|
+
|
|
843
|
+
<div class="form-row">
|
|
844
|
+
<div class="form-group">
|
|
845
|
+
<label class="form-label" for="jobDirection">Sync Direction</label>
|
|
846
|
+
<select class="form-input" id="jobDirection">
|
|
847
|
+
<option value="bidirectional">Bidirectional (Sync)</option>
|
|
848
|
+
<option value="push">Push (Local to Server)</option>
|
|
849
|
+
<option value="pull">Pull (Server to Local)</option>
|
|
850
|
+
</select>
|
|
851
|
+
</div>
|
|
852
|
+
<div class="form-group">
|
|
853
|
+
<label class="form-label" for="jobStrategy">Conflict Strategy</label>
|
|
854
|
+
<select class="form-input" id="jobStrategy">
|
|
855
|
+
<option value="newer-wins">Newer Wins</option>
|
|
856
|
+
<option value="local-wins">Local Wins</option>
|
|
857
|
+
<option value="server-wins">Server Wins</option>
|
|
858
|
+
</select>
|
|
859
|
+
</div>
|
|
860
|
+
</div>
|
|
861
|
+
|
|
862
|
+
<div class="form-row">
|
|
863
|
+
<div class="form-group">
|
|
864
|
+
<label class="form-label" for="jobInterval">Sync Interval (Seconds)</label>
|
|
865
|
+
<input type="number" class="form-input" id="jobInterval" min="2" value="10" required>
|
|
866
|
+
</div>
|
|
867
|
+
</div>
|
|
868
|
+
|
|
869
|
+
<div class="checkbox-group" onclick="toggleCheckboxValue(event)">
|
|
870
|
+
<input type="checkbox" id="jobConflictCopy" checked>
|
|
871
|
+
<label class="checkbox-label" for="jobConflictCopy">Create copy of conflicted files</label>
|
|
872
|
+
</div>
|
|
873
|
+
|
|
874
|
+
<div class="modal-footer">
|
|
875
|
+
<button type="button" class="btn btn-secondary" onclick="closeModal()">Cancel</button>
|
|
876
|
+
<button type="submit" class="btn btn-primary" id="saveBtn">Register Profile</button>
|
|
877
|
+
</div>
|
|
878
|
+
</form>
|
|
879
|
+
</div>
|
|
880
|
+
</div>
|
|
881
|
+
|
|
882
|
+
<script>
|
|
883
|
+
let syncJobs = [];
|
|
884
|
+
let runningStates = {};
|
|
885
|
+
let autostartConfigured = false;
|
|
886
|
+
let pollIntervalId = null;
|
|
887
|
+
let logPollIntervalId = null;
|
|
888
|
+
|
|
889
|
+
// Load everything when the DOM content loads
|
|
890
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
891
|
+
loadDashboardData();
|
|
892
|
+
|
|
893
|
+
// Setup periodic polling
|
|
894
|
+
pollIntervalId = setInterval(pollRunningStates, 3000);
|
|
895
|
+
logPollIntervalId = setInterval(pollLogs, 3000);
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
async function loadDashboardData() {
|
|
899
|
+
showLoading(true);
|
|
900
|
+
try {
|
|
901
|
+
// 1. Fetch CLI configuration
|
|
902
|
+
const configRes = await fetch('/api/local/config');
|
|
903
|
+
if (!configRes.ok) throw new Error('Failed to load local CLI configuration.');
|
|
904
|
+
const config = await configRes.json();
|
|
905
|
+
|
|
906
|
+
document.getElementById('serverUrlText').textContent = config.serverUrl || 'Not Configured';
|
|
907
|
+
syncJobs = config.syncJobs || [];
|
|
908
|
+
|
|
909
|
+
// 2. Fetch Autostart status
|
|
910
|
+
await checkAutostartStatus();
|
|
911
|
+
|
|
912
|
+
// 3. Fetch running statuses
|
|
913
|
+
await pollRunningStates();
|
|
914
|
+
|
|
915
|
+
// 4. Load logs
|
|
916
|
+
await pollLogs();
|
|
917
|
+
|
|
918
|
+
renderProfiles();
|
|
919
|
+
} catch (err) {
|
|
920
|
+
console.error(err);
|
|
921
|
+
alert('Error communicating with local hsync server. Make sure "hsync ui" is running.');
|
|
922
|
+
} finally {
|
|
923
|
+
showLoading(false);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function showLoading(isLoading) {
|
|
928
|
+
document.getElementById('loadingState').style.display = isLoading ? 'flex' : 'none';
|
|
929
|
+
if (isLoading) {
|
|
930
|
+
document.getElementById('profilesGrid').style.display = 'none';
|
|
931
|
+
document.getElementById('emptyState').style.display = 'none';
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
async function checkAutostartStatus() {
|
|
936
|
+
try {
|
|
937
|
+
const res = await fetch('/api/local/autostart/status');
|
|
938
|
+
const data = await res.json();
|
|
939
|
+
autostartConfigured = !!data.configured;
|
|
940
|
+
|
|
941
|
+
const btn = document.getElementById('autostartToggleBtn');
|
|
942
|
+
const desc = document.getElementById('autostartStatusDesc');
|
|
943
|
+
|
|
944
|
+
btn.disabled = false;
|
|
945
|
+
if (autostartConfigured) {
|
|
946
|
+
btn.textContent = 'Disable Autostart';
|
|
947
|
+
btn.className = 'btn btn-danger';
|
|
948
|
+
desc.textContent = 'hsync is configured to start automatically on system startup.';
|
|
949
|
+
} else {
|
|
950
|
+
btn.textContent = 'Enable Autostart';
|
|
951
|
+
btn.className = 'btn btn-secondary';
|
|
952
|
+
desc.textContent = 'hsync will not launch on boot.';
|
|
953
|
+
}
|
|
954
|
+
} catch (err) {
|
|
955
|
+
console.warn('Autostart check failed', err);
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
async function toggleAutostart() {
|
|
960
|
+
try {
|
|
961
|
+
const btn = document.getElementById('autostartToggleBtn');
|
|
962
|
+
btn.disabled = true;
|
|
963
|
+
|
|
964
|
+
if (autostartConfigured) {
|
|
965
|
+
// Note: In the existing CLI logic, autostart is disabled by removing files,
|
|
966
|
+
// but we only have a POST /api/local/autostart/enable endpoint. Let's see if it supports disabling?
|
|
967
|
+
// If the endpoint doesn't support disable, let's notify they can only configure it, or talk to CLI.
|
|
968
|
+
// Wait, the API endpoint is /api/local/autostart/enable. Let's send enable.
|
|
969
|
+
const res = await fetch('/api/local/autostart/enable', { method: 'POST' });
|
|
970
|
+
if (!res.ok) throw new Error('Failed to toggle autostart.');
|
|
971
|
+
} else {
|
|
972
|
+
const res = await fetch('/api/local/autostart/enable', { method: 'POST' });
|
|
973
|
+
if (!res.ok) throw new Error('Failed to enable autostart.');
|
|
974
|
+
}
|
|
975
|
+
await checkAutostartStatus();
|
|
976
|
+
} catch (err) {
|
|
977
|
+
alert(err.message);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
async function pollRunningStates() {
|
|
982
|
+
if (syncJobs.length === 0) return;
|
|
983
|
+
|
|
984
|
+
const states = {};
|
|
985
|
+
for (const job of syncJobs) {
|
|
986
|
+
try {
|
|
987
|
+
const res = await fetch(`/api/local/jobs/${encodeURIComponent(job.name)}/status`);
|
|
988
|
+
const data = await res.json();
|
|
989
|
+
states[job.id] = !!data.running;
|
|
990
|
+
} catch {
|
|
991
|
+
states[job.id] = false;
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
runningStates = states;
|
|
995
|
+
updateUIStates();
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
async function pollLogs() {
|
|
999
|
+
try {
|
|
1000
|
+
const res = await fetch('/api/local/logs');
|
|
1001
|
+
const data = await res.json();
|
|
1002
|
+
const logs = data.logs || [];
|
|
1003
|
+
|
|
1004
|
+
const consoleEl = document.getElementById('consoleLogs');
|
|
1005
|
+
if (logs.length === 0 || (logs.length === 1 && logs[0].includes('No active logs'))) {
|
|
1006
|
+
// Keep empty state
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
consoleEl.innerHTML = '';
|
|
1011
|
+
logs.forEach(line => {
|
|
1012
|
+
const div = document.createElement('div');
|
|
1013
|
+
div.className = 'console-line';
|
|
1014
|
+
|
|
1015
|
+
// Detect errors/success/system prefixes in logs for syntax highlighting
|
|
1016
|
+
if (line.includes('✘') || line.toLowerCase().includes('failed') || line.toLowerCase().includes('error')) {
|
|
1017
|
+
div.className += ' error';
|
|
1018
|
+
} else if (line.includes('✔') || line.includes('Successfully') || line.includes('Synced')) {
|
|
1019
|
+
div.className += ' success';
|
|
1020
|
+
} else if (line.includes('⚙️') || line.includes('⚡') || line.includes('🛑')) {
|
|
1021
|
+
div.className += ' system';
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// Parse timestamps [HH:MM:SS] if present
|
|
1025
|
+
let time = '';
|
|
1026
|
+
let text = line;
|
|
1027
|
+
const match = line.match(/^(\[\d{1,2}:\d{2}:\d{2}\])\s*(.*)/);
|
|
1028
|
+
if (match) {
|
|
1029
|
+
time = match[1];
|
|
1030
|
+
text = match[2];
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
const timeSpan = document.createElement('span');
|
|
1034
|
+
timeSpan.className = 'console-timestamp';
|
|
1035
|
+
timeSpan.textContent = time || '[LOG]';
|
|
1036
|
+
|
|
1037
|
+
const textSpan = document.createElement('span');
|
|
1038
|
+
textSpan.className = 'console-message';
|
|
1039
|
+
textSpan.textContent = text;
|
|
1040
|
+
|
|
1041
|
+
div.appendChild(timeSpan);
|
|
1042
|
+
div.appendChild(textSpan);
|
|
1043
|
+
consoleEl.appendChild(div);
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
// Autoscroll to bottom
|
|
1047
|
+
consoleEl.scrollTop = consoleEl.scrollHeight;
|
|
1048
|
+
} catch (err) {
|
|
1049
|
+
console.warn('Logs poll failed', err);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function clearLogs() {
|
|
1054
|
+
// In web dashboard, we can just clear the local DOM view (polling will refill on next run if still on disk)
|
|
1055
|
+
const consoleEl = document.getElementById('consoleLogs');
|
|
1056
|
+
consoleEl.innerHTML = `
|
|
1057
|
+
<div class="console-empty">
|
|
1058
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
1059
|
+
<circle cx="12" cy="12" r="10"></circle>
|
|
1060
|
+
<line x1="12" y1="8" x2="12" y2="12"></line>
|
|
1061
|
+
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
|
1062
|
+
</svg>
|
|
1063
|
+
<span>Console feed cleared. Waiting for new log lines...</span>
|
|
1064
|
+
</div>`;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function renderProfiles() {
|
|
1068
|
+
const grid = document.getElementById('profilesGrid');
|
|
1069
|
+
const empty = document.getElementById('emptyState');
|
|
1070
|
+
|
|
1071
|
+
if (syncJobs.length === 0) {
|
|
1072
|
+
grid.style.display = 'none';
|
|
1073
|
+
empty.style.display = 'flex';
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
grid.style.display = 'grid';
|
|
1078
|
+
empty.style.display = 'none';
|
|
1079
|
+
grid.innerHTML = '';
|
|
1080
|
+
|
|
1081
|
+
syncJobs.forEach(job => {
|
|
1082
|
+
const isRunning = !!runningStates[job.id];
|
|
1083
|
+
|
|
1084
|
+
const card = document.createElement('div');
|
|
1085
|
+
card.className = 'card';
|
|
1086
|
+
card.id = `card-${job.id}`;
|
|
1087
|
+
|
|
1088
|
+
// Setup card HTML structure
|
|
1089
|
+
card.innerHTML = `
|
|
1090
|
+
<div>
|
|
1091
|
+
<div class="card-header">
|
|
1092
|
+
<span class="profile-title">${escapeHtml(job.name)}</span>
|
|
1093
|
+
<span class="status-badge ${isRunning ? 'status-active' : 'status-inactive'}" id="badge-${job.id}">
|
|
1094
|
+
<span class="pulse-dot"></span>
|
|
1095
|
+
<span class="badge-text">${isRunning ? 'Active' : 'Stopped'}</span>
|
|
1096
|
+
</span>
|
|
1097
|
+
</div>
|
|
1098
|
+
|
|
1099
|
+
<div class="card-body">
|
|
1100
|
+
<div class="path-group">
|
|
1101
|
+
<span class="path-label">Local Directory</span>
|
|
1102
|
+
<span class="path-value">${escapeHtml(job.localPath)}</span>
|
|
1103
|
+
</div>
|
|
1104
|
+
<div class="path-group">
|
|
1105
|
+
<span class="path-label">Remote Destination</span>
|
|
1106
|
+
<span class="path-value">${escapeHtml(job.remoteBucket)}:${escapeHtml(job.remotePath || '')}</span>
|
|
1107
|
+
</div>
|
|
1108
|
+
|
|
1109
|
+
<div class="meta-row">
|
|
1110
|
+
<div class="meta-item">
|
|
1111
|
+
<span class="path-label">Direction</span>
|
|
1112
|
+
<span class="meta-val">${capitalize(job.direction)}</span>
|
|
1113
|
+
</div>
|
|
1114
|
+
<div class="meta-item">
|
|
1115
|
+
<span class="path-label">Interval</span>
|
|
1116
|
+
<span class="meta-val">${job.intervalMs ? job.intervalMs / 1000 : 10}s</span>
|
|
1117
|
+
</div>
|
|
1118
|
+
<div class="meta-item">
|
|
1119
|
+
<span class="path-label">Conflict Copy</span>
|
|
1120
|
+
<span class="meta-val">${job.conflictCopy ? 'Yes' : 'No'}</span>
|
|
1121
|
+
</div>
|
|
1122
|
+
</div>
|
|
1123
|
+
</div>
|
|
1124
|
+
</div>
|
|
1125
|
+
|
|
1126
|
+
<div class="card-actions">
|
|
1127
|
+
<div class="card-actions-left">
|
|
1128
|
+
<button class="btn btn-secondary btn-icon" onclick="openEditModal('${job.id}')" title="Edit Profile">
|
|
1129
|
+
<!-- Edit icon -->
|
|
1130
|
+
<svg style="width: 14px; height: 14px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
1131
|
+
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
|
|
1132
|
+
<path d="M18.5 2.5a2.121 2.121 0 1 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
|
1133
|
+
</svg>
|
|
1134
|
+
</button>
|
|
1135
|
+
<button class="btn btn-danger btn-icon" onclick="deleteProfile('${job.id}', '${escapeJs(job.name)}')" title="Delete Profile">
|
|
1136
|
+
<!-- Trash icon -->
|
|
1137
|
+
<svg style="width: 14px; height: 14px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
1138
|
+
<polyline points="3 6 5 6 21 6"></polyline>
|
|
1139
|
+
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
|
1140
|
+
</svg>
|
|
1141
|
+
</button>
|
|
1142
|
+
</div>
|
|
1143
|
+
|
|
1144
|
+
<button class="btn ${isRunning ? 'btn-danger' : 'btn-primary'}" id="toggle-btn-${job.id}" onclick="toggleDaemon('${job.id}', '${escapeJs(job.name)}')">
|
|
1145
|
+
${isRunning ? `
|
|
1146
|
+
<svg style="width: 12px; height: 12px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
1147
|
+
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"/>
|
|
1148
|
+
</svg> Stop Daemon
|
|
1149
|
+
` : `
|
|
1150
|
+
<svg style="width: 12px; height: 12px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
1151
|
+
<polygon points="5 3 19 12 5 21 5 3"/>
|
|
1152
|
+
</svg> Start Daemon
|
|
1153
|
+
`}
|
|
1154
|
+
</button>
|
|
1155
|
+
</div>
|
|
1156
|
+
`;
|
|
1157
|
+
grid.appendChild(card);
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function updateUIStates() {
|
|
1162
|
+
syncJobs.forEach(job => {
|
|
1163
|
+
const isRunning = !!runningStates[job.id];
|
|
1164
|
+
|
|
1165
|
+
const badge = document.getElementById(`badge-${job.id}`);
|
|
1166
|
+
const toggleBtn = document.getElementById(`toggle-btn-${job.id}`);
|
|
1167
|
+
|
|
1168
|
+
if (badge) {
|
|
1169
|
+
badge.className = `status-badge ${isRunning ? 'status-active' : 'status-inactive'}`;
|
|
1170
|
+
badge.querySelector('.badge-text').textContent = isRunning ? 'Active' : 'Stopped';
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
if (toggleBtn) {
|
|
1174
|
+
toggleBtn.className = `btn ${isRunning ? 'btn-danger' : 'btn-primary'}`;
|
|
1175
|
+
toggleBtn.innerHTML = isRunning ? `
|
|
1176
|
+
<svg style="width: 12px; height: 12px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
1177
|
+
<rect x="4" y="4" width="16" height="16" rx="2" ry="2"/>
|
|
1178
|
+
</svg> Stop Daemon
|
|
1179
|
+
` : `
|
|
1180
|
+
<svg style="width: 12px; height: 12px;" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
1181
|
+
<polygon points="5 3 19 12 5 21 5 3"/>
|
|
1182
|
+
</svg> Start Daemon
|
|
1183
|
+
`;
|
|
1184
|
+
}
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
async function toggleDaemon(jobId, jobName) {
|
|
1189
|
+
const isRunning = !!runningStates[jobId];
|
|
1190
|
+
const endpoint = isRunning ? 'stop' : 'start';
|
|
1191
|
+
|
|
1192
|
+
try {
|
|
1193
|
+
const toggleBtn = document.getElementById(`toggle-btn-${jobId}`);
|
|
1194
|
+
if (toggleBtn) toggleBtn.disabled = true;
|
|
1195
|
+
|
|
1196
|
+
const res = await fetch(`/api/local/jobs/${encodeURIComponent(jobName)}/${endpoint}`, {
|
|
1197
|
+
method: 'POST'
|
|
1198
|
+
});
|
|
1199
|
+
if (!res.ok) {
|
|
1200
|
+
const data = await res.json();
|
|
1201
|
+
throw new Error(data.error || 'Failed to toggle daemon status.');
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
// Wait a small moment to let system process starting, then refresh
|
|
1205
|
+
setTimeout(async () => {
|
|
1206
|
+
await pollRunningStates();
|
|
1207
|
+
if (toggleBtn) toggleBtn.disabled = false;
|
|
1208
|
+
}, 1000);
|
|
1209
|
+
} catch (err) {
|
|
1210
|
+
alert(err.message);
|
|
1211
|
+
await pollRunningStates();
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
async function deleteProfile(jobId, jobName) {
|
|
1216
|
+
if (!confirm(`Are you sure you want to delete the sync profile "${jobName}"?`)) return;
|
|
1217
|
+
|
|
1218
|
+
try {
|
|
1219
|
+
// Stop daemon first if running
|
|
1220
|
+
if (runningStates[jobId]) {
|
|
1221
|
+
await fetch(`/api/local/jobs/${encodeURIComponent(jobName)}/stop`, { method: 'POST' });
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
// Filter out of config and save config back
|
|
1225
|
+
const configRes = await fetch('/api/local/config');
|
|
1226
|
+
const config = await configRes.json();
|
|
1227
|
+
config.syncJobs = (config.syncJobs || []).filter(j => j.id !== jobId);
|
|
1228
|
+
|
|
1229
|
+
const saveRes = await fetch('/api/local/config', {
|
|
1230
|
+
method: 'POST',
|
|
1231
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1232
|
+
body: JSON.stringify(config)
|
|
1233
|
+
});
|
|
1234
|
+
|
|
1235
|
+
if (!saveRes.ok) throw new Error('Failed to save updated config.');
|
|
1236
|
+
|
|
1237
|
+
syncJobs = config.syncJobs;
|
|
1238
|
+
renderProfiles();
|
|
1239
|
+
} catch (err) {
|
|
1240
|
+
alert(err.message);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Modal Operations
|
|
1245
|
+
function openAddModal() {
|
|
1246
|
+
document.getElementById('modalTitle').textContent = 'Register New Sync Profile';
|
|
1247
|
+
document.getElementById('editJobOriginalName').value = '';
|
|
1248
|
+
document.getElementById('jobName').disabled = false;
|
|
1249
|
+
document.getElementById('jobForm').reset();
|
|
1250
|
+
document.getElementById('saveBtn').textContent = 'Register Profile';
|
|
1251
|
+
document.getElementById('jobConflictCopy').checked = true;
|
|
1252
|
+
|
|
1253
|
+
document.getElementById('jobModalBackdrop').classList.add('open');
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function openEditModal(jobId) {
|
|
1257
|
+
const job = syncJobs.find(j => j.id === jobId);
|
|
1258
|
+
if (!job) return;
|
|
1259
|
+
|
|
1260
|
+
document.getElementById('modalTitle').textContent = 'Edit Sync Profile';
|
|
1261
|
+
document.getElementById('editJobOriginalName').value = job.name;
|
|
1262
|
+
document.getElementById('jobName').value = job.name;
|
|
1263
|
+
document.getElementById('jobName').disabled = true; // Cannot edit name/ID as it's the primary key
|
|
1264
|
+
document.getElementById('jobLocalPath').value = job.localPath;
|
|
1265
|
+
document.getElementById('jobRemoteTarget').value = `${job.remoteBucket}:${job.remotePath || ''}`;
|
|
1266
|
+
document.getElementById('jobDirection').value = job.direction;
|
|
1267
|
+
document.getElementById('jobStrategy').value = job.conflictStrategy || 'newer-wins';
|
|
1268
|
+
document.getElementById('jobInterval').value = job.intervalMs ? job.intervalMs / 1000 : 10;
|
|
1269
|
+
document.getElementById('jobConflictCopy').checked = !!job.conflictCopy;
|
|
1270
|
+
document.getElementById('saveBtn').textContent = 'Save Changes';
|
|
1271
|
+
|
|
1272
|
+
document.getElementById('jobModalBackdrop').classList.add('open');
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
function closeModal() {
|
|
1276
|
+
document.getElementById('jobModalBackdrop').classList.remove('open');
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
function closeModalOnBackdrop(e) {
|
|
1280
|
+
if (e.target.id === 'jobModalBackdrop') {
|
|
1281
|
+
closeModal();
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
function toggleCheckboxValue(e) {
|
|
1286
|
+
// Allow clicking label/row to toggle the checkbox
|
|
1287
|
+
if (e.target.id !== 'jobConflictCopy') {
|
|
1288
|
+
const checkbox = document.getElementById('jobConflictCopy');
|
|
1289
|
+
checkbox.checked = !checkbox.checked;
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
async function saveJob(e) {
|
|
1294
|
+
e.preventDefault();
|
|
1295
|
+
|
|
1296
|
+
const originalName = document.getElementById('editJobOriginalName').value;
|
|
1297
|
+
const localPath = document.getElementById('jobLocalPath').value.trim();
|
|
1298
|
+
const rawName = document.getElementById('jobName').value.trim();
|
|
1299
|
+
const remoteTarget = document.getElementById('jobRemoteTarget').value.trim();
|
|
1300
|
+
const direction = document.getElementById('jobDirection').value;
|
|
1301
|
+
const strategy = document.getElementById('jobStrategy').value;
|
|
1302
|
+
const intervalSec = parseInt(document.getElementById('jobInterval').value, 10);
|
|
1303
|
+
const conflictCopy = document.getElementById('jobConflictCopy').checked;
|
|
1304
|
+
|
|
1305
|
+
// Sanitize name/ID to matching requirements: lowercased alphanumeric + hyphen + underscores
|
|
1306
|
+
const name = rawName.toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
|
1307
|
+
|
|
1308
|
+
if (!name || !localPath || !remoteTarget) {
|
|
1309
|
+
alert('Please fill out all mandatory fields.');
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
try {
|
|
1314
|
+
if (originalName) {
|
|
1315
|
+
// Edit operation
|
|
1316
|
+
const res = await fetch(`/api/local/jobs/${encodeURIComponent(originalName)}/edit`, {
|
|
1317
|
+
method: 'POST',
|
|
1318
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1319
|
+
body: JSON.stringify({
|
|
1320
|
+
localPath,
|
|
1321
|
+
remoteTarget,
|
|
1322
|
+
direction,
|
|
1323
|
+
strategy,
|
|
1324
|
+
intervalSec,
|
|
1325
|
+
noConflictCopy: !conflictCopy
|
|
1326
|
+
})
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
if (!res.ok) {
|
|
1330
|
+
const data = await res.json();
|
|
1331
|
+
throw new Error(data.error || 'Failed to edit sync job.');
|
|
1332
|
+
}
|
|
1333
|
+
} else {
|
|
1334
|
+
// Add/Create operation
|
|
1335
|
+
// Split remoteTarget into bucket slug and optional path
|
|
1336
|
+
const parts = remoteTarget.split(':');
|
|
1337
|
+
const slug = parts[0];
|
|
1338
|
+
const subpath = (parts.slice(1).join(':') || '').replace(/^\/+|\/+$/g, '');
|
|
1339
|
+
|
|
1340
|
+
// Check for duplicate name
|
|
1341
|
+
if (syncJobs.some(j => j.name === name)) {
|
|
1342
|
+
throw new Error(`A sync profile named "${name}" already exists.`);
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
const newJob = {
|
|
1346
|
+
id: `job-${Date.now()}-${Math.random().toString(36).substring(2, 6)}`,
|
|
1347
|
+
name,
|
|
1348
|
+
localPath,
|
|
1349
|
+
remoteBucket: slug,
|
|
1350
|
+
remotePath: subpath,
|
|
1351
|
+
direction,
|
|
1352
|
+
conflictStrategy: strategy,
|
|
1353
|
+
conflictCopy,
|
|
1354
|
+
intervalMs: intervalSec * 1000,
|
|
1355
|
+
enabled: true
|
|
1356
|
+
};
|
|
1357
|
+
|
|
1358
|
+
const configRes = await fetch('/api/local/config');
|
|
1359
|
+
const config = await configRes.json();
|
|
1360
|
+
if (!config.syncJobs) config.syncJobs = [];
|
|
1361
|
+
config.syncJobs.push(newJob);
|
|
1362
|
+
|
|
1363
|
+
const saveRes = await fetch('/api/local/config', {
|
|
1364
|
+
method: 'POST',
|
|
1365
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1366
|
+
body: JSON.stringify(config)
|
|
1367
|
+
});
|
|
1368
|
+
|
|
1369
|
+
if (!saveRes.ok) throw new Error('Failed to save configuration.');
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
closeModal();
|
|
1373
|
+
await loadDashboardData();
|
|
1374
|
+
} catch (err) {
|
|
1375
|
+
alert(err.message);
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// Helper formatting
|
|
1380
|
+
function escapeHtml(str) {
|
|
1381
|
+
if (!str) return '';
|
|
1382
|
+
return str.replace(/&/g, '&')
|
|
1383
|
+
.replace(/</g, '<')
|
|
1384
|
+
.replace(/>/g, '>')
|
|
1385
|
+
.replace(/"/g, '"')
|
|
1386
|
+
.replace(/'/g, ''');
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
function escapeJs(str) {
|
|
1390
|
+
if (!str) return '';
|
|
1391
|
+
return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/"/g, '\\"');
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function capitalize(str) {
|
|
1395
|
+
if (!str) return '';
|
|
1396
|
+
return str.charAt(0).toUpperCase() + str.slice(1);
|
|
1397
|
+
}
|
|
1398
|
+
</script>
|
|
1399
|
+
</body>
|
|
1400
|
+
</html>
|