pestdetectionai commited on
Commit
5115bc7
·
verified ·
1 Parent(s): a7d0f38

Sync from GitHub via hub-sync

Browse files
Files changed (4) hide show
  1. web/css/app.css +505 -3
  2. web/js/api.js +85 -39
  3. web/js/dashboard.js +137 -75
  4. web/js/logs.js +103 -8
web/css/app.css CHANGED
@@ -1,4 +1,506 @@
1
- :root{
2
- --bg:#f8f9fa;--surface:#ffffff;--line:#dfe4e1;--muted:#4e616a;--muted2:#6b7680;--primary:#012d1d;--primary2:#06452e;--soft:#ecf8f1;--soft2:#f3f6f5;--danger:#ba1a1a;--orange:#f59e0b;--blue:#3b82f6;--purple:#a855f7;
 
 
 
 
 
 
 
 
 
 
 
 
3
  }
4
- *{box-sizing:border-box}body{margin:0;background:var(--bg);color:#17201c;font-family:Inter,system-ui,-apple-system,Segoe UI,Roboto,Arial,sans-serif}.mono,.label{font-family:"Space Grotesk",Inter,sans-serif}.material-symbols-outlined{font-variation-settings:'FILL' 0,'wght' 420,'GRAD' 0,'opsz' 24}.topbar{height:64px;background:#fff;border-bottom:1px solid #e7ece9;display:flex;align-items:center;justify-content:space-between;padding:0 28px;position:sticky;top:0;z-index:50}.brand{font-weight:800;color:var(--primary);font-size:20px;letter-spacing:-.02em}.nav{display:flex;gap:20px;align-items:center}.nav a{color:#64736c;text-decoration:none;font-weight:650;font-size:14px;padding:22px 0 18px;border-bottom:2px solid transparent}.nav a.active,.nav a:hover{color:var(--primary);border-color:var(--primary)}.avatar{width:34px;height:34px;border-radius:999px;border:2px solid #d9e2dd;background:linear-gradient(135deg,#d8efe1,#f0f5f2);display:grid;place-items:center;color:var(--primary);font-weight:800}.shell{max-width:1440px;margin:0 auto;padding:32px}.grid{display:grid;gap:24px}.stats{grid-template-columns:repeat(3,minmax(0,1fr))}.card{background:var(--surface);border:1px solid var(--line);border-radius:12px;box-shadow:0 2px 6px rgba(1,45,29,.035)}.card.pad{padding:22px}.label{text-transform:uppercase;font-size:12px;letter-spacing:.08em;font-weight:800;color:var(--muted)}.stat{font-family:"Space Grotesk";font-size:44px;line-height:1;color:var(--primary);font-weight:700}.sub{color:var(--muted2);font-size:14px}.good{color:#057347}.muted{color:var(--muted2)}.main-grid{grid-template-columns:7fr 3fr}.live-frame{position:relative;overflow:hidden;background:#07110d;border-radius:12px}.live-frame img{display:block;width:100%;height:100%;aspect-ratio:16/9;object-fit:cover}.badge{display:inline-flex;align-items:center;gap:8px;background:rgba(0,0,0,.55);backdrop-filter:blur(8px);color:#fff;border:1px solid rgba(255,255,255,.15);border-radius:999px;padding:8px 12px;font-size:12px;font-weight:800;text-transform:uppercase;letter-spacing:.04em}.live-badge{position:absolute;top:16px;left:16px}.dot{width:8px;height:8px;border-radius:50%;background:#ef4444;display:inline-block}.pulse{animation:pulse 1.5s infinite}@keyframes pulse{50%{opacity:.35}}.side-list{padding:22px}.det-row{display:flex;align-items:center;justify-content:space-between;padding:14px 0}.det-left{display:flex;align-items:center;gap:12px}.det-dot{width:8px;height:8px;border-radius:50%;background:#10b981}.det-count{font-family:"Space Grotesk";font-size:24px;font-weight:700}.chart{height:260px;display:flex;align-items:end;gap:8px;padding:24px 18px 8px;background-image:radial-gradient(#dfe4e1 1px,transparent 1px);background-size:24px 24px}.bar{flex:1;background:#b8efd2;border-radius:4px 4px 0 0;min-height:4px;transition:.2s}.bar.hot{background:#059669}.section-head{display:flex;align-items:end;justify-content:space-between;margin-bottom:16px}.toolbar{display:flex;gap:10px;align-items:center}.input{border:1px solid #d8e0db;background:#fff;border-radius:10px;padding:12px 14px;font:inherit;outline:none}.input:focus{border-color:#0a6b45;box-shadow:0 0 0 3px rgba(5,115,71,.08)}.btn{border:1px solid #cfd8d3;border-radius:10px;background:#fff;color:#24352e;padding:10px 14px;font-weight:750;cursor:pointer;text-decoration:none;display:inline-flex;gap:8px;align-items:center}.btn:hover{background:#f2f7f4}.btn.primary{background:var(--primary);color:#fff;border-color:var(--primary)}.btn.danger{border-color:#f0b7b7;color:#a40000}.table{width:100%;border-collapse:collapse}.table th{text-align:left;background:#f1f4f3;color:#51625b;font-family:"Space Grotesk";font-size:12px;letter-spacing:.08em;text-transform:uppercase;padding:14px 18px}.table td{padding:16px 18px;border-top:1px solid #ecf0ee;vertical-align:middle}.thumb{width:64px;height:64px;border-radius:8px;object-fit:cover;background:#e8efeb;border:1px solid #dce5e0}.thumb.sm{width:52px;height:52px}.log-row{cursor:pointer}.log-row:hover{background:#f6fbf8}.confbar{width:90px;height:7px;background:#dfe8e3;border-radius:99px;overflow:hidden}.confbar span{display:block;height:100%;background:#057347}.pager{display:flex;justify-content:space-between;align-items:center;padding:16px 20px;background:#f3f6f5;border-top:1px solid #dfe4e1}.detail-grid{grid-template-columns:2fr 1fr}.hero-img{width:100%;aspect-ratio:16/10;object-fit:contain;background:#07110d;border-radius:12px}.metric-grid{grid-template-columns:repeat(2,1fr)}.metric{display:flex;gap:14px;align-items:center;padding:18px}.iconbox{width:48px;height:48px;border-radius:999px;background:#eaf8f0;display:grid;place-items:center;color:#057347}.break-row{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;border-top:1px solid #edf1ef}.dropzone{border:1px dashed #aab8b0;background:#fbfdfc;border-radius:14px;padding:18px;display:flex;flex-direction:column;gap:12px}.notice{background:#fff8e8;border:1px solid #f5d08a;color:#7a4d00;border-radius:10px;padding:12px 14px;font-size:13px}.loading{opacity:.6;pointer-events:none}.hide{display:none!important}.empty{padding:28px;text-align:center;color:#6d7a74}.mobile-only{display:none}@media(max-width:900px){.nav{display:none}.shell{padding:18px 14px 86px}.stats,.main-grid,.detail-grid,.metric-grid{grid-template-columns:1fr}.toolbar{flex-direction:column;align-items:stretch}.topbar{padding:0 16px}.table{min-width:760px}.mobile-only{display:block}.desktop-only{display:none}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --bg: #f8f9fa;
3
+ --surface: #ffffff;
4
+ --line: #dfe4e1;
5
+ --muted: #4e616a;
6
+ --muted2: #6b7680;
7
+ --primary: #012d1d;
8
+ --primary2: #06452e;
9
+ --soft: #ecf8f1;
10
+ --soft2: #f3f6f5;
11
+ --danger: #ba1a1a;
12
+ --orange: #f59e0b;
13
+ --blue: #3b82f6;
14
+ --purple: #a855f7;
15
  }
