ltcai 0.1.0

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.
@@ -0,0 +1,1013 @@
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Lattice AI Admin</title>
8
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
9
+ <style>
10
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
11
+
12
+ :root {
13
+ --bg: #0e1110;
14
+ --panel: #171b19;
15
+ --panel-2: #1d2220;
16
+ --panel-3: #232a26;
17
+ --text: #f3f1e8;
18
+ --muted: #b7b5ab;
19
+ --faint: #7c8078;
20
+ --accent: #42d39a;
21
+ --accent-2: #d8a54a;
22
+ --danger: #e47a73;
23
+ --border: rgba(243,241,232,0.1);
24
+ --border-strong: rgba(243,241,232,0.18);
25
+ --shadow: 0 24px 70px rgba(0,0,0,0.34);
26
+ --radius: 14px;
27
+ --radius-sm: 10px;
28
+ }
29
+
30
+ * { box-sizing: border-box; }
31
+ html, body { height: 100%; }
32
+ body {
33
+ margin: 0;
34
+ font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;
35
+ color: var(--text);
36
+ background:
37
+ linear-gradient(180deg, rgba(255,255,255,0.018), transparent 28%),
38
+ linear-gradient(135deg, #101211 0%, #0d100f 52%, #141514 100%);
39
+ }
40
+
41
+ body::before {
42
+ content: "";
43
+ position: fixed;
44
+ inset: 0;
45
+ pointer-events: none;
46
+ background:
47
+ linear-gradient(rgba(243,241,232,0.032) 1px, transparent 1px),
48
+ linear-gradient(90deg, rgba(243,241,232,0.022) 1px, transparent 1px);
49
+ background-size: 46px 46px;
50
+ mask-image: linear-gradient(180deg, rgba(0,0,0,0.4), rgba(0,0,0,0.08));
51
+ }
52
+
53
+ .page {
54
+ position: relative;
55
+ z-index: 1;
56
+ min-height: 100%;
57
+ display: flex;
58
+ flex-direction: column;
59
+ }
60
+
61
+ .topbar {
62
+ display: flex;
63
+ align-items: center;
64
+ justify-content: space-between;
65
+ gap: 16px;
66
+ padding: 22px 28px;
67
+ border-bottom: 1px solid rgba(243,241,232,0.08);
68
+ background: rgba(15,17,16,0.84);
69
+ backdrop-filter: blur(20px);
70
+ position: sticky;
71
+ top: 0;
72
+ z-index: 2;
73
+ }
74
+
75
+ .brand {
76
+ display: flex;
77
+ align-items: center;
78
+ gap: 12px;
79
+ min-width: 0;
80
+ }
81
+
82
+ .brand-mark {
83
+ width: 42px;
84
+ height: 42px;
85
+ border-radius: 12px;
86
+ background: linear-gradient(135deg, #42d39a, #d8a54a);
87
+ color: #0c120f;
88
+ display: grid;
89
+ place-items: center;
90
+ font-size: 20px;
91
+ font-weight: 800;
92
+ box-shadow: 0 12px 30px rgba(0,0,0,0.25);
93
+ flex: 0 0 auto;
94
+ }
95
+
96
+ .brand h1 {
97
+ margin: 0;
98
+ font-size: 18px;
99
+ line-height: 1.15;
100
+ letter-spacing: 0;
101
+ }
102
+
103
+ .brand p {
104
+ margin: 4px 0 0;
105
+ color: var(--muted);
106
+ font-size: 12px;
107
+ }
108
+
109
+ .top-actions {
110
+ display: flex;
111
+ gap: 8px;
112
+ flex-wrap: wrap;
113
+ justify-content: flex-end;
114
+ }
115
+
116
+ .btn {
117
+ border: 1px solid var(--border);
118
+ background: rgba(255,255,255,0.03);
119
+ color: var(--text);
120
+ border-radius: 999px;
121
+ padding: 10px 14px;
122
+ font: inherit;
123
+ font-size: 13px;
124
+ font-weight: 700;
125
+ cursor: pointer;
126
+ display: inline-flex;
127
+ align-items: center;
128
+ gap: 8px;
129
+ transition: all .15s ease;
130
+ text-decoration: none;
131
+ }
132
+
133
+ .btn:hover {
134
+ border-color: rgba(66,211,154,0.28);
135
+ background: rgba(66,211,154,0.08);
136
+ color: var(--accent);
137
+ }
138
+
139
+ .btn.primary {
140
+ background: rgba(66,211,154,0.1);
141
+ border-color: rgba(66,211,154,0.22);
142
+ }
143
+
144
+ .btn.danger:hover {
145
+ background: rgba(228,122,115,0.08);
146
+ border-color: rgba(228,122,115,0.22);
147
+ color: #ffbdb8;
148
+ }
149
+
150
+ .content {
151
+ width: min(1440px, calc(100vw - 32px));
152
+ margin: 0 auto;
153
+ padding: 22px 0 28px;
154
+ display: flex;
155
+ flex-direction: column;
156
+ gap: 18px;
157
+ }
158
+
159
+ .hero {
160
+ border: 1px solid var(--border);
161
+ border-radius: calc(var(--radius) + 2px);
162
+ background:
163
+ linear-gradient(135deg, rgba(66,211,154,0.09), rgba(216,165,74,0.05)),
164
+ rgba(23,27,25,0.9);
165
+ box-shadow: var(--shadow);
166
+ padding: 20px 22px;
167
+ display: flex;
168
+ align-items: center;
169
+ justify-content: space-between;
170
+ gap: 14px;
171
+ flex-wrap: wrap;
172
+ }
173
+
174
+ .hero h2 {
175
+ margin: 0 0 6px;
176
+ font-size: 26px;
177
+ line-height: 1.15;
178
+ }
179
+
180
+ .hero p {
181
+ margin: 0;
182
+ color: var(--muted);
183
+ font-size: 13px;
184
+ line-height: 1.5;
185
+ max-width: 760px;
186
+ }
187
+
188
+ .session-card {
189
+ border: 1px solid rgba(66,211,154,0.16);
190
+ background: rgba(66,211,154,0.06);
191
+ color: var(--text);
192
+ border-radius: 12px;
193
+ padding: 12px 14px;
194
+ min-width: 280px;
195
+ }
196
+
197
+ .session-card .label {
198
+ font-size: 11px;
199
+ text-transform: uppercase;
200
+ letter-spacing: 0.08em;
201
+ color: var(--faint);
202
+ margin-bottom: 6px;
203
+ }
204
+
205
+ .session-card .value {
206
+ font-size: 13px;
207
+ font-weight: 700;
208
+ word-break: break-all;
209
+ }
210
+
211
+ .notice {
212
+ border: 1px solid rgba(216,165,74,0.2);
213
+ background: rgba(216,165,74,0.08);
214
+ color: #f4dfb3;
215
+ border-radius: 12px;
216
+ padding: 12px 14px;
217
+ font-size: 13px;
218
+ line-height: 1.5;
219
+ }
220
+
221
+ .summary-grid {
222
+ display: grid;
223
+ grid-template-columns: repeat(4, minmax(0, 1fr));
224
+ gap: 12px;
225
+ }
226
+
227
+ .summary-card {
228
+ border: 1px solid var(--border);
229
+ border-radius: var(--radius);
230
+ background: rgba(24,28,26,0.94);
231
+ box-shadow: var(--shadow);
232
+ padding: 16px 16px 15px;
233
+ min-width: 0;
234
+ }
235
+
236
+ .summary-card .label {
237
+ color: var(--faint);
238
+ font-size: 11px;
239
+ font-weight: 700;
240
+ letter-spacing: 0.08em;
241
+ text-transform: uppercase;
242
+ margin-bottom: 8px;
243
+ }
244
+
245
+ .summary-card .value {
246
+ font-size: 28px;
247
+ font-weight: 800;
248
+ line-height: 1;
249
+ }
250
+
251
+ .summary-card .meta {
252
+ margin-top: 8px;
253
+ color: var(--muted);
254
+ font-size: 12px;
255
+ line-height: 1.45;
256
+ min-height: 2.7em;
257
+ }
258
+
259
+ .panel-grid {
260
+ display: grid;
261
+ grid-template-columns: 1.1fr 0.9fr;
262
+ gap: 18px;
263
+ }
264
+
265
+ .panel {
266
+ border: 1px solid var(--border);
267
+ border-radius: var(--radius);
268
+ background: rgba(23,27,25,0.96);
269
+ box-shadow: var(--shadow);
270
+ overflow: hidden;
271
+ min-width: 0;
272
+ }
273
+
274
+ .panel-header {
275
+ padding: 16px 18px;
276
+ border-bottom: 1px solid var(--border);
277
+ display: flex;
278
+ align-items: center;
279
+ justify-content: space-between;
280
+ gap: 12px;
281
+ }
282
+
283
+ .panel-header h3 {
284
+ margin: 0;
285
+ font-size: 15px;
286
+ }
287
+
288
+ .panel-header p {
289
+ margin: 4px 0 0;
290
+ color: var(--muted);
291
+ font-size: 12px;
292
+ line-height: 1.45;
293
+ }
294
+
295
+ .panel-body {
296
+ padding: 16px 18px 18px;
297
+ }
298
+
299
+ .form-grid {
300
+ display: grid;
301
+ grid-template-columns: repeat(2, minmax(0, 1fr));
302
+ gap: 10px;
303
+ }
304
+
305
+ .field {
306
+ display: flex;
307
+ flex-direction: column;
308
+ gap: 6px;
309
+ }
310
+
311
+ .field.full { grid-column: 1 / -1; }
312
+
313
+ label {
314
+ color: var(--muted);
315
+ font-size: 12px;
316
+ font-weight: 700;
317
+ }
318
+
319
+ input, textarea {
320
+ width: 100%;
321
+ border: 1px solid var(--border);
322
+ background: var(--panel-2);
323
+ color: var(--text);
324
+ border-radius: 10px;
325
+ padding: 10px 12px;
326
+ font: inherit;
327
+ font-size: 13px;
328
+ outline: none;
329
+ }
330
+
331
+ textarea {
332
+ min-height: 80px;
333
+ resize: vertical;
334
+ }
335
+
336
+ input:focus, textarea:focus {
337
+ border-color: rgba(66,211,154,0.45);
338
+ box-shadow: 0 0 0 3px rgba(66,211,154,0.06);
339
+ }
340
+
341
+ .toolbar {
342
+ display: flex;
343
+ align-items: center;
344
+ justify-content: space-between;
345
+ gap: 12px;
346
+ margin-top: 12px;
347
+ flex-wrap: wrap;
348
+ }
349
+
350
+ .status-copy {
351
+ color: var(--muted);
352
+ font-size: 12px;
353
+ }
354
+
355
+ .tag-row {
356
+ display: flex;
357
+ flex-wrap: wrap;
358
+ gap: 8px;
359
+ }
360
+
361
+ .tag {
362
+ display: inline-flex;
363
+ align-items: center;
364
+ gap: 6px;
365
+ padding: 5px 9px;
366
+ border-radius: 999px;
367
+ border: 1px solid var(--border);
368
+ background: rgba(255,255,255,0.03);
369
+ color: var(--muted);
370
+ font-size: 12px;
371
+ font-weight: 600;
372
+ }
373
+
374
+ .tag.high { border-color: rgba(228,122,115,0.34); color: #ffbdb8; }
375
+ .tag.medium { border-color: rgba(216,165,74,0.34); color: #f1d39e; }
376
+ .tag.low { border-color: rgba(66,211,154,0.34); color: #9be7cd; }
377
+
378
+ .two-col {
379
+ display: grid;
380
+ grid-template-columns: repeat(2, minmax(0, 1fr));
381
+ gap: 12px;
382
+ }
383
+
384
+ .subpanel {
385
+ border: 1px solid var(--border);
386
+ border-radius: 12px;
387
+ background: rgba(255,255,255,0.02);
388
+ padding: 14px;
389
+ min-width: 0;
390
+ }
391
+
392
+ .subpanel h4 {
393
+ margin: 0 0 10px;
394
+ font-size: 13px;
395
+ display: flex;
396
+ align-items: center;
397
+ gap: 7px;
398
+ }
399
+
400
+ .list {
401
+ display: flex;
402
+ flex-direction: column;
403
+ gap: 8px;
404
+ max-height: 320px;
405
+ overflow: auto;
406
+ }
407
+
408
+ .item {
409
+ border: 1px solid var(--border);
410
+ border-radius: 10px;
411
+ background: rgba(0,0,0,0.12);
412
+ padding: 10px 11px;
413
+ }
414
+
415
+ .item-meta {
416
+ display: flex;
417
+ flex-wrap: wrap;
418
+ gap: 6px;
419
+ margin-bottom: 6px;
420
+ }
421
+
422
+ .preview {
423
+ color: var(--muted);
424
+ font-size: 12px;
425
+ line-height: 1.5;
426
+ word-break: break-word;
427
+ }
428
+
429
+ .table-wrap {
430
+ overflow: auto;
431
+ border: 1px solid var(--border);
432
+ border-radius: 12px;
433
+ }
434
+
435
+ table {
436
+ width: 100%;
437
+ border-collapse: collapse;
438
+ min-width: 820px;
439
+ background: rgba(255,255,255,0.015);
440
+ }
441
+
442
+ th, td {
443
+ padding: 11px 10px;
444
+ border-bottom: 1px solid var(--border);
445
+ text-align: left;
446
+ vertical-align: middle;
447
+ font-size: 13px;
448
+ }
449
+
450
+ th {
451
+ color: var(--muted);
452
+ background: rgba(255,255,255,0.03);
453
+ font-size: 12px;
454
+ letter-spacing: 0.02em;
455
+ }
456
+
457
+ td .actions {
458
+ display: flex;
459
+ gap: 6px;
460
+ flex-wrap: wrap;
461
+ }
462
+
463
+ .table-btn {
464
+ border: 1px solid var(--border);
465
+ background: rgba(255,255,255,0.03);
466
+ color: var(--text);
467
+ border-radius: 8px;
468
+ padding: 7px 9px;
469
+ cursor: pointer;
470
+ font-size: 12px;
471
+ font-weight: 600;
472
+ }
473
+
474
+ .table-btn:hover {
475
+ border-color: rgba(66,211,154,0.24);
476
+ background: rgba(66,211,154,0.08);
477
+ }
478
+
479
+ .table-btn.danger:hover {
480
+ border-color: rgba(228,122,115,0.24);
481
+ background: rgba(228,122,115,0.08);
482
+ color: #ffbdb8;
483
+ }
484
+
485
+ .role {
486
+ display: inline-flex;
487
+ align-items: center;
488
+ padding: 4px 8px;
489
+ border-radius: 999px;
490
+ border: 1px solid var(--border);
491
+ color: var(--muted);
492
+ font-size: 12px;
493
+ font-weight: 700;
494
+ }
495
+
496
+ .footer-space { height: 8px; }
497
+
498
+ .muted {
499
+ color: var(--muted);
500
+ }
501
+
502
+ .error {
503
+ color: #ffbdb8;
504
+ }
505
+
506
+ @media (max-width: 1100px) {
507
+ .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
508
+ .panel-grid { grid-template-columns: 1fr; }
509
+ }
510
+
511
+ @media (max-width: 760px) {
512
+ .topbar, .content { width: auto; }
513
+ .topbar {
514
+ padding: 16px;
515
+ align-items: flex-start;
516
+ flex-direction: column;
517
+ }
518
+ .content { padding: 16px; gap: 14px; }
519
+ .hero h2 { font-size: 22px; }
520
+ .summary-grid { grid-template-columns: 1fr; }
521
+ .two-col, .form-grid { grid-template-columns: 1fr; }
522
+ .field.full { grid-column: auto; }
523
+ }
524
+ </style>
525
+ </head>
526
+
527
+ <body>
528
+ <div class="page">
529
+ <header class="topbar">
530
+ <div class="brand">
531
+ <div class="brand-mark"><i class="ti ti-shield-lock"></i></div>
532
+ <div>
533
+ <h1>Lattice AI Admin</h1>
534
+ <p>관리자 대시보드</p>
535
+ </div>
536
+ </div>
537
+ <div class="top-actions">
538
+ <a class="btn" href="/"><i class="ti ti-arrow-left"></i> 채팅으로</a>
539
+ <button class="btn primary" id="refresh-btn" type="button"><i class="ti ti-refresh"></i> 새로고침</button>
540
+ <button class="btn danger" id="logout-btn" type="button"><i class="ti ti-logout"></i> 로그아웃</button>
541
+ </div>
542
+ </header>
543
+
544
+ <main class="content">
545
+ <section class="hero">
546
+ <div>
547
+ <h2>관리자 대시보드</h2>
548
+ <p>사용자, 민감도 로그, Private VPC, 서버 상태를 한 화면에서 관리합니다. 채팅 화면과 분리되어 있고, 필요할 때만 따로 열어볼 수 있습니다.</p>
549
+ </div>
550
+ <div class="session-card">
551
+ <div class="label">Current Session</div>
552
+ <div class="value" id="session-value">세션 확인 중...</div>
553
+ </div>
554
+ </section>
555
+
556
+ <div id="access-notice" class="notice" style="display:none"></div>
557
+
558
+ <section class="summary-grid" id="summary-grid">
559
+ <div class="summary-card">
560
+ <div class="label">Total Users</div>
561
+ <div class="value" id="total-users">-</div>
562
+ <div class="meta" id="total-users-meta">사용자 계정 수</div>
563
+ </div>
564
+ <div class="summary-card">
565
+ <div class="label">Active Messages</div>
566
+ <div class="value" id="total-messages">-</div>
567
+ <div class="meta" id="total-messages-meta">최근 대화 활동</div>
568
+ </div>
569
+ <div class="summary-card">
570
+ <div class="label">Current Model</div>
571
+ <div class="value" id="current-model">-</div>
572
+ <div class="meta" id="current-model-meta">로드된 모델 수: -</div>
573
+ </div>
574
+ <div class="summary-card">
575
+ <div class="label">VPC Status</div>
576
+ <div class="value" id="vpc-status">-</div>
577
+ <div class="meta" id="vpc-status-meta">Private network state</div>
578
+ </div>
579
+ </section>
580
+
581
+ <section class="panel-grid">
582
+ <article class="panel">
583
+ <div class="panel-header">
584
+ <div>
585
+ <h3>Private VPC</h3>
586
+ <p>네트워크 프로필과 운영 상태를 수정합니다.</p>
587
+ </div>
588
+ <span class="tag" id="admin-pill"><i class="ti ti-user-cog"></i> Admin</span>
589
+ </div>
590
+ <div class="panel-body">
591
+ <div class="form-grid">
592
+ <div class="field">
593
+ <label for="vpc-provider">Provider</label>
594
+ <input id="vpc-provider" placeholder="AWS">
595
+ </div>
596
+ <div class="field">
597
+ <label for="vpc-region">Region</label>
598
+ <input id="vpc-region" placeholder="ap-northeast-2">
599
+ </div>
600
+ <div class="field">
601
+ <label for="vpc-cidr">CIDR Block</label>
602
+ <input id="vpc-cidr" placeholder="10.42.0.0/16">
603
+ </div>
604
+ <div class="field">
605
+ <label for="vpc-endpoint">Private Endpoint</label>
606
+ <input id="vpc-endpoint" placeholder="ltcai-private.local">
607
+ </div>
608
+ <div class="field">
609
+ <label for="vpc-vpn">VPN Status</label>
610
+ <input id="vpc-vpn" placeholder="standby">
611
+ </div>
612
+ <div class="field">
613
+ <label for="vpc-peering">Peering Status</label>
614
+ <input id="vpc-peering" placeholder="not_configured">
615
+ </div>
616
+ <div class="field full">
617
+ <label for="vpc-subnets">Private Subnets</label>
618
+ <input id="vpc-subnets" placeholder="10.42.10.0/24, 10.42.20.0/24">
619
+ </div>
620
+ <div class="field full">
621
+ <label for="vpc-notes">Notes</label>
622
+ <textarea id="vpc-notes" placeholder="운영 메모"></textarea>
623
+ </div>
624
+ </div>
625
+ <div class="toolbar">
626
+ <div class="status-copy" id="vpc-save-status">불러오는 중...</div>
627
+ <button class="btn primary" id="save-vpc-btn" type="button"><i class="ti ti-device-floppy"></i> 저장</button>
628
+ </div>
629
+ </div>
630
+ </article>
631
+
632
+ <article class="panel">
633
+ <div class="panel-header">
634
+ <div>
635
+ <h3>Current Session</h3>
636
+ <p>현재 로그인한 계정과 서버 상태를 빠르게 확인합니다.</p>
637
+ </div>
638
+ </div>
639
+ <div class="panel-body">
640
+ <div class="tag-row" id="session-tags"></div>
641
+ <div class="footer-space"></div>
642
+ <div class="notice" id="session-help">
643
+ 로그인 정보가 없으면 이 화면의 관리자 API를 사용할 수 없습니다.
644
+ </div>
645
+ </div>
646
+ </article>
647
+ </section>
648
+
649
+ <section class="panel">
650
+ <div class="panel-header">
651
+ <div>
652
+ <h3>민감도 분석</h3>
653
+ <p>감지된 위험 메시지와 준수 메시지를 분리해서 보여줍니다.</p>
654
+ </div>
655
+ <div class="tag-row" id="sensitivity-summary"></div>
656
+ </div>
657
+ <div class="panel-body">
658
+ <div class="two-col">
659
+ <div class="subpanel">
660
+ <h4><i class="ti ti-alert-triangle"></i> Risk Fields</h4>
661
+ <div class="list" id="risk-fields"></div>
662
+ </div>
663
+ <div class="subpanel">
664
+ <h4><i class="ti ti-shield-check"></i> Compliance Fields</h4>
665
+ <div class="list" id="compliance-fields"></div>
666
+ </div>
667
+ </div>
668
+ </div>
669
+ </section>
670
+
671
+ <section class="panel">
672
+ <div class="panel-header">
673
+ <div>
674
+ <h3>사용자 관리</h3>
675
+ <p>역할 변경, 비활성화, 삭제를 처리합니다.</p>
676
+ </div>
677
+ </div>
678
+ <div class="panel-body">
679
+ <div class="table-wrap" id="user-table-wrap">
680
+ <div class="preview" style="padding: 14px;">불러오는 중...</div>
681
+ </div>
682
+ </div>
683
+ </section>
684
+ </main>
685
+ </div>
686
+
687
+ <script>
688
+ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825' : '';
689
+
690
+ function apiFetch(path, options = {}) {
691
+ return fetch(`${API_BASE}${path}`, options);
692
+ }
693
+
694
+ function currentUserEmail() {
695
+ return localStorage.getItem('ltcai_user_email') || '';
696
+ }
697
+
698
+ function currentUserNickname() {
699
+ return localStorage.getItem('ltcai_user_nickname') || 'Guest';
700
+ }
701
+
702
+ function currentUserIsAdmin() {
703
+ return localStorage.getItem('ltcai_is_admin') === 'true';
704
+ }
705
+
706
+ function adminHeaders() {
707
+ return {
708
+ 'Content-Type': 'application/json',
709
+ 'X-Admin-Email': currentUserEmail()
710
+ };
711
+ }
712
+
713
+ function esc(value) {
714
+ return String(value ?? '')
715
+ .replace(/&/g, '&amp;')
716
+ .replace(/</g, '&lt;')
717
+ .replace(/>/g, '&gt;')
718
+ .replace(/"/g, '&quot;')
719
+ .replace(/'/g, '&#039;');
720
+ }
721
+
722
+ function compactModelName(modelId) {
723
+ if (!modelId) return 'None';
724
+ const clean = String(modelId).replaceAll('mlx-community/', '');
725
+ return clean.length <= 28 ? clean : `${clean.slice(0, 18)}...${clean.slice(-8)}`;
726
+ }
727
+
728
+ function vpcHealthText(config) {
729
+ if (!config) return '대기';
730
+ if (config.vpn_status === 'connected' || config.peering_status === 'active') return '연결됨';
731
+ if (config.vpn_status === 'standby') return '대기';
732
+ return config.vpn_status || config.peering_status || '설정 필요';
733
+ }
734
+
735
+ function setSessionInfo() {
736
+ const email = currentUserEmail();
737
+ const nick = currentUserNickname();
738
+ const isAdmin = currentUserIsAdmin();
739
+ document.getElementById('session-value').textContent = email ? `${nick} <${email}>` : '세션 정보가 없습니다';
740
+ const tags = [
741
+ ['사용자', nick, 'low'],
742
+ ['이메일', email || '없음', 'medium'],
743
+ ['권한', isAdmin ? 'admin' : 'user', isAdmin ? 'low' : 'medium']
744
+ ];
745
+ document.getElementById('session-tags').innerHTML = tags.map(([label, value, tone]) => `
746
+ <span class="tag ${tone}"><span>${esc(label)}</span> ${esc(value)}</span>
747
+ `).join('');
748
+ document.getElementById('admin-pill').innerHTML = isAdmin
749
+ ? '<i class="ti ti-shield-check"></i> Admin'
750
+ : '<i class="ti ti-lock"></i> Read only';
751
+ const help = document.getElementById('session-help');
752
+ help.innerHTML = email
753
+ ? '이메일 헤더가 설정되어 관리자 API를 호출할 수 있습니다.'
754
+ : '채팅 화면에서 로그인한 뒤 이 화면을 열어야 관리자 API를 사용할 수 있습니다.';
755
+ }
756
+
757
+ function fillVpcForm(config) {
758
+ if (!config) return;
759
+ document.getElementById('vpc-provider').value = config.provider || '';
760
+ document.getElementById('vpc-region').value = config.region || '';
761
+ document.getElementById('vpc-cidr').value = config.cidr_block || '';
762
+ document.getElementById('vpc-endpoint').value = config.endpoint || '';
763
+ document.getElementById('vpc-vpn').value = config.vpn_status || '';
764
+ document.getElementById('vpc-peering').value = config.peering_status || '';
765
+ document.getElementById('vpc-subnets').value = (config.private_subnets || []).join(', ');
766
+ document.getElementById('vpc-notes').value = config.notes || '';
767
+ document.getElementById('vpc-save-status').textContent = config.updated_at
768
+ ? `마지막 저장: ${new Date(config.updated_at).toLocaleString()}`
769
+ : '기본 VPC 프로필을 사용 중입니다.';
770
+
771
+ const provider = config.provider || 'VPC';
772
+ const region = config.region || '-';
773
+ document.getElementById('vpc-status').textContent = `${provider} ${region}`;
774
+ document.getElementById('vpc-status-meta').textContent = `${config.cidr_block || '-'} · ${config.endpoint || '-'} · ${vpcHealthText(config)}`;
775
+ }
776
+
777
+ function renderSummary(health, summary, vpc) {
778
+ document.getElementById('total-users').textContent = summary ? summary.total_users : '-';
779
+ document.getElementById('total-users-meta').textContent = summary
780
+ ? `${summary.active_users} active · ${summary.admin_users} admins`
781
+ : '관리자 권한 필요';
782
+ document.getElementById('total-messages').textContent = summary ? summary.total_messages : '-';
783
+ document.getElementById('total-messages-meta').textContent = summary
784
+ ? `user ${summary.user_messages} · assistant ${summary.assistant_messages}`
785
+ : '최근 메시지 정보를 불러올 수 없음';
786
+ document.getElementById('current-model').textContent = compactModelName(health?.current_model);
787
+ document.getElementById('current-model-meta').textContent = `${health?.loaded_models?.length || 0} loaded · ${health?.device || 'local runtime'}`;
788
+ document.getElementById('vpc-status').textContent = `${vpc?.provider || '-'} ${vpc?.region || '-'}`;
789
+ document.getElementById('vpc-status-meta').textContent = `${vpc?.cidr_block || '-'} · ${vpc?.endpoint || '-'} · ${vpcHealthText(vpc)}`;
790
+ }
791
+
792
+ function renderSensitivity(report) {
793
+ const summary = report?.summary || {};
794
+ const severity = summary.severity_counts || {};
795
+ const fieldCounts = summary.field_counts || {};
796
+ const userCounts = summary.user_counts || {};
797
+
798
+ const tags = [
799
+ ['risk', `위험 ${summary.risky_messages || 0}`],
800
+ ['low', `준수 ${summary.compliant_messages || 0}`],
801
+ ['medium', `위험률 ${summary.risk_rate || 0}%`],
802
+ ['high', `높음 ${severity.high || 0}`]
803
+ ];
804
+ document.getElementById('sensitivity-summary').innerHTML = tags.map(([tone, label]) => `<span class="tag ${tone}">${esc(label)}</span>`).join('');
805
+
806
+ const riskList = report?.risk_fields || [];
807
+ const complianceList = report?.compliance_fields || [];
808
+
809
+ document.getElementById('risk-fields').innerHTML = riskList.length
810
+ ? riskList.slice().reverse().map(item => `
811
+ <div class="item">
812
+ <div class="item-meta">
813
+ <span class="tag">${esc(item.user_nickname || 'Unknown')}</span>
814
+ <span class="tag">${esc(item.user_email || 'unknown')}</span>
815
+ <span class="tag ${item.sensitivity || 'low'}">${esc(item.sensitivity || 'none')}</span>
816
+ ${(item.labels || []).map(label => `<span class="tag medium">${esc(label)}</span>`).join('')}
817
+ </div>
818
+ <div class="preview">${esc(item.preview || '')}</div>
819
+ </div>
820
+ `).join('')
821
+ : '<div class="preview">감지된 위험 필드가 없습니다.</div>';
822
+
823
+ document.getElementById('compliance-fields').innerHTML = complianceList.length
824
+ ? complianceList.slice().reverse().map(item => `
825
+ <div class="item">
826
+ <div class="item-meta">
827
+ <span class="tag">${esc(item.user_nickname || 'Unknown')}</span>
828
+ <span class="tag">${esc(item.user_email || 'unknown')}</span>
829
+ <span class="tag ${item.sensitivity || 'low'}">${esc(item.sensitivity || 'none')}</span>
830
+ ${(item.compliance_fields || []).map(label => `<span class="tag low">${esc(label)}</span>`).join('')}
831
+ </div>
832
+ <div class="preview">${esc(item.preview || '')}</div>
833
+ </div>
834
+ `).join('')
835
+ : '<div class="preview">준수 항목이 없습니다.</div>';
836
+
837
+ const fieldTags = Object.entries(fieldCounts).map(([label, count]) => `<span class="tag medium">${esc(label)} ${esc(count)}</span>`);
838
+ const userTags = Object.entries(userCounts).map(([label, count]) => `<span class="tag high">${esc(label)} ${esc(count)}</span>`);
839
+ document.getElementById('sensitivity-summary').insertAdjacentHTML('beforeend', fieldTags.join('') + userTags.join(''));
840
+ }
841
+
842
+ function renderUsers(users) {
843
+ const wrap = document.getElementById('user-table-wrap');
844
+ if (!Array.isArray(users) || !users.length) {
845
+ wrap.innerHTML = '<div class="preview" style="padding:14px">사용자 데이터가 없습니다.</div>';
846
+ return;
847
+ }
848
+ wrap.innerHTML = `
849
+ <table>
850
+ <thead>
851
+ <tr>
852
+ <th>Email</th>
853
+ <th>Name</th>
854
+ <th>Nickname</th>
855
+ <th>Role</th>
856
+ <th>Status</th>
857
+ <th>Actions</th>
858
+ </tr>
859
+ </thead>
860
+ <tbody>
861
+ ${users.map(user => `
862
+ <tr>
863
+ <td>${esc(user.email)}</td>
864
+ <td>${esc(user.name || '-')}</td>
865
+ <td>${esc(user.nickname || '-')}</td>
866
+ <td><span class="role">${esc(user.role || '-')}</span></td>
867
+ <td>${user.disabled ? '비활성' : '활성'}</td>
868
+ <td>
869
+ <div class="actions">
870
+ <button class="table-btn"
871
+ data-action="role"
872
+ data-email="${esc(user.email)}"
873
+ data-next-role="${user.role === 'admin' ? 'user' : 'admin'}">
874
+ ${user.role === 'admin' ? '권한 해제' : '관리자 지정'}
875
+ </button>
876
+ <button class="table-btn"
877
+ data-action="disable"
878
+ data-email="${esc(user.email)}"
879
+ data-disabled="${user.disabled ? 'false' : 'true'}">
880
+ ${user.disabled ? '활성화' : '비활성화'}
881
+ </button>
882
+ <button class="table-btn danger"
883
+ data-action="delete"
884
+ data-email="${esc(user.email)}">삭제</button>
885
+ </div>
886
+ </td>
887
+ </tr>
888
+ `).join('')}
889
+ </tbody>
890
+ </table>
891
+ `;
892
+ }
893
+
894
+ document.getElementById('user-table-wrap').addEventListener('click', async (e) => {
895
+ const btn = e.target.closest('button[data-action]');
896
+ if (!btn) return;
897
+ const action = btn.dataset.action;
898
+ const email = btn.dataset.email;
899
+ if (!email) return;
900
+ const encodedEmail = encodeURIComponent(email);
901
+ if (action === 'role') {
902
+ const nextRole = btn.dataset.nextRole;
903
+ await apiFetch(`/admin/users/${encodedEmail}`, {
904
+ method: 'PATCH', headers: adminHeaders(), body: JSON.stringify({ role: nextRole })
905
+ });
906
+ await loadDashboard();
907
+ } else if (action === 'disable') {
908
+ const disabled = btn.dataset.disabled === 'true';
909
+ await apiFetch(`/admin/users/${encodedEmail}`, {
910
+ method: 'PATCH', headers: adminHeaders(), body: JSON.stringify({ disabled })
911
+ });
912
+ await loadDashboard();
913
+ } else if (action === 'delete') {
914
+ if (!confirm(`'${email}' 사용자를 삭제할까요?`)) return;
915
+ await apiFetch(`/admin/users/${encodedEmail}`, {
916
+ method: 'DELETE', headers: adminHeaders()
917
+ });
918
+ await loadDashboard();
919
+ }
920
+ });
921
+
922
+ async function loadDashboard() {
923
+ setSessionInfo();
924
+
925
+ const access = document.getElementById('access-notice');
926
+ access.style.display = 'none';
927
+
928
+ try {
929
+ const [healthRes, vpcRes, summaryRes, usersRes, sensitivityRes] = await Promise.all([
930
+ apiFetch('/health'),
931
+ apiFetch('/vpc/status'),
932
+ apiFetch('/admin/summary', { headers: adminHeaders() }),
933
+ apiFetch('/admin/users', { headers: adminHeaders() }),
934
+ apiFetch('/admin/sensitivity', { headers: adminHeaders() })
935
+ ]);
936
+
937
+ const health = healthRes.ok ? await healthRes.json() : null;
938
+ const vpc = vpcRes.ok ? await vpcRes.json() : null;
939
+ const summary = summaryRes.ok ? await summaryRes.json() : null;
940
+ const users = usersRes.ok ? await usersRes.json() : null;
941
+ const sensitivity = sensitivityRes.ok ? await sensitivityRes.json() : null;
942
+
943
+ renderSummary(health, summary, vpc);
944
+ fillVpcForm(vpc);
945
+ renderUsers(users);
946
+ renderSensitivity(sensitivity);
947
+
948
+ const failedSections = [];
949
+ if (!summaryRes.ok) failedSections.push('요약');
950
+ if (!usersRes.ok) failedSections.push('사용자 목록');
951
+ if (!sensitivityRes.ok) failedSections.push('민감 정보 분석');
952
+
953
+ if (failedSections.length) {
954
+ access.style.display = 'block';
955
+ access.textContent = summaryRes.status === 403
956
+ ? '관리자 권한이 없습니다. 채팅 화면에서 관리자 계정으로 로그인한 뒤 다시 열어주세요.'
957
+ : `일부 섹션을 불러오지 못했습니다: ${failedSections.join(', ')}`;
958
+ }
959
+ } catch (e) {
960
+ access.style.display = 'block';
961
+ access.textContent = !navigator.onLine
962
+ ? '네트워크 연결을 확인해 주세요.'
963
+ : (e.message || '대시보드를 불러오지 못했습니다.');
964
+ }
965
+ }
966
+
967
+ async function saveVpc() {
968
+ const payload = {
969
+ provider: document.getElementById('vpc-provider').value.trim(),
970
+ region: document.getElementById('vpc-region').value.trim(),
971
+ cidr_block: document.getElementById('vpc-cidr').value.trim(),
972
+ endpoint: document.getElementById('vpc-endpoint').value.trim(),
973
+ vpn_status: document.getElementById('vpc-vpn').value.trim(),
974
+ peering_status: document.getElementById('vpc-peering').value.trim(),
975
+ private_subnets: document.getElementById('vpc-subnets').value.split(',').map(v => v.trim()).filter(Boolean),
976
+ notes: document.getElementById('vpc-notes').value.trim()
977
+ };
978
+ const status = document.getElementById('vpc-save-status');
979
+ status.textContent = '저장 중...';
980
+ try {
981
+ const res = await apiFetch('/admin/vpc', {
982
+ method: 'PATCH',
983
+ headers: adminHeaders(),
984
+ body: JSON.stringify(payload)
985
+ });
986
+ const data = await res.json().catch(() => ({}));
987
+ if (!res.ok) throw new Error(data.detail || '저장 실패');
988
+ fillVpcForm(data);
989
+ status.textContent = '저장되었습니다.';
990
+ await loadDashboard();
991
+ } catch (e) {
992
+ status.textContent = e.message || '저장 실패';
993
+ }
994
+ }
995
+
996
+ async function logout() {
997
+ try {
998
+ await apiFetch('/logout', { method: 'POST' });
999
+ } catch (e) { }
1000
+ localStorage.removeItem('ltcai_user_email');
1001
+ localStorage.removeItem('ltcai_user_nickname');
1002
+ localStorage.removeItem('ltcai_is_admin');
1003
+ window.location.href = '/';
1004
+ }
1005
+
1006
+ document.getElementById('refresh-btn').addEventListener('click', loadDashboard);
1007
+ document.getElementById('save-vpc-btn').addEventListener('click', saveVpc);
1008
+ document.getElementById('logout-btn').addEventListener('click', logout);
1009
+ loadDashboard();
1010
+ </script>
1011
+ </body>
1012
+
1013
+ </html>