16
+
17
+ * {
18
+ box-sizing: border-box
19
+ }
20
+
21
+ body {
22
+ margin: 0;
23
+ background: var(--bg);
24
+ color: #17201c;
25
+ font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif
26
+ }
27
+
28
+ .mono,
29
+ .label {
30
+ font-family: "Space Grotesk", Inter, sans-serif
31
+ }
32
+
33
+ .material-symbols-outlined {
34
+ font-variation-settings: 'FILL' 0, 'wght' 420, 'GRAD' 0, 'opsz' 24
35
+ }
36
+
37
+ .topbar {
38
+ height: 64px;
39
+ background: #fff;
40
+ border-bottom: 1px solid #e7ece9;
41
+ display: flex;
42
+ align-items: center;
43
+ justify-content: space-between;
44
+ padding: 0 28px;
45
+ position: sticky;
46
+ top: 0;
47
+ z-index: 50
48
+ }
49
+
50
+ .brand {
51
+ font-weight: 800;
52
+ color: var(--primary);
53
+ font-size: 20px;
54
+ letter-spacing: -.02em
55
+ }
56
+
57
+ .nav {
58
+ display: flex;
59
+ gap: 20px;
60
+ align-items: center
61
+ }
62
+
63
+ .nav a {
64
+ color: #64736c;
65
+ text-decoration: none;
66
+ font-weight: 650;
67
+ font-size: 14px;
68
+ padding: 22px 0 18px;
69
+ border-bottom: 2px solid transparent
70
+ }
71
+
72
+ .nav a.active,
73
+ .nav a:hover {
74
+ color: var(--primary);
75
+ border-color: var(--primary)
76
+ }
77
+
78
+ .avatar {
79
+ width: 34px;
80
+ height: 34px;
81
+ border-radius: 999px;
82
+ border: 2px solid #d9e2dd;
83
+ background: linear-gradient(135deg, #d8efe1, #f0f5f2);
84
+ display: grid;
85
+ place-items: center;
86
+ color: var(--primary);
87
+ font-weight: 800
88
+ }
89
+
90
+ .shell {
91
+ max-width: 1440px;
92
+ margin: 0 auto;
93
+ padding: 32px
94
+ }
95
+
96
+ .grid {
97
+ display: grid;
98
+ gap: 24px
99
+ }
100
+
101
+ .stats {
102
+ grid-template-columns: repeat(3, minmax(0, 1fr))
103
+ }
104
+
105
+ .card {
106
+ background: var(--surface);
107
+ border: 1px solid var(--line);
108
+ border-radius: 12px;
109
+ box-shadow: 0 2px 6px rgba(1, 45, 29, .035)
110
+ }
111
+
112
+ .card.pad {
113
+ padding: 22px
114
+ }
115
+
116
+ .label {
117
+ text-transform: uppercase;
118
+ font-size: 12px;
119
+ letter-spacing: .08em;
120
+ font-weight: 800;
121
+ color: var(--muted)
122
+ }
123
+
124
+ .stat {
125
+ font-family: "Space Grotesk";
126
+ font-size: 44px;
127
+ line-height: 1;
128
+ color: var(--primary);
129
+ font-weight: 700
130
+ }
131
+
132
+ .sub {
133
+ color: var(--muted2);
134
+ font-size: 14px
135
+ }
136
+
137
+ .good {
138
+ color: #057347
139
+ }
140
+
141
+ .muted {
142
+ color: var(--muted2)
143
+ }
144
+
145
+ .main-grid {
146
+ grid-template-columns: 7fr 3fr
147
+ }
148
+
149
+ .live-frame {
150
+ position: relative;
151
+ overflow: hidden;
152
+ background: #07110d;
153
+ border-radius: 12px
154
+ }
155
+
156
+ .live-frame img {
157
+ display: block;
158
+ width: 100%;
159
+ height: 100%;
160
+ aspect-ratio: 16/9;
161
+ object-fit: cover
162
+ }
163
+
164
+ .badge {
165
+ display: inline-flex;
166
+ align-items: center;
167
+ gap: 8px;
168
+ background: rgba(0, 0, 0, .55);
169
+ backdrop-filter: blur(8px);
170
+ color: #fff;
171
+ border: 1px solid rgba(255, 255, 255, .15);
172
+ border-radius: 999px;
173
+ padding: 8px 12px;
174
+ font-size: 12px;
175
+ font-weight: 800;
176
+ text-transform: uppercase;
177
+ letter-spacing: .04em
178
+ }
179
+
180
+ .live-badge {
181
+ position: absolute;
182
+ top: 16px;
183
+ left: 16px
184
+ }
185
+
186
+ .dot {
187
+ width: 8px;
188
+ height: 8px;
189
+ border-radius: 50%;
190
+ background: #ef4444;
191
+ display: inline-block
192
+ }
193
+
194
+ .pulse {
195
+ animation: pulse 1.5s infinite
196
+ }
197
+
198
+ @keyframes pulse {
199
+ 50% {
200
+ opacity: .35
201
+ }
202
+ }
203
+
204
+ .side-list {
205
+ padding: 22px
206
+ }
207
+
208
+ .det-row {
209
+ display: flex;
210
+ align-items: center;
211
+ justify-content: space-between;
212
+ padding: 14px 0
213
+ }
214
+
215
+ .det-left {
216
+ display: flex;
217
+ align-items: center;
218
+ gap: 12px
219
+ }
220
+
221
+ .det-dot {
222
+ width: 8px;
223
+ height: 8px;
224
+ border-radius: 50%;
225
+ background: #10b981
226
+ }
227
+
228
+ .det-count {
229
+ font-family: "Space Grotesk";
230
+ font-size: 24px;
231
+ font-weight: 700
232
+ }
233
+
234
+ .chart {
235
+ height: 260px;
236
+ display: flex;
237
+ align-items: end;
238
+ gap: 8px;
239
+ padding: 24px 18px 8px;
240
+ background-image: radial-gradient(#dfe4e1 1px, transparent 1px);
241
+ background-size: 24px 24px
242
+ }
243
+
244
+ .bar {
245
+ flex: 1;
246
+ background: #b8efd2;
247
+ border-radius: 4px 4px 0 0;
248
+ min-height: 4px;
249
+ transition: .2s
250
+ }
251
+
252
+ .bar.hot {
253
+ background: #059669
254
+ }
255
+
256
+ .section-head {
257
+ display: flex;
258
+ align-items: end;
259
+ justify-content: space-between;
260
+ margin-bottom: 16px
261
+ }
262
+
263
+ .toolbar {
264
+ display: flex;
265
+ gap: 10px;
266
+ align-items: center
267
+ }
268
+
269
+ .input {
270
+ border: 1px solid #d8e0db;
271
+ background: #fff;
272
+ border-radius: 10px;
273
+ padding: 12px 14px;
274
+ font: inherit;
275
+ outline: none
276
+ }
277
+
278
+ .input:focus {
279
+ border-color: #0a6b45;
280
+ box-shadow: 0 0 0 3px rgba(5, 115, 71, .08)
281
+ }
282
+
283
+ .btn {
284
+ border: 1px solid #cfd8d3;
285
+ border-radius: 10px;
286
+ background: #fff;
287
+ color: #24352e;
288
+ padding: 10px 14px;
289
+ font-weight: 750;
290
+ cursor: pointer;
291
+ text-decoration: none;
292
+ display: inline-flex;
293
+ gap: 8px;
294
+ align-items: center
295
+ }
296
+
297
+ .btn:hover {
298
+ background: #f2f7f4
299
+ }
300
+
301
+ .btn.primary {
302
+ background: var(--primary);
303
+ color: #fff;
304
+ border-color: var(--primary)
305
+ }
306
+
307
+ .btn.danger {
308
+ border-color: #f0b7b7;
309
+ color: #a40000
310
+ }
311
+
312
+ .table {
313
+ width: 100%;
314
+ border-collapse: collapse
315
+ }
316
+
317
+ .table th {
318
+ text-align: left;
319
+ background: #f1f4f3;
320
+ color: #51625b;
321
+ font-family: "Space Grotesk";
322
+ font-size: 12px;
323
+ letter-spacing: .08em;
324
+ text-transform: uppercase;
325
+ padding: 14px 18px
326
+ }
327
+
328
+ .table td {
329
+ padding: 16px 18px;
330
+ border-top: 1px solid #ecf0ee;
331
+ vertical-align: middle
332
+ }
333
+
334
+ .thumb {
335
+ width: 64px;
336
+ height: 64px;
337
+ border-radius: 8px;
338
+ object-fit: cover;
339
+ background: #e8efeb;
340
+ border: 1px solid #dce5e0
341
+ }
342
+
343
+ .thumb.sm {
344
+ width: 52px;
345
+ height: 52px
346
+ }
347
+
348
+ .log-row {
349
+ cursor: pointer
350
+ }
351
+
352
+ .log-row:hover {
353
+ background: #f6fbf8
354
+ }
355
+
356
+ .confbar {
357
+ width: 90px;
358
+ height: 7px;
359
+ background: #dfe8e3;
360
+ border-radius: 99px;
361
+ overflow: hidden
362
+ }
363
+
364
+ .confbar span {
365
+ display: block;
366
+ height: 100%;
367
+ background: #057347
368
+ }
369
+
370
+ .pager {
371
+ display: flex;
372
+ justify-content: space-between;
373
+ align-items: center;
374
+ padding: 16px 20px;
375
+ background: #f3f6f5;
376
+ border-top: 1px solid #dfe4e1
377
+ }
378
+
379
+ .detail-grid {
380
+ grid-template-columns: 2fr 1fr
381
+ }
382
+
383
+ .hero-img {
384
+ width: 100%;
385
+ aspect-ratio: 16/10;
386
+ object-fit: contain;
387
+ background: #07110d;
388
+ border-radius: 12px
389
+ }
390
+
391
+ .metric-grid {
392
+ grid-template-columns: repeat(2, 1fr)
393
+ }
394
+
395
+ .metric {
396
+ display: flex;
397
+ gap: 14px;
398
+ align-items: center;
399
+ padding: 18px
400
+ }
401
+
402
+ .iconbox {
403
+ width: 48px;
404
+ height: 48px;
405
+ border-radius: 999px;
406
+ background: #eaf8f0;
407
+ display: grid;
408
+ place-items: center;
409
+ color: #057347
410
+ }
411
+
412
+ .break-row {
413
+ display: flex;
414
+ align-items: center;
415
+ justify-content: space-between;
416
+ padding: 14px 18px;
417
+ border-top: 1px solid #edf1ef
418
+ }
419
+
420
+ .dropzone {
421
+ border: 1px dashed #aab8b0;
422
+ background: #fbfdfc;
423
+ border-radius: 14px;
424
+ padding: 18px;
425
+ display: flex;
426
+ flex-direction: column;
427
+ gap: 12px
428
+ }
429
+
430
+ .notice {
431
+ background: #fff8e8;
432
+ border: 1px solid #f5d08a;
433
+ color: #7a4d00;
434
+ border-radius: 10px;
435
+ padding: 12px 14px;
436
+ font-size: 13px
437
+ }
438
+
439
+ .loading {
440
+ opacity: .6;
441
+ pointer-events: none
442
+ }
443
+
444
+ .hide {
445
+ display: none !important
446
+ }
447
+
448
+ .empty {
449
+ padding: 28px;
450
+ text-align: center;
451
+ color: #6d7a74
452
+ }
453
+
454
+ .mobile-only {
455
+ display: none
456
+ }
457
+
458
+ @media(max-width:900px) {
459
+ .nav {
460
+ display: none
461
+ }
462
+
463
+ .shell {
464
+ padding: 18px 14px 86px
465
+ }
466
+
467
+ .stats,
468
+ .main-grid,
469
+ .detail-grid,
470
+ .metric-grid {
471
+ grid-template-columns: 1fr
472
+ }
473
+
474
+ .toolbar {
475
+ flex-direction: column;
476
+ align-items: stretch
477
+ }
478
+
479
+ .topbar {
480
+ padding: 0 16px
481
+ }
482
+
483
+ .table {
484
+ min-width: 760px
485
+ }
486
+
487
+ .mobile-only {
488
+ display: block
489
+ }
490
+
491
+ .desktop-only {
492
+ display: none
493
+ }
494
+ }
495
+
496
+ .identification-cell {
497
+ max-width: 340px;
498
+ }
499
+
500
+ .type-name {
501
+ display: block;
502
+ max-width: 100%;
503
+ overflow: hidden;
504
+ text-overflow: ellipsis;
505
+ white-space: nowrap;
506
+ }
web/js/api.js CHANGED
@@ -13,57 +13,102 @@ const API = {
13
  }
14
  };
15
 
16
- function fmtNum(n){
17
- return Number(n || 0).toLocaleString();
18
  }
19
 
20
- function pad2(n){
21
- return String(n || 0).padStart(2,'0');
22
  }
23
 
24
- function fmtDate(ms, fallback=''){
25
  if(!ms) return fallback || '-';
 
26
  const d = new Date(Number(ms));
 
27
  return d.toLocaleString([], {
28
- year:'numeric',
29
- month:'short',
30
- day:'2-digit',
31
- hour:'2-digit',
32
- minute:'2-digit'
33
  });
34
  }
35
 
36
- function fmtTime(ms){
37
- if(!ms) return '-';
 
38
  return new Date(Number(ms)).toLocaleTimeString([], {
39
- hour:'2-digit',
40
- minute:'2-digit'
41
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  }
43
 
44
  function primaryType(item){
45
  const data = item?.data || [];
 
46
  if(!data.length) return 'No pest';
47
 
48
- return [...data].sort((a,b)=>(b.count||0)-(a.count||0))[0].type || 'unknown_pest';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  }
50
 
51
  function avgConfidence(item){
52
- // Preferred: API compact log field
53
- if (item && item.avg_confidence !== undefined && item.avg_confidence !== null) {
54
  return Number(item.avg_confidence || 0);
55
  }
56
 
57
- // Fallback: detail response has detections
58
  const dets = item?.detections || [];
59
 
60
  if(!dets.length) return 0;
61
 
62
- return dets.reduce((s,d)=>s + Number(d.confidence || 0), 0) / dets.length;
63
  }
64
 
65
  function topConfidence(item){
66
- if (item && item.top_confidence !== undefined && item.top_confidence !== null) {
67
  return Number(item.top_confidence || 0);
68
  }
69
 
@@ -74,12 +119,12 @@ function topConfidence(item){
74
  return Math.max(...dets.map(d => Number(d.confidence || 0)));
75
  }
76
 
77
- function confidenceText(v){
78
- return `${Math.round(Number(v || 0) * 100)}%`;
79
  }
80
 
81
  function imgFallback(e){
82
- e.target.src='data:image/svg+xml;charset=UTF-8,'+encodeURIComponent(`
83
  <svg xmlns="http://www.w3.org/2000/svg" width="600" height="400">
84
  <rect width="100%" height="100%" fill="#e9efec"/>
85
  <text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="#52655d" font-family="Arial" font-size="22">No image</text>
@@ -87,24 +132,25 @@ function imgFallback(e){
87
  `);
88
  }
89
 
90
- function setText(id, value){
91
- const el = document.getElementById(id);
92
- if(el) el.textContent = value;
93
  }
94
 
95
- function setImg(id, src){
96
- const el = document.getElementById(id);
97
- if(el){
98
- el.src = src || '';
99
- el.onerror = imgFallback;
100
- }
 
101
  }
102
 
103
- function escapeHtml(s){
104
  return String(s ?? '').replace(/[&<>"]/g, c => ({
105
- '&':'&amp;',
106
- '<':'&lt;',
107
- '>':'&gt;',
108
- '"':'&quot;'
109
- }[c]));
110
  }
 
13
  }
14
  };
15
 
16
+ function fmtNum(n){
17
+ return Number(n || 0).toLocaleString();
18
  }
19
 
20
+ function pad2(n){
21
+ return String(n || 0).padStart(2, '0');
22
  }
23
 
24
+ function fmtDate(ms, fallback = ''){
25
  if(!ms) return fallback || '-';
26
+
27
  const d = new Date(Number(ms));
28
+
29
  return d.toLocaleString([], {
30
+ year: 'numeric',
31
+ month: 'short',
32
+ day: '2-digit',
33
+ hour: '2-digit',
34
+ minute: '2-digit'
35
  });
36
  }
37
 
38
+ function fmtTime(ms){
39
+ if(!ms) return '-';
40
+
41
  return new Date(Number(ms)).toLocaleTimeString([], {
42
+ hour: '2-digit',
43
+ minute: '2-digit'
44
+ });
45
+ }
46
+
47
+ function isUnknownType(type){
48
+ return String(type || '').trim().toLowerCase() === 'unknown_pest';
49
+ }
50
+
51
+ function knownTypesFromData(item){
52
+ const data = item?.data || [];
53
+
54
+ return data
55
+ .filter(row => !isUnknownType(row.type))
56
+ .sort((a, b) => Number(b.count || 0) - Number(a.count || 0))
57
+ .map(row => row.type)
58
+ .filter(Boolean);
59
  }
60
 
61
  function primaryType(item){
62
  const data = item?.data || [];
63
+
64
  if(!data.length) return 'No pest';
65
 
66
+ const sorted = [...data].sort((a, b) => Number(b.count || 0) - Number(a.count || 0));
67
+ const top = sorted[0];
68
+
69
+ if(!top) return 'No pest';
70
+
71
+ const knownTypes = knownTypesFromData(item);
72
+
73
+ // Main rule:
74
+ // If unknown_pest is the highest count, show the known pest names instead.
75
+ if(isUnknownType(top.type) && knownTypes.length){
76
+ return knownTypes.join(', ');
77
+ }
78
+
79
+ return top.type || 'unknown_pest';
80
+ }
81
+
82
+ function primaryTypeTitle(item){
83
+ const data = item?.data || [];
84
+
85
+ if(!data.length) return 'No pest';
86
+
87
+ const knownTypes = knownTypesFromData(item);
88
+ const sorted = [...data].sort((a, b) => Number(b.count || 0) - Number(a.count || 0));
89
+ const top = sorted[0];
90
+
91
+ if(top && isUnknownType(top.type) && knownTypes.length){
92
+ return knownTypes.join(', ');
93
+ }
94
+
95
+ return top?.type || 'unknown_pest';
96
  }
97
 
98
  function avgConfidence(item){
99
+ if(item && item.avg_confidence !== undefined && item.avg_confidence !== null){
 
100
  return Number(item.avg_confidence || 0);
101
  }
102
 
 
103
  const dets = item?.detections || [];
104
 
105
  if(!dets.length) return 0;
106
 
107
+ return dets.reduce((s, d) => s + Number(d.confidence || 0), 0) / dets.length;
108
  }
109
 
110
  function topConfidence(item){
111
+ if(item && item.top_confidence !== undefined && item.top_confidence !== null){
112
  return Number(item.top_confidence || 0);
113
  }
114
 
 
119
  return Math.max(...dets.map(d => Number(d.confidence || 0)));
120
  }
121
 
122
+ function confidenceText(v){
123
+ return `${Math.round(Number(v || 0) * 100)}%`;
124
  }
125
 
126
  function imgFallback(e){
127
+ e.target.src = 'data:image/svg+xml;charset=UTF-8,' + encodeURIComponent(`
128
  <svg xmlns="http://www.w3.org/2000/svg" width="600" height="400">
129
  <rect width="100%" height="100%" fill="#e9efec"/>
130
  <text x="50%" y="50%" dominant-baseline="middle" text-anchor="middle" fill="#52655d" font-family="Arial" font-size="22">No image</text>
 
132
  `);
133
  }
134
 
135
+ function setText(id, value){
136
+ const el = document.getElementById(id);
137
+ if(el) el.textContent = value;
138
  }
139
 
140
+ function setImg(id, src){
141
+ const el = document.getElementById(id);
142
+
143
+ if(el){
144
+ el.src = src || '';
145
+ el.onerror = imgFallback;
146
+ }
147
  }
148
 
149
+ function escapeHtml(s){
150
  return String(s ?? '').replace(/[&<>"]/g, c => ({
151
+ '&': '&amp;',
152
+ '<': '&lt;',
153
+ '>': '&gt;',
154
+ '"': '&quot;'
155
+ }[c]));
156
  }
web/js/dashboard.js CHANGED
@@ -1,117 +1,179 @@
1
  let dashboardCache = null;
2
- async function loadDashboard() {
3
- const dash = await API.get("/api/dashboard");
 
 
4
  dashboardCache = dash;
 
5
  const s = dash.summary || {};
6
  const chart = dash.chart || {};
7
- setText("todayPests", fmtNum(s.today_pests));
8
- setText("biodiversity", fmtNum((s.top_pests || []).length));
 
 
9
  const avg = (s.last_7_days_pests || 0) / 7;
10
- setText("dailyAvg", Math.round(avg));
 
11
  const top = (s.top_pests || [])[0];
12
- setText(
13
- "topToday",
14
- top ? `${fmtNum(top.count)} ${top.type} total` : "No detections yet",
15
- );
16
- setText("monthInfo", `${fmtNum(s.last_7_days_pests)} pests in last 7 days`);
17
  renderLive(dash.live_camera_stream?.latest);
18
  renderChart(chart.hourly_today || []);
19
  renderRecent(dash.logs || []);
20
  }
21
- async function loadLatest() {
22
- try {
23
- const r = await API.get("/api/live/latest");
 
24
  renderLive(r.latest);
25
- } catch (e) {
26
  console.warn(e);
27
  }
28
  }
29
- function renderLive(item) {
30
- const list = document.getElementById("liveList");
31
- if (!item) {
32
- setImg("liveImage", "");
 
 
33
  list.innerHTML = '<div class="empty">No live detection yet</div>';
34
  return;
35
  }
36
- setImg("liveImage", item.annotated_image || item.original_image);
37
- document.getElementById("openLatest").href = `/ui/logs/${item.id}`;
 
 
 
38
  const data = item.data || [];
39
  const total = item.total || 0;
40
- setText("updateAge", fmtTime(item.timestamp_ms));
 
 
41
  list.innerHTML = data.length
42
- ? data
43
- .map(
44
- (d, i) =>
45
- `<div class="det-row"><div class="det-left"><span class="det-dot" style="background:${["#10b981", "#f59e0b", "#3b82f6", "#a855f7", "#ef4444"][i % 5]}"></span><span>${escapeHtml(d.type)}</span></div><span class="det-count">${pad2(d.count)}</span></div>`,
46
- )
47
- .join("")
 
 
 
 
 
 
48
  : '<div class="empty">No pest in latest frame</div>';
 
49
  const pct = Math.min(100, Math.round((total / 25) * 100));
50
- setText("loadPct", pct + "%");
51
- document.getElementById("loadBar").style.width = pct + "%";
 
52
  }
53
- function renderChart(rows) {
54
- const el = document.getElementById("hourlyChart");
55
- const max = Math.max(1, ...rows.map((r) => r.total || 0));
56
- el.innerHTML = rows
57
- .map(
58
- (r, i) =>
59
- `<div title="${r.hour}: ${r.total}" class="bar ${r.total === max ? "hot" : ""}" style="height:${Math.max(4, (r.total / max) * 92)}%"></div>`,
60
- )
61
- .join("");
 
 
 
62
  }
63
- function renderRecent(logs) {
64
- const root = document.getElementById("recentLogs");
65
- setText("logCount", `Showing ${logs.length} latest entries`);
66
- if (!logs.length) {
 
 
 
67
  root.innerHTML = '<div class="empty">No logs found</div>';
68
  return;
69
  }
70
- root.innerHTML = logs
71
- .map((item) => {
72
- const type = primaryType(item);
73
- const conf = avgConfidence(item);
74
- return `<div class="log-row" onclick="location.href='/ui/logs/${item.id}'" style="display:flex;align-items:center;padding:16px 20px;border-bottom:1px solid #f0f3f1"><img class="thumb" src="${item.annotated_image || item.original_image || ""}" onerror="imgFallback(event)"><div style="margin-left:16px;flex:1"><b style="color:var(--primary)">${escapeHtml(type)}</b><div class="sub">Avg confidence ${confidenceText(conf)} · Total ${item.total||0}</div></div><div class="desktop-only sub" style="min-width:180px">${fmtDate(item.timestamp_ms, item.datatime)}</div><span class="material-symbols-outlined muted">chevron_right</span></div>`;
75
- })
76
- .join("");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  }
78
- document.getElementById("searchBox").addEventListener("input", async (e) => {
 
79
  const q = e.target.value.trim();
80
- if (!q) {
 
81
  renderRecent(dashboardCache?.logs || []);
82
  return;
83
  }
84
- const r = await API.get(
85
- `/api/logs?page=1&page_size=10&search=${encodeURIComponent(q)}`,
86
- );
87
  renderRecent(r.items || []);
88
  });
89
- document.getElementById("uploadBtn").addEventListener("click", async () => {
90
- const file = document.getElementById("uploadInput").files[0];
91
- if (!file) {
92
- setText("uploadStatus", "Choose image first.");
 
 
93
  return;
94
  }
95
- document.body.classList.add("loading");
96
- setText("uploadStatus", "Analyzing image...");
97
- try {
98
- const r = await API.postImage("/api/analyze", file);
99
- setText(
100
- "uploadStatus",
101
- `Saved. Total detected: ${r.total}. Opening detail...`,
102
- );
103
- setTimeout(() => (location.href = `/ui/logs/${r.id}`), 700);
104
- } catch (e) {
105
- setText("uploadStatus", "Failed: " + e.message);
106
- } finally {
107
- document.body.classList.remove("loading");
108
  }
109
  });
110
- loadDashboard().catch((e) => {
 
111
  console.error(e);
112
  document.body.insertAdjacentHTML(
113
- "afterbegin",
114
- `<div class="notice">Dashboard error: ${escapeHtml(e.message)}</div>`,
115
  );
116
  });
117
- setInterval(loadLatest, 5000);
 
 
1
  let dashboardCache = null;
2
+
3
+ async function loadDashboard(){
4
+ const dash = await API.get('/api/dashboard');
5
+
6
  dashboardCache = dash;
7
+
8
  const s = dash.summary || {};
9
  const chart = dash.chart || {};
10
+
11
+ setText('todayPests', fmtNum(s.today_pests));
12
+ setText('biodiversity', fmtNum((s.top_pests || []).length));
13
+
14
  const avg = (s.last_7_days_pests || 0) / 7;
15
+ setText('dailyAvg', Math.round(avg));
16
+
17
  const top = (s.top_pests || [])[0];
18
+ setText('topToday', top ? `${fmtNum(top.count)} ${top.type} total` : 'No detections yet');
19
+ setText('monthInfo', `${fmtNum(s.last_7_days_pests)} pests in last 7 days`);
20
+
 
 
21
  renderLive(dash.live_camera_stream?.latest);
22
  renderChart(chart.hourly_today || []);
23
  renderRecent(dash.logs || []);
24
  }
25
+
26
+ async function loadLatest(){
27
+ try{
28
+ const r = await API.get('/api/live/latest');
29
  renderLive(r.latest);
30
+ }catch(e){
31
  console.warn(e);
32
  }
33
  }
34
+
35
+ function renderLive(item){
36
+ const list = document.getElementById('liveList');
37
+
38
+ if(!item){
39
+ setImg('liveImage', '');
40
  list.innerHTML = '<div class="empty">No live detection yet</div>';
41
  return;
42
  }
43
+
44
+ setImg('liveImage', item.annotated_image || item.original_image);
45
+
46
+ document.getElementById('openLatest').href = `/ui/logs/${item.id}`;
47
+
48
  const data = item.data || [];
49
  const total = item.total || 0;
50
+
51
+ setText('updateAge', fmtTime(item.timestamp_ms));
52
+
53
  list.innerHTML = data.length
54
+ ? data.map((d, i) => `
55
+ <div class="det-row">
56
+ <div class="det-left">
57
+ <span
58
+ class="det-dot"
59
+ style="background:${['#10b981', '#f59e0b', '#3b82f6', '#a855f7', '#ef4444'][i % 5]}"
60
+ ></span>
61
+ <span>${escapeHtml(d.type)}</span>
62
+ </div>
63
+ <span class="det-count">${pad2(d.count)}</span>
64
+ </div>
65
+ `).join('')
66
  : '<div class="empty">No pest in latest frame</div>';
67
+
68
  const pct = Math.min(100, Math.round((total / 25) * 100));
69
+
70
+ setText('loadPct', pct + '%');
71
+ document.getElementById('loadBar').style.width = pct + '%';
72
  }
73
+
74
+ function renderChart(rows){
75
+ const el = document.getElementById('hourlyChart');
76
+ const max = Math.max(1, ...rows.map(r => r.total || 0));
77
+
78
+ el.innerHTML = rows.map(r => `
79
+ <div
80
+ title="${r.hour}: ${r.total}"
81
+ class="bar ${r.total === max ? 'hot' : ''}"
82
+ style="height:${Math.max(4, (r.total / max) * 92)}%"
83
+ ></div>
84
+ `).join('');
85
  }
86
+
87
+ function renderRecent(logs){
88
+ const root = document.getElementById('recentLogs');
89
+
90
+ setText('logCount', `Showing ${logs.length} latest entries`);
91
+
92
+ if(!logs.length){
93
  root.innerHTML = '<div class="empty">No logs found</div>';
94
  return;
95
  }
96
+
97
+ root.innerHTML = logs.map(item => {
98
+ const type = primaryType(item);
99
+ const typeTitle = primaryTypeTitle(item);
100
+ const conf = avgConfidence(item);
101
+
102
+ return `
103
+ <div
104
+ class="log-row"
105
+ onclick="location.href='/ui/logs/${item.id}'"
106
+ style="display:flex;align-items:center;padding:16px 20px;border-bottom:1px solid #f0f3f1"
107
+ >
108
+ <img
109
+ class="thumb"
110
+ src="${item.annotated_image || item.original_image || ''}"
111
+ onerror="imgFallback(event)"
112
+ >
113
+
114
+ <div style="margin-left:16px;flex:1;min-width:0">
115
+ <b
116
+ class="type-name"
117
+ title="${escapeHtml(typeTitle)}"
118
+ style="color:var(--primary)"
119
+ >
120
+ ${escapeHtml(type)}
121
+ </b>
122
+ <div class="sub">Avg confidence ${confidenceText(conf)} · Total ${item.total || 0}</div>
123
+ </div>
124
+
125
+ <div class="desktop-only sub" style="min-width:180px">
126
+ ${fmtDate(item.timestamp_ms, item.datatime)}
127
+ </div>
128
+
129
+ <span class="material-symbols-outlined muted">chevron_right</span>
130
+ </div>
131
+ `;
132
+ }).join('');
133
  }
134
+
135
+ document.getElementById('searchBox').addEventListener('input', async e => {
136
  const q = e.target.value.trim();
137
+
138
+ if(!q){
139
  renderRecent(dashboardCache?.logs || []);
140
  return;
141
  }
142
+
143
+ const r = await API.get(`/api/logs?page=1&page_size=10&search=${encodeURIComponent(q)}`);
144
+
145
  renderRecent(r.items || []);
146
  });
147
+
148
+ document.getElementById('uploadBtn').addEventListener('click', async () => {
149
+ const file = document.getElementById('uploadInput').files[0];
150
+
151
+ if(!file){
152
+ setText('uploadStatus', 'Choose image first.');
153
  return;
154
  }
155
+
156
+ document.body.classList.add('loading');
157
+ setText('uploadStatus', 'Analyzing image...');
158
+
159
+ try{
160
+ const r = await API.postImage('/api/analyze', file);
161
+ setText('uploadStatus', `Saved. Total detected: ${r.total}. Opening detail...`);
162
+
163
+ setTimeout(() => location.href = `/ui/logs/${r.id}`, 700);
164
+ }catch(e){
165
+ setText('uploadStatus', 'Failed: ' + e.message);
166
+ }finally{
167
+ document.body.classList.remove('loading');
168
  }
169
  });
170
+
171
+ loadDashboard().catch(e => {
172
  console.error(e);
173
  document.body.insertAdjacentHTML(
174
+ 'afterbegin',
175
+ `<div class="notice">Dashboard error: ${escapeHtml(e.message)}</div>`
176
  );
177
  });
178
+
179
+ setInterval(loadLatest, 5000);
web/js/logs.js CHANGED
@@ -1,12 +1,107 @@
1
- let page=1,pageSize=10,last=null;
 
 
 
2
  async function loadLogs(){
3
- const q=document.getElementById('search').value.trim(); const p=document.getElementById('pestType').value.trim();
4
- const url=`/api/logs?page=${page}&page_size=${pageSize}${q?`&search=${encodeURIComponent(q)}`:''}${p?`&pest_type=${encodeURIComponent(p)}`:''}`;
5
- const r=await API.get(url); last=r; renderLogs(r.items||[]); setText('pageInfo',`Showing page ${r.page} of ${r.total_pages} · ${r.total_items} records`); document.getElementById('prevBtn').disabled=!r.has_prev; document.getElementById('nextBtn').disabled=!r.has_next;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  }
 
7
  function renderLogs(items){
8
- const tb=document.getElementById('logTable');
9
- if(!items.length){ tb.innerHTML='<tr><td colspan="6"><div class="empty">No logs found</div></td></tr>'; return; }
10
- tb.innerHTML=items.map(item=>{ const type=primaryType(item); const conf=avgConfidence(item); return `<tr class="log-row" onclick="location.href='/ui/logs/${item.id}'"><td><img class="thumb sm" src="${item.annotated_image||item.original_image||''}" onerror="imgFallback(event)"></td><td><b style="color:var(--primary)">${escapeHtml(type)}</b><div class="sub">${escapeHtml(item.id||'')}</div></td><td><div style="display:flex;align-items:center;gap:10px"><div class="confbar"><span style="width:${Math.round(conf*100)}%"></span></div><b>${confidenceText(conf)}</b></div></td><td>${item.total||0}</td><td>${fmtDate(item.timestamp_ms,item.datatime)}</td><td style="text-align:right"><span class="material-symbols-outlined muted">open_in_new</span></td></tr>`; }).join('');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  }
12
- document.getElementById('filterBtn').onclick=()=>{page=1;loadLogs().catch(alert)}; document.getElementById('search').addEventListener('keydown',e=>{if(e.key==='Enter'){page=1;loadLogs().catch(alert)}}); document.getElementById('prevBtn').onclick=()=>{if(last?.has_prev){page--;loadLogs().catch(alert)}}; document.getElementById('nextBtn').onclick=()=>{if(last?.has_next){page++;loadLogs().catch(alert)}}; loadLogs().catch(e=>alert(e.message));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ let page = 1;
2
+ let pageSize = 10;
3
+ let last = null;
4
+
5
  async function loadLogs(){
6
+ const q = document.getElementById('search').value.trim();
7
+ const p = document.getElementById('pestType').value.trim();
8
+
9
+ const url = `/api/logs?page=${page}&page_size=${pageSize}${q ? `&search=${encodeURIComponent(q)}` : ''}${p ? `&pest_type=${encodeURIComponent(p)}` : ''}`;
10
+
11
+ const r = await API.get(url);
12
+
13
+ last = r;
14
+
15
+ renderLogs(r.items || []);
16
+
17
+ setText(
18
+ 'pageInfo',
19
+ `Showing page ${r.page} of ${r.total_pages} · ${r.total_items} records`
20
+ );
21
+
22
+ document.getElementById('prevBtn').disabled = !r.has_prev;
23
+ document.getElementById('nextBtn').disabled = !r.has_next;
24
  }
25
+
26
  function renderLogs(items){
27
+ const tb = document.getElementById('logTable');
28
+
29
+ if(!items.length){
30
+ tb.innerHTML = '<tr><td colspan="6"><div class="empty">No logs found</div></td></tr>';
31
+ return;
32
+ }
33
+
34
+ tb.innerHTML = items.map(item => {
35
+ const type = primaryType(item);
36
+ const typeTitle = primaryTypeTitle(item);
37
+ const conf = avgConfidence(item);
38
+
39
+ return `
40
+ <tr class="log-row" onclick="location.href='/ui/logs/${item.id}'">
41
+ <td>
42
+ <img
43
+ class="thumb sm"
44
+ src="${item.annotated_image || item.original_image || ''}"
45
+ onerror="imgFallback(event)"
46
+ >
47
+ </td>
48
+
49
+ <td class="identification-cell">
50
+ <b
51
+ class="type-name"
52
+ title="${escapeHtml(typeTitle)}"
53
+ style="color:var(--primary)"
54
+ >
55
+ ${escapeHtml(type)}
56
+ </b>
57
+ <div class="sub">${escapeHtml(item.id || '')}</div>
58
+ </td>
59
+
60
+ <td>
61
+ <div style="display:flex;align-items:center;gap:10px">
62
+ <div class="confbar">
63
+ <span style="width:${Math.round(conf * 100)}%"></span>
64
+ </div>
65
+ <b>${confidenceText(conf)}</b>
66
+ </div>
67
+ </td>
68
+
69
+ <td>${item.total || 0}</td>
70
+
71
+ <td>${fmtDate(item.timestamp_ms, item.datatime)}</td>
72
+
73
+ <td style="text-align:right">
74
+ <span class="material-symbols-outlined muted">open_in_new</span>
75
+ </td>
76
+ </tr>
77
+ `;
78
+ }).join('');
79
  }
80
+
81
+ document.getElementById('filterBtn').onclick = () => {
82
+ page = 1;
83
+ loadLogs().catch(alert);
84
+ };
85
+
86
+ document.getElementById('search').addEventListener('keydown', e => {
87
+ if(e.key === 'Enter'){
88
+ page = 1;
89
+ loadLogs().catch(alert);
90
+ }
91
+ });
92
+
93
+ document.getElementById('prevBtn').onclick = () => {
94
+ if(last?.has_prev){
95
+ page--;
96
+ loadLogs().catch(alert);
97
+ }
98
+ };
99
+
100
+ document.getElementById('nextBtn').onclick = () => {
101
+ if(last?.has_next){
102
+ page++;
103
+ loadLogs().catch(alert);
104
+ }
105
+ };
106
+
107
+ loadLogs().catch(e => alert(e.message));