ltcai 0.1.28 → 0.1.29
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/README.md +8 -3
- package/auto_setup.py +605 -0
- package/docs/CHANGELOG.md +30 -0
- package/kg_schema.py +723 -0
- package/package.json +4 -1
- package/server.py +727 -42
- package/static/account.html +5 -616
- package/static/admin.html +236 -1371
- package/static/chat.html +204 -7146
- package/static/graph.html +15 -1436
- package/static/lattice-reference.css +6557 -71
- package/static/scripts/account.js +230 -0
- package/static/scripts/admin.js +1198 -0
- package/static/scripts/chat.js +4634 -0
- package/static/scripts/graph.js +1059 -0
- package/static/sw.js +11 -1
package/static/admin.html
CHANGED
|
@@ -6,595 +6,14 @@
|
|
|
6
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
|
7
7
|
<title>Lattice AI Admin</title>
|
|
8
8
|
<link rel="manifest" href="/manifest.json">
|
|
9
|
-
<meta name="theme-color" content="#
|
|
9
|
+
<meta name="theme-color" content="#f3ecff">
|
|
10
10
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
11
11
|
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
|
12
12
|
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png">
|
|
13
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
14
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
15
|
+
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap">
|
|
13
16
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
|
|
14
|
-
<style>
|
|
15
|
-
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
|
|
16
|
-
|
|
17
|
-
:root {
|
|
18
|
-
--bg: #282a36;
|
|
19
|
-
--panel: #222431;
|
|
20
|
-
--panel-2: #2f3142;
|
|
21
|
-
--panel-3: #3a3d52;
|
|
22
|
-
--text: #f7f7f2;
|
|
23
|
-
--muted: #c4c8d8;
|
|
24
|
-
--faint: #8d93ab;
|
|
25
|
-
--accent: #a77cff;
|
|
26
|
-
--accent-2: #20b8aa;
|
|
27
|
-
--accent-3: #f1c86d;
|
|
28
|
-
--danger: #e47a73;
|
|
29
|
-
--border: rgba(218,225,255,0.13);
|
|
30
|
-
--border-strong: rgba(218,225,255,0.23);
|
|
31
|
-
--shadow: 0 24px 70px rgba(5,7,12,0.36);
|
|
32
|
-
--radius: 8px;
|
|
33
|
-
--radius-sm: 8px;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
* { box-sizing: border-box; }
|
|
37
|
-
html, body { height: 100%; }
|
|
38
|
-
body {
|
|
39
|
-
margin: 0;
|
|
40
|
-
font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;
|
|
41
|
-
color: var(--text);
|
|
42
|
-
background:
|
|
43
|
-
radial-gradient(circle at 52% 28%, rgba(167,124,255,0.13), transparent 34%),
|
|
44
|
-
radial-gradient(circle at 84% 78%, rgba(32,184,170,0.08), transparent 28%),
|
|
45
|
-
linear-gradient(135deg, #292b38 0%, #242632 52%, #2f3040 100%);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
body::before {
|
|
49
|
-
content: "";
|
|
50
|
-
position: fixed;
|
|
51
|
-
inset: 0;
|
|
52
|
-
pointer-events: none;
|
|
53
|
-
background:
|
|
54
|
-
radial-gradient(circle, rgba(247,247,242,0.58) 1px, transparent 1.8px),
|
|
55
|
-
linear-gradient(rgba(157,177,255,0.12) 1px, transparent 1px),
|
|
56
|
-
linear-gradient(90deg, rgba(157,177,255,0.08) 1px, transparent 1px);
|
|
57
|
-
background-size: 82px 82px, 46px 46px, 46px 46px;
|
|
58
|
-
background-position: 16px 18px, 0 0, 0 0;
|
|
59
|
-
mask-image: linear-gradient(180deg, rgba(0,0,0,0.4), rgba(0,0,0,0.08));
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
.page {
|
|
63
|
-
position: relative;
|
|
64
|
-
z-index: 1;
|
|
65
|
-
min-height: 100%;
|
|
66
|
-
display: flex;
|
|
67
|
-
flex-direction: column;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
.topbar {
|
|
71
|
-
display: flex;
|
|
72
|
-
align-items: center;
|
|
73
|
-
justify-content: space-between;
|
|
74
|
-
gap: 16px;
|
|
75
|
-
padding: 22px 28px;
|
|
76
|
-
border-bottom: 1px solid rgba(243,241,232,0.08);
|
|
77
|
-
background: rgba(33,35,48,0.84);
|
|
78
|
-
backdrop-filter: blur(20px);
|
|
79
|
-
position: sticky;
|
|
80
|
-
top: 0;
|
|
81
|
-
z-index: 2;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
.brand {
|
|
85
|
-
display: flex;
|
|
86
|
-
align-items: center;
|
|
87
|
-
gap: 12px;
|
|
88
|
-
min-width: 0;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
.brand-mark {
|
|
92
|
-
width: 42px;
|
|
93
|
-
height: 42px;
|
|
94
|
-
border-radius: 12px;
|
|
95
|
-
background: radial-gradient(circle at 35% 30%, #ffffff 0 16%, #a77cff 17% 62%, #5b6cff 100%);
|
|
96
|
-
color: #171926;
|
|
97
|
-
display: grid;
|
|
98
|
-
place-items: center;
|
|
99
|
-
font-size: 20px;
|
|
100
|
-
font-weight: 800;
|
|
101
|
-
box-shadow: 0 12px 30px rgba(0,0,0,0.25);
|
|
102
|
-
flex: 0 0 auto;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
.brand h1 {
|
|
106
|
-
margin: 0;
|
|
107
|
-
font-size: 18px;
|
|
108
|
-
line-height: 1.15;
|
|
109
|
-
letter-spacing: 0;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
.brand p {
|
|
113
|
-
margin: 4px 0 0;
|
|
114
|
-
color: var(--muted);
|
|
115
|
-
font-size: 12px;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
.top-actions {
|
|
119
|
-
display: flex;
|
|
120
|
-
gap: 8px;
|
|
121
|
-
flex-wrap: wrap;
|
|
122
|
-
justify-content: flex-end;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
.btn {
|
|
126
|
-
border: 1px solid var(--border);
|
|
127
|
-
background: rgba(255,255,255,0.03);
|
|
128
|
-
color: var(--text);
|
|
129
|
-
border-radius: 8px;
|
|
130
|
-
padding: 10px 14px;
|
|
131
|
-
font: inherit;
|
|
132
|
-
font-size: 13px;
|
|
133
|
-
font-weight: 700;
|
|
134
|
-
cursor: pointer;
|
|
135
|
-
display: inline-flex;
|
|
136
|
-
align-items: center;
|
|
137
|
-
gap: 8px;
|
|
138
|
-
transition: all .15s ease;
|
|
139
|
-
text-decoration: none;
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
.btn:hover {
|
|
143
|
-
border-color: rgba(167,124,255,0.32);
|
|
144
|
-
background: rgba(167,124,255,0.10);
|
|
145
|
-
color: var(--accent);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
.btn.primary {
|
|
149
|
-
background: rgba(167,124,255,0.12);
|
|
150
|
-
border-color: rgba(167,124,255,0.28);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
.btn.danger:hover {
|
|
154
|
-
background: rgba(228,122,115,0.08);
|
|
155
|
-
border-color: rgba(228,122,115,0.22);
|
|
156
|
-
color: #ffbdb8;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
.content {
|
|
160
|
-
width: min(1440px, calc(100vw - 32px));
|
|
161
|
-
margin: 0 auto;
|
|
162
|
-
padding: 22px 0 28px;
|
|
163
|
-
display: flex;
|
|
164
|
-
flex-direction: column;
|
|
165
|
-
gap: 18px;
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
.hero {
|
|
169
|
-
border: 1px solid var(--border);
|
|
170
|
-
border-radius: calc(var(--radius) + 2px);
|
|
171
|
-
background:
|
|
172
|
-
linear-gradient(135deg, rgba(167,124,255,0.12), rgba(32,184,170,0.06)),
|
|
173
|
-
rgba(34,36,49,0.88);
|
|
174
|
-
box-shadow: var(--shadow);
|
|
175
|
-
padding: 20px 22px;
|
|
176
|
-
display: flex;
|
|
177
|
-
align-items: center;
|
|
178
|
-
justify-content: space-between;
|
|
179
|
-
gap: 14px;
|
|
180
|
-
flex-wrap: wrap;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
.hero h2 {
|
|
184
|
-
margin: 0 0 6px;
|
|
185
|
-
font-size: 26px;
|
|
186
|
-
line-height: 1.15;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
.hero p {
|
|
190
|
-
margin: 0;
|
|
191
|
-
color: var(--muted);
|
|
192
|
-
font-size: 13px;
|
|
193
|
-
line-height: 1.5;
|
|
194
|
-
max-width: 760px;
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
.session-card {
|
|
198
|
-
border: 1px solid rgba(167,124,255,0.2);
|
|
199
|
-
background: rgba(167,124,255,0.08);
|
|
200
|
-
color: var(--text);
|
|
201
|
-
border-radius: 12px;
|
|
202
|
-
padding: 12px 14px;
|
|
203
|
-
min-width: 280px;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
.session-card .label {
|
|
207
|
-
font-size: 11px;
|
|
208
|
-
text-transform: uppercase;
|
|
209
|
-
letter-spacing: 0.08em;
|
|
210
|
-
color: var(--faint);
|
|
211
|
-
margin-bottom: 6px;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
.session-card .value {
|
|
215
|
-
font-size: 13px;
|
|
216
|
-
font-weight: 700;
|
|
217
|
-
word-break: break-all;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
.notice {
|
|
221
|
-
border: 1px solid rgba(216,165,74,0.2);
|
|
222
|
-
background: rgba(216,165,74,0.08);
|
|
223
|
-
color: #f4dfb3;
|
|
224
|
-
border-radius: 12px;
|
|
225
|
-
padding: 12px 14px;
|
|
226
|
-
font-size: 13px;
|
|
227
|
-
line-height: 1.5;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
.summary-grid {
|
|
231
|
-
display: grid;
|
|
232
|
-
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
233
|
-
gap: 12px;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
.summary-card {
|
|
237
|
-
border: 1px solid var(--border);
|
|
238
|
-
border-radius: var(--radius);
|
|
239
|
-
background: rgba(34,36,49,0.9);
|
|
240
|
-
box-shadow: var(--shadow);
|
|
241
|
-
padding: 16px 16px 15px;
|
|
242
|
-
min-width: 0;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
.summary-card .label {
|
|
246
|
-
color: var(--faint);
|
|
247
|
-
font-size: 11px;
|
|
248
|
-
font-weight: 700;
|
|
249
|
-
letter-spacing: 0.08em;
|
|
250
|
-
text-transform: uppercase;
|
|
251
|
-
margin-bottom: 8px;
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
.summary-card .value {
|
|
255
|
-
font-size: 28px;
|
|
256
|
-
font-weight: 800;
|
|
257
|
-
line-height: 1;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
.summary-card .meta {
|
|
261
|
-
margin-top: 8px;
|
|
262
|
-
color: var(--muted);
|
|
263
|
-
font-size: 12px;
|
|
264
|
-
line-height: 1.45;
|
|
265
|
-
min-height: 2.7em;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
.panel-grid {
|
|
269
|
-
display: grid;
|
|
270
|
-
grid-template-columns: 1.1fr 0.9fr;
|
|
271
|
-
gap: 18px;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
.panel {
|
|
275
|
-
border: 1px solid var(--border);
|
|
276
|
-
border-radius: var(--radius);
|
|
277
|
-
background: rgba(34,36,49,0.9);
|
|
278
|
-
box-shadow: var(--shadow);
|
|
279
|
-
overflow: hidden;
|
|
280
|
-
min-width: 0;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
.panel-header {
|
|
284
|
-
padding: 16px 18px;
|
|
285
|
-
border-bottom: 1px solid var(--border);
|
|
286
|
-
display: flex;
|
|
287
|
-
align-items: center;
|
|
288
|
-
justify-content: space-between;
|
|
289
|
-
gap: 12px;
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
.panel-header h3 {
|
|
293
|
-
margin: 0;
|
|
294
|
-
font-size: 15px;
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
.panel-header p {
|
|
298
|
-
margin: 4px 0 0;
|
|
299
|
-
color: var(--muted);
|
|
300
|
-
font-size: 12px;
|
|
301
|
-
line-height: 1.45;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
.panel-body {
|
|
305
|
-
padding: 16px 18px 18px;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
.form-grid {
|
|
309
|
-
display: grid;
|
|
310
|
-
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
311
|
-
gap: 10px;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
.field {
|
|
315
|
-
display: flex;
|
|
316
|
-
flex-direction: column;
|
|
317
|
-
gap: 6px;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
.field.full { grid-column: 1 / -1; }
|
|
321
|
-
|
|
322
|
-
label {
|
|
323
|
-
color: var(--muted);
|
|
324
|
-
font-size: 12px;
|
|
325
|
-
font-weight: 700;
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
input, textarea {
|
|
329
|
-
width: 100%;
|
|
330
|
-
border: 1px solid var(--border);
|
|
331
|
-
background: rgba(20,22,31,0.72);
|
|
332
|
-
color: var(--text);
|
|
333
|
-
border-radius: 8px;
|
|
334
|
-
padding: 10px 12px;
|
|
335
|
-
font: inherit;
|
|
336
|
-
font-size: 13px;
|
|
337
|
-
outline: none;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
textarea {
|
|
341
|
-
min-height: 80px;
|
|
342
|
-
resize: vertical;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
input:focus, textarea:focus {
|
|
346
|
-
border-color: rgba(167,124,255,0.45);
|
|
347
|
-
box-shadow: 0 0 0 3px rgba(167,124,255,0.08);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
.toolbar {
|
|
351
|
-
display: flex;
|
|
352
|
-
align-items: center;
|
|
353
|
-
justify-content: space-between;
|
|
354
|
-
gap: 12px;
|
|
355
|
-
margin-top: 12px;
|
|
356
|
-
flex-wrap: wrap;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
.status-copy {
|
|
360
|
-
color: var(--muted);
|
|
361
|
-
font-size: 12px;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
.tag-row {
|
|
365
|
-
display: flex;
|
|
366
|
-
flex-wrap: wrap;
|
|
367
|
-
gap: 8px;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
.tag {
|
|
371
|
-
display: inline-flex;
|
|
372
|
-
align-items: center;
|
|
373
|
-
gap: 6px;
|
|
374
|
-
padding: 5px 9px;
|
|
375
|
-
border-radius: 999px;
|
|
376
|
-
border: 1px solid var(--border);
|
|
377
|
-
background: rgba(255,255,255,0.03);
|
|
378
|
-
color: var(--muted);
|
|
379
|
-
font-size: 12px;
|
|
380
|
-
font-weight: 600;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
.tag.high { border-color: rgba(228,122,115,0.34); color: #ffbdb8; }
|
|
384
|
-
.tag.medium { border-color: rgba(216,165,74,0.34); color: #f1d39e; }
|
|
385
|
-
.tag.low { border-color: rgba(32,184,170,0.34); color: #8be9df; }
|
|
386
|
-
|
|
387
|
-
.two-col {
|
|
388
|
-
display: grid;
|
|
389
|
-
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
390
|
-
gap: 12px;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
.audit-grid {
|
|
394
|
-
display: grid;
|
|
395
|
-
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
396
|
-
gap: 10px;
|
|
397
|
-
margin-bottom: 14px;
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
.audit-metric {
|
|
401
|
-
border: 1px solid var(--border);
|
|
402
|
-
border-radius: 12px;
|
|
403
|
-
background: rgba(255,255,255,0.025);
|
|
404
|
-
padding: 12px;
|
|
405
|
-
min-width: 0;
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
.audit-metric .label {
|
|
409
|
-
color: var(--faint);
|
|
410
|
-
font-size: 11px;
|
|
411
|
-
font-weight: 800;
|
|
412
|
-
letter-spacing: 0.06em;
|
|
413
|
-
text-transform: uppercase;
|
|
414
|
-
margin-bottom: 8px;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
.audit-metric .value {
|
|
418
|
-
font-size: 24px;
|
|
419
|
-
font-weight: 800;
|
|
420
|
-
line-height: 1;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
.audit-metric .meta {
|
|
424
|
-
color: var(--muted);
|
|
425
|
-
font-size: 12px;
|
|
426
|
-
margin-top: 7px;
|
|
427
|
-
line-height: 1.4;
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
.subpanel {
|
|
431
|
-
border: 1px solid var(--border);
|
|
432
|
-
border-radius: 12px;
|
|
433
|
-
background: rgba(255,255,255,0.02);
|
|
434
|
-
padding: 14px;
|
|
435
|
-
min-width: 0;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
.subpanel h4 {
|
|
439
|
-
margin: 0 0 10px;
|
|
440
|
-
font-size: 13px;
|
|
441
|
-
display: flex;
|
|
442
|
-
align-items: center;
|
|
443
|
-
gap: 7px;
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
.list {
|
|
447
|
-
display: flex;
|
|
448
|
-
flex-direction: column;
|
|
449
|
-
gap: 8px;
|
|
450
|
-
max-height: 320px;
|
|
451
|
-
overflow: auto;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
.item {
|
|
455
|
-
border: 1px solid var(--border);
|
|
456
|
-
border-radius: 10px;
|
|
457
|
-
background: rgba(0,0,0,0.12);
|
|
458
|
-
padding: 10px 11px;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
.item-meta {
|
|
462
|
-
display: flex;
|
|
463
|
-
flex-wrap: wrap;
|
|
464
|
-
gap: 6px;
|
|
465
|
-
margin-bottom: 6px;
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
.preview {
|
|
469
|
-
color: var(--muted);
|
|
470
|
-
font-size: 12px;
|
|
471
|
-
line-height: 1.5;
|
|
472
|
-
word-break: break-word;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
.table-wrap {
|
|
476
|
-
overflow: auto;
|
|
477
|
-
border: 1px solid var(--border);
|
|
478
|
-
border-radius: 12px;
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
table {
|
|
482
|
-
width: 100%;
|
|
483
|
-
border-collapse: collapse;
|
|
484
|
-
min-width: 820px;
|
|
485
|
-
background: rgba(255,255,255,0.015);
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
th, td {
|
|
489
|
-
padding: 11px 10px;
|
|
490
|
-
border-bottom: 1px solid var(--border);
|
|
491
|
-
text-align: left;
|
|
492
|
-
vertical-align: middle;
|
|
493
|
-
font-size: 13px;
|
|
494
|
-
}
|
|
495
|
-
|
|
496
|
-
th {
|
|
497
|
-
color: var(--muted);
|
|
498
|
-
background: rgba(255,255,255,0.03);
|
|
499
|
-
font-size: 12px;
|
|
500
|
-
letter-spacing: 0.02em;
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
td .actions {
|
|
504
|
-
display: flex;
|
|
505
|
-
gap: 6px;
|
|
506
|
-
flex-wrap: wrap;
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
.table-btn {
|
|
510
|
-
border: 1px solid var(--border);
|
|
511
|
-
background: rgba(255,255,255,0.03);
|
|
512
|
-
color: var(--text);
|
|
513
|
-
border-radius: 8px;
|
|
514
|
-
padding: 7px 9px;
|
|
515
|
-
cursor: pointer;
|
|
516
|
-
font-size: 12px;
|
|
517
|
-
font-weight: 600;
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
.table-btn:hover {
|
|
521
|
-
border-color: rgba(66,211,154,0.24);
|
|
522
|
-
background: rgba(66,211,154,0.08);
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
.table-btn.danger:hover {
|
|
526
|
-
border-color: rgba(228,122,115,0.24);
|
|
527
|
-
background: rgba(228,122,115,0.08);
|
|
528
|
-
color: #ffbdb8;
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
.role {
|
|
532
|
-
display: inline-flex;
|
|
533
|
-
align-items: center;
|
|
534
|
-
padding: 4px 8px;
|
|
535
|
-
border-radius: 999px;
|
|
536
|
-
border: 1px solid var(--border);
|
|
537
|
-
color: var(--muted);
|
|
538
|
-
font-size: 12px;
|
|
539
|
-
font-weight: 700;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
.footer-space { height: 8px; }
|
|
543
|
-
|
|
544
|
-
.muted {
|
|
545
|
-
color: var(--muted);
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
.error {
|
|
549
|
-
color: #ffbdb8;
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
@media (max-width: 1100px) {
|
|
553
|
-
.summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
554
|
-
.audit-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
555
|
-
.panel-grid { grid-template-columns: 1fr; }
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
@media (max-width: 760px) {
|
|
559
|
-
.topbar, .content { width: auto; }
|
|
560
|
-
.topbar {
|
|
561
|
-
padding: 16px;
|
|
562
|
-
align-items: flex-start;
|
|
563
|
-
flex-direction: column;
|
|
564
|
-
}
|
|
565
|
-
.content { padding: 16px; gap: 14px; }
|
|
566
|
-
.hero h2 { font-size: 22px; }
|
|
567
|
-
.summary-grid { grid-template-columns: 1fr; }
|
|
568
|
-
.audit-grid { grid-template-columns: 1fr; }
|
|
569
|
-
.two-col, .form-grid { grid-template-columns: 1fr; }
|
|
570
|
-
.field.full { grid-column: auto; }
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
.lang-picker { position: relative; }
|
|
574
|
-
.lang-picker-menu {
|
|
575
|
-
display: none;
|
|
576
|
-
position: absolute;
|
|
577
|
-
top: calc(100% + 6px);
|
|
578
|
-
right: 0;
|
|
579
|
-
background: #222431;
|
|
580
|
-
border: 1px solid rgba(218,225,255,0.14);
|
|
581
|
-
border-radius: 8px;
|
|
582
|
-
padding: 4px;
|
|
583
|
-
min-width: 130px;
|
|
584
|
-
z-index: 100;
|
|
585
|
-
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
|
586
|
-
}
|
|
587
|
-
.lang-picker-menu.open { display: block; }
|
|
588
|
-
.lang-option {
|
|
589
|
-
padding: 7px 10px;
|
|
590
|
-
border-radius: 7px;
|
|
591
|
-
cursor: pointer;
|
|
592
|
-
font-size: 13px;
|
|
593
|
-
color: var(--muted);
|
|
594
|
-
}
|
|
595
|
-
.lang-option:hover { background: rgba(167,124,255,0.10); color: var(--text); }
|
|
596
|
-
.lang-option.active { color: var(--accent); font-weight: 600; }
|
|
597
|
-
</style>
|
|
598
17
|
<link rel="stylesheet" href="/static/lattice-reference.css">
|
|
599
18
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
|
600
19
|
</head>
|
|
@@ -603,17 +22,17 @@
|
|
|
603
22
|
<aside class="reference-rail admin-rail">
|
|
604
23
|
<div class="rail-brand"><i class="ti ti-shield-lock"></i><strong>LATTICE AI</strong><span>Administrator</span></div>
|
|
605
24
|
<nav>
|
|
606
|
-
<a class="active" href="
|
|
607
|
-
<a href="
|
|
608
|
-
<a href="
|
|
609
|
-
<a href="
|
|
610
|
-
<a href="
|
|
611
|
-
<a href="
|
|
612
|
-
<a href="/chat"><i class="ti ti-message-circle"></i>
|
|
25
|
+
<a class="active" href="#dashboard" data-admin-nav="dashboard"><i class="ti ti-layout-dashboard"></i> <span data-i18n="nav_dashboard">대시보드</span></a>
|
|
26
|
+
<a href="#users" data-admin-nav="users"><i class="ti ti-users"></i> <span data-i18n="nav_users">사용자 관리</span></a>
|
|
27
|
+
<a href="#permissions" data-admin-nav="permissions"><i class="ti ti-key"></i> <span data-i18n="nav_permissions">권한 관리</span></a>
|
|
28
|
+
<a href="#sso" data-admin-nav="sso"><i class="ti ti-lock-access"></i> <span data-i18n="nav_sso">SSO 관리</span></a>
|
|
29
|
+
<a href="#security" data-admin-nav="security"><i class="ti ti-shield-check"></i> <span data-i18n="nav_security">보안 모니터링</span></a>
|
|
30
|
+
<a href="#audit" data-admin-nav="audit"><i class="ti ti-report-search"></i> <span data-i18n="nav_audit">감사 로그</span></a>
|
|
31
|
+
<a href="/chat"><i class="ti ti-message-circle"></i> <span data-i18n="nav_chat">채팅으로</span></a>
|
|
613
32
|
</nav>
|
|
614
33
|
<div class="rail-project">
|
|
615
34
|
<strong>admin@lattice.ai</strong>
|
|
616
|
-
<span>시스템 관리자</span>
|
|
35
|
+
<span data-i18n="system_admin">시스템 관리자</span>
|
|
617
36
|
</div>
|
|
618
37
|
</aside>
|
|
619
38
|
<div class="page">
|
|
@@ -640,838 +59,284 @@
|
|
|
640
59
|
</header>
|
|
641
60
|
|
|
642
61
|
<main class="content">
|
|
643
|
-
<section class="hero">
|
|
644
|
-
<div>
|
|
645
|
-
<h2 data-i18n="hero_title">관리자 대시보드</h2>
|
|
646
|
-
<p data-i18n="hero_desc">사용자, 민감도 로그, Private VPC, 서버 상태를 한 화면에서 관리합니다. 채팅 화면과 분리되어 있고, 필요할 때만 따로 열어볼 수 있습니다.</p>
|
|
647
|
-
</div>
|
|
648
|
-
<div class="session-card">
|
|
649
|
-
<div class="label">Current Session</div>
|
|
650
|
-
<div class="value" id="session-value" data-i18n="checking_session">세션 확인 중...</div>
|
|
651
|
-
</div>
|
|
652
|
-
</section>
|
|
653
|
-
|
|
654
62
|
<div id="access-notice" class="notice" style="display:none"></div>
|
|
655
63
|
|
|
656
|
-
<section class="
|
|
657
|
-
<
|
|
658
|
-
<div class="label">Total Users</div>
|
|
659
|
-
<div class="value" id="total-users">-</div>
|
|
660
|
-
<div class="meta" id="total-users-meta">사용자 계정 수</div>
|
|
661
|
-
</div>
|
|
662
|
-
<div class="summary-card">
|
|
663
|
-
<div class="label">Active Messages</div>
|
|
664
|
-
<div class="value" id="total-messages">-</div>
|
|
665
|
-
<div class="meta" id="total-messages-meta">최근 대화 활동</div>
|
|
666
|
-
</div>
|
|
667
|
-
<div class="summary-card">
|
|
668
|
-
<div class="label">Current Model</div>
|
|
669
|
-
<div class="value" id="current-model">-</div>
|
|
670
|
-
<div class="meta" id="current-model-meta">로드된 모델 수: -</div>
|
|
671
|
-
</div>
|
|
672
|
-
<div class="summary-card">
|
|
673
|
-
<div class="label">VPC Status</div>
|
|
674
|
-
<div class="value" id="vpc-status">-</div>
|
|
675
|
-
<div class="meta" id="vpc-status-meta">Private network state</div>
|
|
676
|
-
</div>
|
|
677
|
-
</section>
|
|
678
|
-
|
|
679
|
-
<section class="panel" style="margin-bottom:0;">
|
|
680
|
-
<div class="panel-header">
|
|
64
|
+
<section class="admin-view active" id="admin-view-dashboard" data-admin-view="dashboard">
|
|
65
|
+
<section class="hero">
|
|
681
66
|
<div>
|
|
682
|
-
<
|
|
683
|
-
<p data-i18n="
|
|
67
|
+
<h2 data-i18n="hero_title">관리자 대시보드</h2>
|
|
68
|
+
<p data-i18n="hero_desc">운영 현황, 세션, 모델, VPC 상태를 요약해서 보여줍니다.</p>
|
|
684
69
|
</div>
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
70
|
+
<div class="session-card">
|
|
71
|
+
<div class="label" data-i18n="current_session">현재 세션</div>
|
|
72
|
+
<div class="value" id="session-value" data-i18n="checking_session">세션 확인 중...</div>
|
|
73
|
+
</div>
|
|
74
|
+
</section>
|
|
690
75
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
<
|
|
695
|
-
<
|
|
76
|
+
<section class="summary-grid" id="summary-grid">
|
|
77
|
+
<div class="summary-card">
|
|
78
|
+
<div class="label" data-i18n="card_total_users">전체 사용자</div>
|
|
79
|
+
<div class="value" id="total-users">-</div>
|
|
80
|
+
<div class="meta" id="total-users-meta">사용자 계정 수</div>
|
|
696
81
|
</div>
|
|
697
|
-
<div class="
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
82
|
+
<div class="summary-card">
|
|
83
|
+
<div class="label" data-i18n="card_messages">활성 메시지</div>
|
|
84
|
+
<div class="value" id="total-messages">-</div>
|
|
85
|
+
<div class="meta" id="total-messages-meta">최근 대화 활동</div>
|
|
86
|
+
</div>
|
|
87
|
+
<div class="summary-card">
|
|
88
|
+
<div class="label" data-i18n="card_model">현재 모델</div>
|
|
89
|
+
<div class="value" id="current-model">-</div>
|
|
90
|
+
<div class="meta" id="current-model-meta">로드된 모델 수: -</div>
|
|
91
|
+
</div>
|
|
92
|
+
<div class="summary-card">
|
|
93
|
+
<div class="label" data-i18n="card_vpc">VPC 상태</div>
|
|
94
|
+
<div class="value" id="vpc-status">-</div>
|
|
95
|
+
<div class="meta" id="vpc-status-meta">Private network state</div>
|
|
96
|
+
</div>
|
|
97
|
+
</section>
|
|
98
|
+
|
|
99
|
+
<section class="panel">
|
|
100
|
+
<div class="panel-header">
|
|
101
|
+
<div>
|
|
102
|
+
<h3 data-i18n="chart_title">메시지 활동 (최근 14일)</h3>
|
|
103
|
+
<p data-i18n="chart_desc">사용자 메시지와 AI 응답 수를 날짜별로 표시합니다.</p>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
<div class="panel-body chart-body">
|
|
107
|
+
<canvas id="activity-chart" height="80"></canvas>
|
|
108
|
+
</div>
|
|
109
|
+
</section>
|
|
110
|
+
|
|
111
|
+
<section class="panel-grid">
|
|
112
|
+
<article class="panel">
|
|
113
|
+
<div class="panel-header">
|
|
114
|
+
<div>
|
|
115
|
+
<h3>Private VPC</h3>
|
|
116
|
+
<p data-i18n="vpc_desc">네트워크 프로필과 운영 상태를 수정합니다.</p>
|
|
706
117
|
</div>
|
|
118
|
+
<span class="tag" id="admin-pill"><i class="ti ti-user-cog"></i> Admin</span>
|
|
707
119
|
</div>
|
|
708
|
-
<div class="
|
|
709
|
-
<
|
|
710
|
-
|
|
711
|
-
<div class="
|
|
120
|
+
<div class="panel-body">
|
|
121
|
+
<div class="form-grid">
|
|
122
|
+
<div class="field"><label for="vpc-provider">Provider</label><input id="vpc-provider" placeholder="AWS"></div>
|
|
123
|
+
<div class="field"><label for="vpc-region">Region</label><input id="vpc-region" placeholder="ap-northeast-2"></div>
|
|
124
|
+
<div class="field"><label for="vpc-cidr">CIDR Block</label><input id="vpc-cidr" placeholder="10.42.0.0/16"></div>
|
|
125
|
+
<div class="field"><label for="vpc-endpoint">Private Endpoint</label><input id="vpc-endpoint" placeholder="ltcai-private.local"></div>
|
|
126
|
+
<div class="field"><label for="vpc-vpn">VPN Status</label><input id="vpc-vpn" placeholder="standby"></div>
|
|
127
|
+
<div class="field"><label for="vpc-peering">Peering Status</label><input id="vpc-peering" placeholder="not_configured"></div>
|
|
128
|
+
<div class="field full"><label for="vpc-subnets">Private Subnets</label><input id="vpc-subnets" placeholder="10.42.10.0/24, 10.42.20.0/24"></div>
|
|
129
|
+
<div class="field full"><label for="vpc-notes">Notes</label><textarea id="vpc-notes" data-i18n-ph="vpc_notes_ph" placeholder="운영 메모"></textarea></div>
|
|
712
130
|
</div>
|
|
131
|
+
<div class="toolbar">
|
|
132
|
+
<div class="status-copy" id="vpc-save-status" data-i18n="vpc_loading">불러오는 중...</div>
|
|
133
|
+
<button class="btn primary" id="save-vpc-btn" type="button"><i class="ti ti-device-floppy"></i> <span data-i18n="vpc_save">저장</span></button>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</article>
|
|
137
|
+
|
|
138
|
+
<article class="panel">
|
|
139
|
+
<div class="panel-header">
|
|
140
|
+
<div>
|
|
141
|
+
<h3 data-i18n="current_session">현재 세션</h3>
|
|
142
|
+
<p data-i18n="session_desc">현재 로그인한 계정과 관리자 API 상태를 확인합니다.</p>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
<div class="panel-body">
|
|
146
|
+
<div class="tag-row" id="session-tags"></div>
|
|
147
|
+
<div class="footer-space"></div>
|
|
148
|
+
<div class="notice" id="session-help" data-i18n="session_help_fail">로그인 정보가 없으면 이 화면의 관리자 API를 사용할 수 없습니다.</div>
|
|
149
|
+
</div>
|
|
150
|
+
</article>
|
|
151
|
+
</section>
|
|
152
|
+
</section>
|
|
153
|
+
|
|
154
|
+
<section class="admin-view" id="admin-view-users" data-admin-view="users">
|
|
155
|
+
<section class="panel">
|
|
156
|
+
<div class="panel-header">
|
|
157
|
+
<div>
|
|
158
|
+
<h3 data-i18n="users_title">사용자 관리</h3>
|
|
159
|
+
<p data-i18n="users_desc">등록된 사용자와 활성/비활성 상태를 관리합니다.</p>
|
|
713
160
|
</div>
|
|
714
161
|
</div>
|
|
715
|
-
|
|
162
|
+
<div class="panel-body">
|
|
163
|
+
<div class="table-wrap" id="user-table-wrap">
|
|
164
|
+
<div class="preview" style="padding:14px" data-i18n="loading">불러오는 중...</div>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
</section>
|
|
168
|
+
|
|
169
|
+
<section class="panel">
|
|
170
|
+
<div class="panel-header">
|
|
171
|
+
<div>
|
|
172
|
+
<h3 data-i18n="invite_title">초대 링크</h3>
|
|
173
|
+
<p data-i18n="invite_desc">새 사용자를 초대할 링크를 확인하고 복사합니다.</p>
|
|
174
|
+
</div>
|
|
175
|
+
</div>
|
|
176
|
+
<div class="panel-body">
|
|
177
|
+
<div class="inline-control">
|
|
178
|
+
<input id="invite-link-input" type="text" readonly>
|
|
179
|
+
<button class="btn primary" id="copy-invite-btn" type="button"><i class="ti ti-copy"></i> <span data-i18n="btn_copy">복사</span></button>
|
|
180
|
+
</div>
|
|
181
|
+
<div id="invite-gate-info" class="status-copy"></div>
|
|
182
|
+
</div>
|
|
183
|
+
</section>
|
|
716
184
|
</section>
|
|
717
185
|
|
|
718
|
-
<section class="
|
|
719
|
-
<
|
|
186
|
+
<section class="admin-view" id="admin-view-permissions" data-admin-view="permissions">
|
|
187
|
+
<section class="panel">
|
|
720
188
|
<div class="panel-header">
|
|
721
189
|
<div>
|
|
722
|
-
<h3
|
|
723
|
-
<p data-i18n="
|
|
190
|
+
<h3 data-i18n="permissions_title">권한 관리</h3>
|
|
191
|
+
<p data-i18n="permissions_desc">사용자별 기본 모드, 고급 모드, 관리자 모드 권한을 확인합니다.</p>
|
|
724
192
|
</div>
|
|
725
|
-
|
|
193
|
+
</div>
|
|
194
|
+
<div class="panel-body">
|
|
195
|
+
<div class="table-wrap" id="permission-table-wrap">
|
|
196
|
+
<div class="preview" style="padding:14px" data-i18n="loading">불러오는 중...</div>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
</section>
|
|
200
|
+
</section>
|
|
201
|
+
|
|
202
|
+
<section class="admin-view" id="admin-view-sso" data-admin-view="sso">
|
|
203
|
+
<section class="panel">
|
|
204
|
+
<div class="panel-header">
|
|
205
|
+
<div>
|
|
206
|
+
<h3 data-i18n="sso_title">SSO 관리</h3>
|
|
207
|
+
<p data-i18n="sso_desc">Okta 또는 Microsoft Entra ID OIDC 설정을 저장하고 로그인 플로우에 연결합니다.</p>
|
|
208
|
+
</div>
|
|
209
|
+
<div class="tag-row" id="sso-status-tags"></div>
|
|
726
210
|
</div>
|
|
727
211
|
<div class="panel-body">
|
|
728
212
|
<div class="form-grid">
|
|
729
213
|
<div class="field">
|
|
730
|
-
<label for="
|
|
731
|
-
<
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
214
|
+
<label for="sso-provider-template" data-i18n="sso_provider_template">제공자 템플릿</label>
|
|
215
|
+
<select id="sso-provider-template">
|
|
216
|
+
<option value="okta">Okta</option>
|
|
217
|
+
<option value="entra">Microsoft Entra ID</option>
|
|
218
|
+
<option value="custom">Custom OIDC</option>
|
|
219
|
+
</select>
|
|
736
220
|
</div>
|
|
737
221
|
<div class="field">
|
|
738
|
-
<label for="
|
|
739
|
-
<input id="
|
|
222
|
+
<label for="sso-provider-name" data-i18n="sso_provider_name">제공자 이름</label>
|
|
223
|
+
<input id="sso-provider-name" placeholder="Okta">
|
|
740
224
|
</div>
|
|
741
|
-
<div class="field">
|
|
742
|
-
<label for="
|
|
743
|
-
<input id="
|
|
225
|
+
<div class="field full">
|
|
226
|
+
<label for="sso-discovery-url" data-i18n="sso_discovery_url">OIDC Discovery URL</label>
|
|
227
|
+
<input id="sso-discovery-url" placeholder="https://your-domain.okta.com/oauth2/default/.well-known/openid-configuration">
|
|
744
228
|
</div>
|
|
745
229
|
<div class="field">
|
|
746
|
-
<label for="
|
|
747
|
-
<input id="
|
|
230
|
+
<label for="sso-client-id" data-i18n="sso_client_id">Client ID</label>
|
|
231
|
+
<input id="sso-client-id" autocomplete="off">
|
|
748
232
|
</div>
|
|
749
233
|
<div class="field">
|
|
750
|
-
<label for="
|
|
751
|
-
<input id="
|
|
234
|
+
<label for="sso-client-secret" data-i18n="sso_client_secret">Client Secret</label>
|
|
235
|
+
<input id="sso-client-secret" type="password" autocomplete="new-password" data-i18n-ph="sso_secret_ph" placeholder="비워두면 기존 값을 유지합니다">
|
|
752
236
|
</div>
|
|
753
237
|
<div class="field full">
|
|
754
|
-
<label for="
|
|
755
|
-
<input id="
|
|
238
|
+
<label for="sso-redirect-uri" data-i18n="sso_redirect_uri">Redirect URI</label>
|
|
239
|
+
<input id="sso-redirect-uri" placeholder="http://localhost:4825/auth/sso/callback">
|
|
756
240
|
</div>
|
|
757
241
|
<div class="field full">
|
|
758
|
-
<label for="
|
|
759
|
-
<
|
|
242
|
+
<label for="sso-scopes" data-i18n="sso_scopes">Scopes</label>
|
|
243
|
+
<input id="sso-scopes" placeholder="openid email profile">
|
|
760
244
|
</div>
|
|
761
245
|
</div>
|
|
246
|
+
<div class="sso-template-help" id="sso-template-help"></div>
|
|
762
247
|
<div class="toolbar">
|
|
763
|
-
<div class="status-copy" id="
|
|
764
|
-
<
|
|
248
|
+
<div class="status-copy" id="sso-save-status" data-i18n="sso_loading">SSO 설정을 불러오는 중...</div>
|
|
249
|
+
<div class="button-row">
|
|
250
|
+
<button class="btn" id="test-sso-btn" type="button"><i class="ti ti-external-link"></i> <span data-i18n="sso_test">SSO 로그인 테스트</span></button>
|
|
251
|
+
<button class="btn primary" id="save-sso-btn" type="button"><i class="ti ti-device-floppy"></i> <span data-i18n="sso_save">SSO 설정 저장</span></button>
|
|
252
|
+
</div>
|
|
765
253
|
</div>
|
|
766
254
|
</div>
|
|
767
|
-
</
|
|
255
|
+
</section>
|
|
256
|
+
</section>
|
|
768
257
|
|
|
769
|
-
|
|
258
|
+
<section class="admin-view" id="admin-view-security" data-admin-view="security">
|
|
259
|
+
<section class="panel">
|
|
770
260
|
<div class="panel-header">
|
|
771
261
|
<div>
|
|
772
|
-
<h3
|
|
773
|
-
<p data-i18n="
|
|
262
|
+
<h3 data-i18n="sensitivity_title">보안 모니터링</h3>
|
|
263
|
+
<p data-i18n="sensitivity_desc">민감정보, 위험 필드, 준수 필드를 집중적으로 확인합니다.</p>
|
|
264
|
+
</div>
|
|
265
|
+
<div class="panel-tools">
|
|
266
|
+
<div class="export-control">
|
|
267
|
+
<button class="btn" id="security-export-toggle" type="button">
|
|
268
|
+
<i class="ti ti-download"></i>
|
|
269
|
+
<span data-i18n="security_export_toggle">보안 모니터링 로그 추출</span>
|
|
270
|
+
</button>
|
|
271
|
+
<div class="export-options" id="security-export-options">
|
|
272
|
+
<button class="table-btn" type="button" data-export-scope="security" data-export-format="txt" data-i18n="export_txt">TXT 추출</button>
|
|
273
|
+
<button class="table-btn" type="button" data-export-scope="security" data-export-format="excel" data-i18n="export_excel">Excel 추출</button>
|
|
274
|
+
<button class="table-btn" type="button" data-export-scope="security" data-export-format="csv" data-i18n="export_csv">CSV 추출</button>
|
|
275
|
+
</div>
|
|
276
|
+
</div>
|
|
277
|
+
<div class="tag-row" id="sensitivity-summary"></div>
|
|
774
278
|
</div>
|
|
775
279
|
</div>
|
|
776
280
|
<div class="panel-body">
|
|
777
|
-
<div class="
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
281
|
+
<div class="two-col">
|
|
282
|
+
<div class="subpanel">
|
|
283
|
+
<h4><i class="ti ti-alert-triangle"></i> <span data-i18n="risk_fields">위험 필드</span></h4>
|
|
284
|
+
<div class="list" id="risk-fields"></div>
|
|
285
|
+
</div>
|
|
286
|
+
<div class="subpanel">
|
|
287
|
+
<h4><i class="ti ti-shield-check"></i> <span data-i18n="compliance_fields">준수 필드</span></h4>
|
|
288
|
+
<div class="list" id="compliance-fields"></div>
|
|
289
|
+
</div>
|
|
781
290
|
</div>
|
|
782
291
|
</div>
|
|
783
|
-
</
|
|
292
|
+
</section>
|
|
784
293
|
</section>
|
|
785
294
|
|
|
786
|
-
<section class="
|
|
787
|
-
<
|
|
788
|
-
<div>
|
|
789
|
-
<
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
<div class="tag-row" id="sensitivity-summary"></div>
|
|
793
|
-
</div>
|
|
794
|
-
<div class="panel-body">
|
|
795
|
-
<div class="two-col">
|
|
796
|
-
<div class="subpanel">
|
|
797
|
-
<h4><i class="ti ti-alert-triangle"></i> Risk Fields</h4>
|
|
798
|
-
<div class="list" id="risk-fields"></div>
|
|
295
|
+
<section class="admin-view" id="admin-view-audit" data-admin-view="audit">
|
|
296
|
+
<section class="panel">
|
|
297
|
+
<div class="panel-header">
|
|
298
|
+
<div>
|
|
299
|
+
<h3 data-i18n="audit_title">감사 로그</h3>
|
|
300
|
+
<p data-i18n="audit_desc">AI 사용량, 업로드, 민감정보 감지, 삭제/정리 이벤트를 보존합니다.</p>
|
|
799
301
|
</div>
|
|
800
|
-
<div class="
|
|
801
|
-
<
|
|
802
|
-
|
|
302
|
+
<div class="panel-tools">
|
|
303
|
+
<div class="export-control">
|
|
304
|
+
<button class="btn" id="audit-export-toggle" type="button">
|
|
305
|
+
<i class="ti ti-download"></i>
|
|
306
|
+
<span data-i18n="audit_export_toggle">감사 로그 추출</span>
|
|
307
|
+
</button>
|
|
308
|
+
<div class="export-options" id="audit-export-options">
|
|
309
|
+
<button class="table-btn" type="button" data-export-scope="audit" data-export-format="txt" data-i18n="export_txt">TXT 추출</button>
|
|
310
|
+
<button class="table-btn" type="button" data-export-scope="audit" data-export-format="excel" data-i18n="export_excel">Excel 추출</button>
|
|
311
|
+
<button class="table-btn" type="button" data-export-scope="audit" data-export-format="csv" data-i18n="export_csv">CSV 추출</button>
|
|
312
|
+
</div>
|
|
313
|
+
</div>
|
|
314
|
+
<div class="tag-row" id="audit-summary-tags"></div>
|
|
803
315
|
</div>
|
|
804
316
|
</div>
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
<span data-i18n="btn_copy">복사</span>
|
|
822
|
-
</button>
|
|
823
|
-
</div>
|
|
824
|
-
<div id="invite-gate-info" style="font-size:12px;color:#8d93ab;margin-top:8px;"></div>
|
|
825
|
-
</div>
|
|
826
|
-
</section>
|
|
827
|
-
|
|
828
|
-
<section class="panel">
|
|
829
|
-
<div class="panel-header">
|
|
830
|
-
<div>
|
|
831
|
-
<h3 data-i18n="users_title">사용자 관리</h3>
|
|
832
|
-
<p data-i18n="users_desc">역할 변경, 비활성화, 삭제를 처리합니다.</p>
|
|
833
|
-
</div>
|
|
834
|
-
</div>
|
|
835
|
-
<div class="panel-body">
|
|
836
|
-
<div class="table-wrap" id="user-table-wrap">
|
|
837
|
-
<div class="preview" style="padding: 14px;" data-i18n="loading">불러오는 중...</div>
|
|
317
|
+
<div class="panel-body">
|
|
318
|
+
<div class="audit-grid" id="audit-metrics"></div>
|
|
319
|
+
<div class="two-col">
|
|
320
|
+
<div class="subpanel">
|
|
321
|
+
<h4><i class="ti ti-users"></i> <span data-i18n="audit_user_risk">사용자 사용량 및 위험도</span></h4>
|
|
322
|
+
<div class="table-wrap" id="audit-user-table">
|
|
323
|
+
<div class="preview" style="padding:14px" data-i18n="loading">불러오는 중...</div>
|
|
324
|
+
</div>
|
|
325
|
+
</div>
|
|
326
|
+
<div class="subpanel">
|
|
327
|
+
<h4><i class="ti ti-history"></i> <span data-i18n="audit_trail">감사 이벤트</span></h4>
|
|
328
|
+
<div class="table-wrap" id="audit-event-table">
|
|
329
|
+
<div class="preview" style="padding:14px" data-i18n="loading">불러오는 중...</div>
|
|
330
|
+
</div>
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
838
333
|
</div>
|
|
839
|
-
</
|
|
334
|
+
</section>
|
|
840
335
|
</section>
|
|
841
336
|
</main>
|
|
842
337
|
</div>
|
|
843
338
|
|
|
844
|
-
<script>
|
|
845
|
-
const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825' : '';
|
|
846
|
-
|
|
847
|
-
function apiFetch(path, options = {}) {
|
|
848
|
-
const headers = { ...(options.headers || {}) };
|
|
849
|
-
return fetch(`${API_BASE}${path}`, { credentials: 'include', ...options, headers });
|
|
850
|
-
}
|
|
851
|
-
|
|
852
|
-
function currentUserEmail() {
|
|
853
|
-
return localStorage.getItem('ltcai_user_email') || '';
|
|
854
|
-
}
|
|
855
|
-
|
|
856
|
-
function currentUserNickname() {
|
|
857
|
-
return localStorage.getItem('ltcai_user_nickname') || 'Guest';
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
function currentUserIsAdmin() {
|
|
861
|
-
return localStorage.getItem('ltcai_is_admin') === 'true';
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
function restoreSessionFromQuery() {
|
|
865
|
-
const raw = sessionStorage.getItem('ltcai_admin_handoff');
|
|
866
|
-
if (!raw) return;
|
|
867
|
-
sessionStorage.removeItem('ltcai_admin_handoff');
|
|
868
|
-
let data;
|
|
869
|
-
try { data = JSON.parse(raw); } catch { return; }
|
|
870
|
-
const { email, nickname, is_admin } = data;
|
|
871
|
-
if (!email) return;
|
|
872
|
-
localStorage.setItem('ltcai_user_email', email);
|
|
873
|
-
if (nickname) localStorage.setItem('ltcai_user_nickname', nickname);
|
|
874
|
-
if (is_admin === 'true' || is_admin === 'false') localStorage.setItem('ltcai_is_admin', is_admin);
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
function adminHeaders() {
|
|
878
|
-
return {
|
|
879
|
-
'Content-Type': 'application/json',
|
|
880
|
-
'X-Admin-Email': currentUserEmail(),
|
|
881
|
-
};
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
// ── i18n ─────────────────────────────────────────────────────────────
|
|
885
|
-
const A18N = {
|
|
886
|
-
ko: {
|
|
887
|
-
admin_sub: '관리자 대시보드', btn_back: '채팅으로', btn_refresh: '새로고침', btn_logout: '로그아웃',
|
|
888
|
-
hero_title: '관리자 대시보드',
|
|
889
|
-
hero_desc: '사용자, 민감도 로그, Private VPC, 서버 상태를 한 화면에서 관리합니다. 채팅 화면과 분리되어 있고, 필요할 때만 따로 열어볼 수 있습니다.',
|
|
890
|
-
checking_session: '세션 확인 중...',
|
|
891
|
-
meta_user_accounts: '사용자 계정 수', meta_recent_activity: '최근 대화 활동',
|
|
892
|
-
meta_need_admin: '관리자 권한 필요', meta_msg_unavailable: '최근 메시지 정보를 불러올 수 없음',
|
|
893
|
-
chart_title: '메시지 활동 (최근 14일)', chart_desc: '사용자 메시지와 AI 응답 수를 날짜별로 표시합니다.',
|
|
894
|
-
label_user: '사용자', label_email: '이메일', label_perm: '권한', label_none: '없음',
|
|
895
|
-
vpc_desc: '네트워크 프로필과 운영 상태를 수정합니다.',
|
|
896
|
-
vpc_notes_ph: '운영 메모', vpc_save: '저장', vpc_loading: '불러오는 중...',
|
|
897
|
-
vpc_saving: '저장 중...', vpc_saved: '저장되었습니다.', vpc_save_fail: '저장 실패',
|
|
898
|
-
vpc_default_profile: '기본 VPC 프로필을 사용 중입니다.', vpc_last_saved: '마지막 저장:',
|
|
899
|
-
vpc_standby: '대기', vpc_connected: '연결됨', vpc_needs_setup: '설정 필요',
|
|
900
|
-
session_desc: '현재 로그인한 계정과 서버 상태를 빠르게 확인합니다.',
|
|
901
|
-
session_no_info: '세션 정보가 없습니다',
|
|
902
|
-
session_help_ok: '이메일 헤더가 설정되어 관리자 API를 호출할 수 있습니다.',
|
|
903
|
-
session_help_fail: '채팅 화면에서 로그인한 뒤 이 화면을 열어야 관리자 API를 사용할 수 있습니다.',
|
|
904
|
-
sensitivity_title: '민감도 분석', sensitivity_desc: '감지된 위험 메시지와 준수 메시지를 분리해서 보여줍니다.',
|
|
905
|
-
sensitivity_risk: '위험', sensitivity_compliant: '준수', sensitivity_risk_rate: '위험률', sensitivity_high: '높음',
|
|
906
|
-
no_risk_fields: '감지된 위험 필드가 없습니다.', no_compliance_fields: '준수 항목이 없습니다.',
|
|
907
|
-
invite_title: '초대 링크', invite_desc: '새 사용자를 초대할 링크를 확인하고 복사합니다.',
|
|
908
|
-
btn_copy: '복사', copied: '복사됨 ✅',
|
|
909
|
-
invite_gate_active: '초대 게이트 활성화됨', invite_gate_inactive: '초대 게이트 비활성화 — 링크 없이도 접근 가능합니다.',
|
|
910
|
-
users_title: '사용자 관리', users_desc: '역할 변경, 비활성화, 삭제를 처리합니다.',
|
|
911
|
-
loading: '불러오는 중...', no_users: '사용자 데이터가 없습니다.',
|
|
912
|
-
status_active: '활성', status_inactive: '비활성',
|
|
913
|
-
btn_grant_admin: '관리자 지정', btn_revoke_admin: '권한 해제',
|
|
914
|
-
btn_activate: '활성화', btn_deactivate: '비활성화', btn_delete: '삭제',
|
|
915
|
-
confirm_delete: '사용자를 삭제할까요?',
|
|
916
|
-
err_no_admin: '관리자 권한이 없습니다. 채팅 화면에서 관리자 계정으로 로그인한 뒤 다시 열어주세요.',
|
|
917
|
-
err_partial: '일부 섹션을 불러오지 못했습니다:',
|
|
918
|
-
err_network: '네트워크 연결을 확인해 주세요.', err_load: '대시보드를 불러오지 못했습니다.',
|
|
919
|
-
section_summary: '요약', section_users: '사용자 목록', section_sensitivity: '민감 정보 분석',
|
|
920
|
-
},
|
|
921
|
-
en: {
|
|
922
|
-
admin_sub: 'Admin Dashboard', btn_back: 'Chat', btn_refresh: 'Refresh', btn_logout: 'Logout',
|
|
923
|
-
hero_title: 'Admin Dashboard',
|
|
924
|
-
hero_desc: 'Manage users, sensitivity logs, Private VPC, and server state — all in one screen.',
|
|
925
|
-
checking_session: 'Checking session...',
|
|
926
|
-
meta_user_accounts: 'User accounts', meta_recent_activity: 'Recent activity',
|
|
927
|
-
meta_need_admin: 'Admin permission required', meta_msg_unavailable: 'Could not load recent message info',
|
|
928
|
-
chart_title: 'Message Activity (Last 14 days)', chart_desc: 'User messages and AI responses by day.',
|
|
929
|
-
label_user: 'User', label_email: 'Email', label_perm: 'Role', label_none: 'None',
|
|
930
|
-
vpc_desc: 'Edit network profile and operating state.',
|
|
931
|
-
vpc_notes_ph: 'Operations notes', vpc_save: 'Save', vpc_loading: 'Loading...',
|
|
932
|
-
vpc_saving: 'Saving...', vpc_saved: 'Saved.', vpc_save_fail: 'Save failed',
|
|
933
|
-
vpc_default_profile: 'Using default VPC profile.', vpc_last_saved: 'Last saved:',
|
|
934
|
-
vpc_standby: 'Standby', vpc_connected: 'Connected', vpc_needs_setup: 'Setup required',
|
|
935
|
-
session_desc: 'Quickly check the current login account and server state.',
|
|
936
|
-
session_no_info: 'No session info',
|
|
937
|
-
session_help_ok: 'Email header is set — admin API calls are available.',
|
|
938
|
-
session_help_fail: 'Log in from the chat screen first, then open this screen.',
|
|
939
|
-
sensitivity_title: 'Sensitivity Analysis', sensitivity_desc: 'Shows detected risk messages and compliant messages separately.',
|
|
940
|
-
sensitivity_risk: 'Risk', sensitivity_compliant: 'Compliant', sensitivity_risk_rate: 'Risk rate', sensitivity_high: 'High',
|
|
941
|
-
no_risk_fields: 'No risk fields detected.', no_compliance_fields: 'No compliance items.',
|
|
942
|
-
invite_title: 'Invite Link', invite_desc: 'View and copy the link to invite new users.',
|
|
943
|
-
btn_copy: 'Copy', copied: 'Copied ✅',
|
|
944
|
-
invite_gate_active: 'Invite gate active', invite_gate_inactive: 'Invite gate disabled — accessible without link.',
|
|
945
|
-
users_title: 'User Management', users_desc: 'Handle role changes, disables, and deletions.',
|
|
946
|
-
loading: 'Loading...', no_users: 'No user data.',
|
|
947
|
-
status_active: 'Active', status_inactive: 'Inactive',
|
|
948
|
-
btn_grant_admin: 'Make Admin', btn_revoke_admin: 'Remove Admin',
|
|
949
|
-
btn_activate: 'Activate', btn_deactivate: 'Deactivate', btn_delete: 'Delete',
|
|
950
|
-
confirm_delete: 'Delete this user?',
|
|
951
|
-
err_no_admin: 'No admin permission. Log in as admin from the chat screen.',
|
|
952
|
-
err_partial: 'Failed to load some sections:',
|
|
953
|
-
err_network: 'Please check your network connection.', err_load: 'Could not load dashboard.',
|
|
954
|
-
section_summary: 'Summary', section_users: 'User list', section_sensitivity: 'Sensitivity report',
|
|
955
|
-
}
|
|
956
|
-
};
|
|
957
|
-
|
|
958
|
-
let currentLang = localStorage.getItem('ltcai_lang') || 'ko';
|
|
959
|
-
function t(key) { return (A18N[currentLang] || A18N.ko)[key] || key; }
|
|
960
|
-
|
|
961
|
-
function applyI18n() {
|
|
962
|
-
document.querySelectorAll('[data-i18n]').forEach(el => {
|
|
963
|
-
if (el.id === 'session-help') return;
|
|
964
|
-
const val = t(el.dataset.i18n);
|
|
965
|
-
if (val) el.textContent = val;
|
|
966
|
-
});
|
|
967
|
-
document.querySelectorAll('[data-i18n-ph]').forEach(el => {
|
|
968
|
-
const val = t(el.dataset.i18nPh);
|
|
969
|
-
if (val) el.placeholder = val;
|
|
970
|
-
});
|
|
971
|
-
['ko', 'en'].forEach(lang => {
|
|
972
|
-
const el = document.getElementById(`admin-lang-${lang}`);
|
|
973
|
-
if (el) el.classList.toggle('active', lang === currentLang);
|
|
974
|
-
});
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
function toggleLangMenu(pickerId) {
|
|
978
|
-
const menu = document.getElementById(`${pickerId}-menu`);
|
|
979
|
-
const isOpen = menu.classList.contains('open');
|
|
980
|
-
document.querySelectorAll('.lang-picker-menu').forEach(m => m.classList.remove('open'));
|
|
981
|
-
if (!isOpen) menu.classList.add('open');
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
function setLang(lang) {
|
|
985
|
-
currentLang = lang;
|
|
986
|
-
localStorage.setItem('ltcai_lang', lang);
|
|
987
|
-
document.querySelectorAll('.lang-picker-menu').forEach(m => m.classList.remove('open'));
|
|
988
|
-
applyI18n();
|
|
989
|
-
loadDashboard();
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
document.addEventListener('click', e => {
|
|
993
|
-
if (!e.target.closest('.lang-picker')) {
|
|
994
|
-
document.querySelectorAll('.lang-picker-menu').forEach(m => m.classList.remove('open'));
|
|
995
|
-
}
|
|
996
|
-
});
|
|
997
|
-
// ─────────────────────────────────────────────────────────────────────
|
|
998
|
-
|
|
999
|
-
let activityChartInstance = null;
|
|
1000
|
-
function renderActivityChart(daily) {
|
|
1001
|
-
const labels = daily.map(d => d.date);
|
|
1002
|
-
const userData = daily.map(d => d.user);
|
|
1003
|
-
const aiData = daily.map(d => d.assistant);
|
|
1004
|
-
const ctx = document.getElementById('activity-chart').getContext('2d');
|
|
1005
|
-
if (activityChartInstance) activityChartInstance.destroy();
|
|
1006
|
-
activityChartInstance = new Chart(ctx, {
|
|
1007
|
-
type: 'bar',
|
|
1008
|
-
data: {
|
|
1009
|
-
labels,
|
|
1010
|
-
datasets: [
|
|
1011
|
-
{ label: t('label_user'), data: userData, backgroundColor: 'rgba(99,102,241,0.7)', borderRadius: 4 },
|
|
1012
|
-
{ label: 'AI', data: aiData, backgroundColor: 'rgba(168,85,247,0.5)', borderRadius: 4 }
|
|
1013
|
-
]
|
|
1014
|
-
},
|
|
1015
|
-
options: {
|
|
1016
|
-
responsive: true,
|
|
1017
|
-
plugins: { legend: { labels: { color: '#94a3b8', font: { size: 12 } } } },
|
|
1018
|
-
scales: {
|
|
1019
|
-
x: { ticks: { color: '#64748b' }, grid: { color: 'rgba(255,255,255,0.04)' } },
|
|
1020
|
-
y: { ticks: { color: '#64748b', stepSize: 1 }, grid: { color: 'rgba(255,255,255,0.04)' }, beginAtZero: true }
|
|
1021
|
-
}
|
|
1022
|
-
}
|
|
1023
|
-
});
|
|
1024
|
-
}
|
|
1025
|
-
|
|
1026
|
-
async function copyInviteLink() {
|
|
1027
|
-
const url = document.getElementById('invite-link-input').value;
|
|
1028
|
-
const btn = document.getElementById('copy-invite-btn');
|
|
1029
|
-
try {
|
|
1030
|
-
await navigator.clipboard.writeText(url);
|
|
1031
|
-
btn.textContent = t('copied');
|
|
1032
|
-
setTimeout(() => btn.textContent = t('btn_copy'), 2000);
|
|
1033
|
-
} catch {
|
|
1034
|
-
document.getElementById('invite-link-input').select();
|
|
1035
|
-
}
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
function esc(value) {
|
|
1039
|
-
return String(value ?? '')
|
|
1040
|
-
.replace(/&/g, '&')
|
|
1041
|
-
.replace(/</g, '<')
|
|
1042
|
-
.replace(/>/g, '>')
|
|
1043
|
-
.replace(/"/g, '"')
|
|
1044
|
-
.replace(/'/g, ''');
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
function compactModelName(modelId) {
|
|
1048
|
-
if (!modelId) return 'None';
|
|
1049
|
-
const clean = String(modelId).replaceAll('mlx-community/', '');
|
|
1050
|
-
return clean.length <= 28 ? clean : `${clean.slice(0, 18)}...${clean.slice(-8)}`;
|
|
1051
|
-
}
|
|
1052
|
-
|
|
1053
|
-
function vpcHealthText(config) {
|
|
1054
|
-
if (!config) return t('vpc_standby');
|
|
1055
|
-
if (config.vpn_status === 'connected' || config.peering_status === 'active') return t('vpc_connected');
|
|
1056
|
-
if (config.vpn_status === 'standby') return t('vpc_standby');
|
|
1057
|
-
return config.vpn_status || config.peering_status || t('vpc_needs_setup');
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
|
-
function setSessionInfo() {
|
|
1061
|
-
const email = currentUserEmail();
|
|
1062
|
-
const nick = currentUserNickname();
|
|
1063
|
-
const isAdmin = currentUserIsAdmin();
|
|
1064
|
-
document.getElementById('session-value').textContent = email ? `${nick} <${email}>` : t('session_no_info');
|
|
1065
|
-
const tags = [
|
|
1066
|
-
[t('label_user'), nick, 'low'],
|
|
1067
|
-
[t('label_email'), email || t('label_none'), 'medium'],
|
|
1068
|
-
[t('label_perm'), isAdmin ? 'admin' : 'user', isAdmin ? 'low' : 'medium']
|
|
1069
|
-
];
|
|
1070
|
-
document.getElementById('session-tags').innerHTML = tags.map(([label, value, tone]) => `
|
|
1071
|
-
<span class="tag ${tone}"><span>${esc(label)}</span> ${esc(value)}</span>
|
|
1072
|
-
`).join('');
|
|
1073
|
-
document.getElementById('admin-pill').innerHTML = isAdmin
|
|
1074
|
-
? '<i class="ti ti-shield-check"></i> Admin'
|
|
1075
|
-
: '<i class="ti ti-lock"></i> Read only';
|
|
1076
|
-
document.getElementById('session-help').textContent = email
|
|
1077
|
-
? t('session_help_ok')
|
|
1078
|
-
: t('session_help_fail');
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
function fillVpcForm(config) {
|
|
1082
|
-
if (!config) return;
|
|
1083
|
-
document.getElementById('vpc-provider').value = config.provider || '';
|
|
1084
|
-
document.getElementById('vpc-region').value = config.region || '';
|
|
1085
|
-
document.getElementById('vpc-cidr').value = config.cidr_block || '';
|
|
1086
|
-
document.getElementById('vpc-endpoint').value = config.endpoint || '';
|
|
1087
|
-
document.getElementById('vpc-vpn').value = config.vpn_status || '';
|
|
1088
|
-
document.getElementById('vpc-peering').value = config.peering_status || '';
|
|
1089
|
-
document.getElementById('vpc-subnets').value = (config.private_subnets || []).join(', ');
|
|
1090
|
-
document.getElementById('vpc-notes').value = config.notes || '';
|
|
1091
|
-
document.getElementById('vpc-save-status').textContent = config.updated_at
|
|
1092
|
-
? `${t('vpc_last_saved')} ${new Date(config.updated_at).toLocaleString()}`
|
|
1093
|
-
: t('vpc_default_profile');
|
|
1094
|
-
|
|
1095
|
-
const provider = config.provider || 'VPC';
|
|
1096
|
-
const region = config.region || '-';
|
|
1097
|
-
document.getElementById('vpc-status').textContent = `${provider} ${region}`;
|
|
1098
|
-
document.getElementById('vpc-status-meta').textContent = `${config.cidr_block || '-'} · ${config.endpoint || '-'} · ${vpcHealthText(config)}`;
|
|
1099
|
-
}
|
|
1100
|
-
|
|
1101
|
-
function renderSummary(health, summary, vpc) {
|
|
1102
|
-
document.getElementById('total-users').textContent = summary ? summary.total_users : '-';
|
|
1103
|
-
document.getElementById('total-users-meta').textContent = summary
|
|
1104
|
-
? `${summary.active_users} active · ${summary.admin_users} admins`
|
|
1105
|
-
: t('meta_need_admin');
|
|
1106
|
-
document.getElementById('total-messages').textContent = summary ? summary.total_messages : '-';
|
|
1107
|
-
document.getElementById('total-messages-meta').textContent = summary
|
|
1108
|
-
? `user ${summary.user_messages} · assistant ${summary.assistant_messages}`
|
|
1109
|
-
: t('meta_msg_unavailable');
|
|
1110
|
-
document.getElementById('current-model').textContent = compactModelName(health?.current_model);
|
|
1111
|
-
document.getElementById('current-model-meta').textContent = `${health?.loaded_models?.length || 0} loaded · ${health?.device || 'local runtime'}`;
|
|
1112
|
-
document.getElementById('vpc-status').textContent = `${vpc?.provider || '-'} ${vpc?.region || '-'}`;
|
|
1113
|
-
document.getElementById('vpc-status-meta').textContent = `${vpc?.cidr_block || '-'} · ${vpc?.endpoint || '-'} · ${vpcHealthText(vpc)}`;
|
|
1114
|
-
}
|
|
1115
|
-
|
|
1116
|
-
function renderSensitivity(report) {
|
|
1117
|
-
const summary = report?.summary || {};
|
|
1118
|
-
const severity = summary.severity_counts || {};
|
|
1119
|
-
const fieldCounts = summary.field_counts || {};
|
|
1120
|
-
const userCounts = summary.user_counts || {};
|
|
1121
|
-
|
|
1122
|
-
const tags = [
|
|
1123
|
-
['risk', `${t('sensitivity_risk')} ${summary.risky_messages || 0}`],
|
|
1124
|
-
['low', `${t('sensitivity_compliant')} ${summary.compliant_messages || 0}`],
|
|
1125
|
-
['medium', `${t('sensitivity_risk_rate')} ${summary.risk_rate || 0}%`],
|
|
1126
|
-
['high', `${t('sensitivity_high')} ${severity.high || 0}`]
|
|
1127
|
-
];
|
|
1128
|
-
document.getElementById('sensitivity-summary').innerHTML = tags.map(([tone, label]) => `<span class="tag ${tone}">${esc(label)}</span>`).join('');
|
|
1129
|
-
|
|
1130
|
-
const riskList = report?.risk_fields || [];
|
|
1131
|
-
const complianceList = report?.compliance_fields || [];
|
|
1132
|
-
|
|
1133
|
-
document.getElementById('risk-fields').innerHTML = riskList.length
|
|
1134
|
-
? riskList.slice().reverse().map(item => `
|
|
1135
|
-
<div class="item">
|
|
1136
|
-
<div class="item-meta">
|
|
1137
|
-
<span class="tag">${esc(item.user_nickname || 'Unknown')}</span>
|
|
1138
|
-
<span class="tag">${esc(item.user_email || 'unknown')}</span>
|
|
1139
|
-
<span class="tag ${item.sensitivity || 'low'}">${esc(item.sensitivity || 'none')}</span>
|
|
1140
|
-
${(item.labels || []).map(label => `<span class="tag medium">${esc(label)}</span>`).join('')}
|
|
1141
|
-
</div>
|
|
1142
|
-
<div class="preview">${esc(item.preview || '')}</div>
|
|
1143
|
-
</div>
|
|
1144
|
-
`).join('')
|
|
1145
|
-
: `<div class="preview">${t('no_risk_fields')}</div>`;
|
|
1146
|
-
|
|
1147
|
-
document.getElementById('compliance-fields').innerHTML = complianceList.length
|
|
1148
|
-
? complianceList.slice().reverse().map(item => `
|
|
1149
|
-
<div class="item">
|
|
1150
|
-
<div class="item-meta">
|
|
1151
|
-
<span class="tag">${esc(item.user_nickname || 'Unknown')}</span>
|
|
1152
|
-
<span class="tag">${esc(item.user_email || 'unknown')}</span>
|
|
1153
|
-
<span class="tag ${item.sensitivity || 'low'}">${esc(item.sensitivity || 'none')}</span>
|
|
1154
|
-
${(item.compliance_fields || []).map(label => `<span class="tag low">${esc(label)}</span>`).join('')}
|
|
1155
|
-
</div>
|
|
1156
|
-
<div class="preview">${esc(item.preview || '')}</div>
|
|
1157
|
-
</div>
|
|
1158
|
-
`).join('')
|
|
1159
|
-
: `<div class="preview">${t('no_compliance_fields')}</div>`;
|
|
1160
|
-
|
|
1161
|
-
const fieldTags = Object.entries(fieldCounts).map(([label, count]) => `<span class="tag medium">${esc(label)} ${esc(count)}</span>`);
|
|
1162
|
-
const userTags = Object.entries(userCounts).map(([label, count]) => `<span class="tag high">${esc(label)} ${esc(count)}</span>`);
|
|
1163
|
-
document.getElementById('sensitivity-summary').insertAdjacentHTML('beforeend', fieldTags.join('') + userTags.join(''));
|
|
1164
|
-
}
|
|
1165
|
-
|
|
1166
|
-
function formatAuditTime(value) {
|
|
1167
|
-
if (!value) return '-';
|
|
1168
|
-
const date = new Date(value);
|
|
1169
|
-
if (Number.isNaN(date.getTime())) return value;
|
|
1170
|
-
return date.toLocaleString();
|
|
1171
|
-
}
|
|
1172
|
-
|
|
1173
|
-
function auditEventLabel(event) {
|
|
1174
|
-
const type = event?.event_type || '-';
|
|
1175
|
-
const labels = {
|
|
1176
|
-
chat_message: event?.role === 'assistant' ? 'AI response' : 'User message',
|
|
1177
|
-
document_upload: 'Document upload',
|
|
1178
|
-
clear_command: 'Chat clear',
|
|
1179
|
-
conversation_delete: 'Conversation delete',
|
|
1180
|
-
history_delete: 'History delete',
|
|
1181
|
-
user_delete: 'User delete'
|
|
1182
|
-
};
|
|
1183
|
-
return labels[type] || type;
|
|
1184
|
-
}
|
|
1185
|
-
|
|
1186
|
-
function auditTarget(event) {
|
|
1187
|
-
if (!event) return '-';
|
|
1188
|
-
if (event.filename) return event.filename;
|
|
1189
|
-
if (event.target_email) return `target: ${event.target_email}`;
|
|
1190
|
-
if (event.command) return `${event.command} · ${event.scope || '-'} · removed ${event.removed || 0}`;
|
|
1191
|
-
if (event.event_type === 'history_delete') return `history · removed ${event.removed || 0} · kept ${event.kept || 0}`;
|
|
1192
|
-
if (event.conversation_id) return `conversation ${String(event.conversation_id).slice(0, 18)}`;
|
|
1193
|
-
return event.content_preview || '-';
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
function renderAudit(audit) {
|
|
1197
|
-
const summary = audit?.summary || {};
|
|
1198
|
-
const graph = audit?.graph || {};
|
|
1199
|
-
const metrics = [
|
|
1200
|
-
['Total Events', summary.total_events || 0, `${summary.chat_events || 0} chat events`],
|
|
1201
|
-
['AI Usage', `${summary.user_messages || 0}/${summary.assistant_messages || 0}`, 'user / assistant'],
|
|
1202
|
-
['Uploads', summary.document_uploads || 0, `${graph.nodes || 0} graph nodes`],
|
|
1203
|
-
['Clear Events', summary.clear_events || 0, 'screen cleanup only'],
|
|
1204
|
-
['Sensitive', summary.sensitive_events || 0, `${summary.high_sensitive_events || 0} high risk`],
|
|
1205
|
-
];
|
|
1206
|
-
document.getElementById('audit-metrics').innerHTML = metrics.map(([label, value, meta]) => `
|
|
1207
|
-
<div class="audit-metric">
|
|
1208
|
-
<div class="label">${esc(label)}</div>
|
|
1209
|
-
<div class="value">${esc(value)}</div>
|
|
1210
|
-
<div class="meta">${esc(meta)}</div>
|
|
1211
|
-
</div>
|
|
1212
|
-
`).join('');
|
|
1213
|
-
|
|
1214
|
-
const tags = [
|
|
1215
|
-
['low', `Graph nodes ${graph.nodes || 0}`],
|
|
1216
|
-
['low', `Edges ${graph.edges || 0}`],
|
|
1217
|
-
['medium', `Deletes ${summary.delete_events || 0}`],
|
|
1218
|
-
[summary.high_sensitive_events ? 'high' : 'low', `High risk ${summary.high_sensitive_events || 0}`]
|
|
1219
|
-
];
|
|
1220
|
-
document.getElementById('audit-summary-tags').innerHTML = tags.map(([tone, label]) => `<span class="tag ${tone}">${esc(label)}</span>`).join('');
|
|
1221
|
-
|
|
1222
|
-
const users = audit?.per_user || [];
|
|
1223
|
-
document.getElementById('audit-user-table').innerHTML = users.length ? `
|
|
1224
|
-
<table>
|
|
1225
|
-
<thead>
|
|
1226
|
-
<tr>
|
|
1227
|
-
<th>User</th>
|
|
1228
|
-
<th>AI Use</th>
|
|
1229
|
-
<th>Uploads</th>
|
|
1230
|
-
<th>Sensitive</th>
|
|
1231
|
-
<th>Clear/Delete</th>
|
|
1232
|
-
<th>Last Active</th>
|
|
1233
|
-
</tr>
|
|
1234
|
-
</thead>
|
|
1235
|
-
<tbody>
|
|
1236
|
-
${users.map(user => `
|
|
1237
|
-
<tr>
|
|
1238
|
-
<td>
|
|
1239
|
-
<strong>${esc(user.nickname || user.email || 'Unknown')}</strong>
|
|
1240
|
-
<div class="preview">${esc(user.email || '')}</div>
|
|
1241
|
-
</td>
|
|
1242
|
-
<td>${esc(user.user_messages || 0)} / ${esc(user.assistant_messages || 0)}</td>
|
|
1243
|
-
<td>${esc(user.document_uploads || 0)}</td>
|
|
1244
|
-
<td>
|
|
1245
|
-
<span class="tag ${(user.high_sensitive_events || 0) ? 'high' : ((user.sensitive_events || 0) ? 'medium' : 'low')}">
|
|
1246
|
-
${esc(user.sensitive_events || 0)}
|
|
1247
|
-
</span>
|
|
1248
|
-
</td>
|
|
1249
|
-
<td>${esc(user.clear_events || 0)} / ${esc(user.delete_events || 0)}</td>
|
|
1250
|
-
<td>${esc(formatAuditTime(user.last_activity_at))}</td>
|
|
1251
|
-
</tr>
|
|
1252
|
-
`).join('')}
|
|
1253
|
-
</tbody>
|
|
1254
|
-
</table>
|
|
1255
|
-
` : '<div class="preview" style="padding:14px">감사 데이터가 아직 없습니다.</div>';
|
|
1256
|
-
|
|
1257
|
-
const events = audit?.recent_events || [];
|
|
1258
|
-
document.getElementById('audit-event-table').innerHTML = events.length ? `
|
|
1259
|
-
<table>
|
|
1260
|
-
<thead>
|
|
1261
|
-
<tr>
|
|
1262
|
-
<th>Time</th>
|
|
1263
|
-
<th>Event</th>
|
|
1264
|
-
<th>User</th>
|
|
1265
|
-
<th>Target/Data</th>
|
|
1266
|
-
<th>Risk</th>
|
|
1267
|
-
</tr>
|
|
1268
|
-
</thead>
|
|
1269
|
-
<tbody>
|
|
1270
|
-
${events.map(event => `
|
|
1271
|
-
<tr>
|
|
1272
|
-
<td>${esc(formatAuditTime(event.timestamp))}</td>
|
|
1273
|
-
<td>${esc(auditEventLabel(event))}</td>
|
|
1274
|
-
<td>${esc(event.user_nickname || event.user_email || 'Unknown')}</td>
|
|
1275
|
-
<td>${esc(auditTarget(event))}</td>
|
|
1276
|
-
<td>
|
|
1277
|
-
<span class="tag ${event.sensitivity === 'high' ? 'high' : (event.sensitivity && event.sensitivity !== 'none' ? 'medium' : 'low')}">
|
|
1278
|
-
${esc(event.sensitivity || 'none')}
|
|
1279
|
-
</span>
|
|
1280
|
-
</td>
|
|
1281
|
-
</tr>
|
|
1282
|
-
`).join('')}
|
|
1283
|
-
</tbody>
|
|
1284
|
-
</table>
|
|
1285
|
-
` : '<div class="preview" style="padding:14px">최근 감사 이벤트가 없습니다.</div>';
|
|
1286
|
-
}
|
|
1287
|
-
|
|
1288
|
-
function renderUsers(users) {
|
|
1289
|
-
const wrap = document.getElementById('user-table-wrap');
|
|
1290
|
-
if (!Array.isArray(users) || !users.length) {
|
|
1291
|
-
wrap.innerHTML = `<div class="preview" style="padding:14px">${t('no_users')}</div>`;
|
|
1292
|
-
return;
|
|
1293
|
-
}
|
|
1294
|
-
wrap.innerHTML = `
|
|
1295
|
-
<table>
|
|
1296
|
-
<thead>
|
|
1297
|
-
<tr>
|
|
1298
|
-
<th>Email</th>
|
|
1299
|
-
<th>Name</th>
|
|
1300
|
-
<th>Nickname</th>
|
|
1301
|
-
<th>Role</th>
|
|
1302
|
-
<th>Status</th>
|
|
1303
|
-
<th>Actions</th>
|
|
1304
|
-
</tr>
|
|
1305
|
-
</thead>
|
|
1306
|
-
<tbody>
|
|
1307
|
-
${users.map(user => `
|
|
1308
|
-
<tr>
|
|
1309
|
-
<td>${esc(user.email)}</td>
|
|
1310
|
-
<td>${esc(user.name || '-')}</td>
|
|
1311
|
-
<td>${esc(user.nickname || '-')}</td>
|
|
1312
|
-
<td><span class="role">${esc(user.role || '-')}</span></td>
|
|
1313
|
-
<td>${user.disabled ? t('status_inactive') : t('status_active')}</td>
|
|
1314
|
-
<td>
|
|
1315
|
-
<div class="actions">
|
|
1316
|
-
<button class="table-btn"
|
|
1317
|
-
data-action="role"
|
|
1318
|
-
data-email="${esc(user.email)}"
|
|
1319
|
-
data-next-role="${user.role === 'admin' ? 'user' : 'admin'}">
|
|
1320
|
-
${user.role === 'admin' ? t('btn_revoke_admin') : t('btn_grant_admin')}
|
|
1321
|
-
</button>
|
|
1322
|
-
<button class="table-btn"
|
|
1323
|
-
data-action="disable"
|
|
1324
|
-
data-email="${esc(user.email)}"
|
|
1325
|
-
data-disabled="${user.disabled ? 'false' : 'true'}">
|
|
1326
|
-
${user.disabled ? t('btn_activate') : t('btn_deactivate')}
|
|
1327
|
-
</button>
|
|
1328
|
-
<button class="table-btn danger"
|
|
1329
|
-
data-action="delete"
|
|
1330
|
-
data-email="${esc(user.email)}">${t('btn_delete')}</button>
|
|
1331
|
-
</div>
|
|
1332
|
-
</td>
|
|
1333
|
-
</tr>
|
|
1334
|
-
`).join('')}
|
|
1335
|
-
</tbody>
|
|
1336
|
-
</table>
|
|
1337
|
-
`;
|
|
1338
|
-
}
|
|
1339
|
-
|
|
1340
|
-
document.getElementById('user-table-wrap').addEventListener('click', async (e) => {
|
|
1341
|
-
const btn = e.target.closest('button[data-action]');
|
|
1342
|
-
if (!btn) return;
|
|
1343
|
-
const action = btn.dataset.action;
|
|
1344
|
-
const email = btn.dataset.email;
|
|
1345
|
-
if (!email) return;
|
|
1346
|
-
const encodedEmail = encodeURIComponent(email);
|
|
1347
|
-
if (action === 'role') {
|
|
1348
|
-
const nextRole = btn.dataset.nextRole;
|
|
1349
|
-
await apiFetch(`/admin/users/${encodedEmail}`, {
|
|
1350
|
-
method: 'PATCH', headers: adminHeaders(), body: JSON.stringify({ role: nextRole })
|
|
1351
|
-
});
|
|
1352
|
-
await loadDashboard();
|
|
1353
|
-
} else if (action === 'disable') {
|
|
1354
|
-
const disabled = btn.dataset.disabled === 'true';
|
|
1355
|
-
await apiFetch(`/admin/users/${encodedEmail}`, {
|
|
1356
|
-
method: 'PATCH', headers: adminHeaders(), body: JSON.stringify({ disabled })
|
|
1357
|
-
});
|
|
1358
|
-
await loadDashboard();
|
|
1359
|
-
} else if (action === 'delete') {
|
|
1360
|
-
if (!confirm(`'${email}' ${t('confirm_delete')}`)) return;
|
|
1361
|
-
await apiFetch(`/admin/users/${encodedEmail}`, {
|
|
1362
|
-
method: 'DELETE', headers: adminHeaders()
|
|
1363
|
-
});
|
|
1364
|
-
await loadDashboard();
|
|
1365
|
-
}
|
|
1366
|
-
});
|
|
1367
|
-
|
|
1368
|
-
async function loadDashboard() {
|
|
1369
|
-
applyI18n();
|
|
1370
|
-
setSessionInfo();
|
|
1371
|
-
|
|
1372
|
-
const access = document.getElementById('access-notice');
|
|
1373
|
-
access.style.display = 'none';
|
|
1374
|
-
|
|
1375
|
-
try {
|
|
1376
|
-
const [healthRes, vpcRes, summaryRes, usersRes, sensitivityRes, inviteRes, statsRes, auditRes] = await Promise.all([
|
|
1377
|
-
apiFetch('/health'),
|
|
1378
|
-
apiFetch('/vpc/status'),
|
|
1379
|
-
apiFetch('/admin/summary', { headers: adminHeaders() }),
|
|
1380
|
-
apiFetch('/admin/users', { headers: adminHeaders() }),
|
|
1381
|
-
apiFetch('/admin/sensitivity', { headers: adminHeaders() }),
|
|
1382
|
-
apiFetch('/admin/invite-link', { headers: adminHeaders() }),
|
|
1383
|
-
apiFetch('/admin/stats', { headers: adminHeaders() }),
|
|
1384
|
-
apiFetch('/admin/audit', { headers: adminHeaders() })
|
|
1385
|
-
]);
|
|
1386
|
-
|
|
1387
|
-
const health = healthRes.ok ? await healthRes.json() : null;
|
|
1388
|
-
const vpc = vpcRes.ok ? await vpcRes.json() : null;
|
|
1389
|
-
const summary = summaryRes.ok ? await summaryRes.json() : null;
|
|
1390
|
-
const users = usersRes.ok ? await usersRes.json() : null;
|
|
1391
|
-
const sensitivity = sensitivityRes.ok ? await sensitivityRes.json() : null;
|
|
1392
|
-
const invite = inviteRes.ok ? await inviteRes.json() : null;
|
|
1393
|
-
const stats = statsRes.ok ? await statsRes.json() : null;
|
|
1394
|
-
const audit = auditRes.ok ? await auditRes.json() : null;
|
|
1395
|
-
|
|
1396
|
-
renderSummary(health, summary, vpc);
|
|
1397
|
-
fillVpcForm(vpc);
|
|
1398
|
-
renderUsers(users);
|
|
1399
|
-
renderSensitivity(sensitivity);
|
|
1400
|
-
renderAudit(audit);
|
|
1401
|
-
if (invite) {
|
|
1402
|
-
document.getElementById('invite-link-input').value = invite.invite_url;
|
|
1403
|
-
document.getElementById('invite-gate-info').textContent = invite.gate_enabled
|
|
1404
|
-
? `${t('invite_gate_active')} — ${invite.invite_code}`
|
|
1405
|
-
: t('invite_gate_inactive');
|
|
1406
|
-
}
|
|
1407
|
-
if (stats) renderActivityChart(stats.daily);
|
|
1408
|
-
|
|
1409
|
-
const failedSections = [];
|
|
1410
|
-
if (!summaryRes.ok) failedSections.push(t('section_summary'));
|
|
1411
|
-
if (!usersRes.ok) failedSections.push(t('section_users'));
|
|
1412
|
-
if (!sensitivityRes.ok) failedSections.push(t('section_sensitivity'));
|
|
1413
|
-
if (!auditRes.ok) failedSections.push('audit');
|
|
1414
|
-
|
|
1415
|
-
if (failedSections.length) {
|
|
1416
|
-
access.style.display = 'block';
|
|
1417
|
-
access.textContent = summaryRes.status === 403
|
|
1418
|
-
? t('err_no_admin')
|
|
1419
|
-
: `${t('err_partial')} ${failedSections.join(', ')}`;
|
|
1420
|
-
}
|
|
1421
|
-
} catch (e) {
|
|
1422
|
-
access.style.display = 'block';
|
|
1423
|
-
access.textContent = !navigator.onLine
|
|
1424
|
-
? t('err_network')
|
|
1425
|
-
: (e.message || t('err_load'));
|
|
1426
|
-
}
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
async function saveVpc() {
|
|
1430
|
-
const payload = {
|
|
1431
|
-
provider: document.getElementById('vpc-provider').value.trim(),
|
|
1432
|
-
region: document.getElementById('vpc-region').value.trim(),
|
|
1433
|
-
cidr_block: document.getElementById('vpc-cidr').value.trim(),
|
|
1434
|
-
endpoint: document.getElementById('vpc-endpoint').value.trim(),
|
|
1435
|
-
vpn_status: document.getElementById('vpc-vpn').value.trim(),
|
|
1436
|
-
peering_status: document.getElementById('vpc-peering').value.trim(),
|
|
1437
|
-
private_subnets: document.getElementById('vpc-subnets').value.split(',').map(v => v.trim()).filter(Boolean),
|
|
1438
|
-
notes: document.getElementById('vpc-notes').value.trim()
|
|
1439
|
-
};
|
|
1440
|
-
const status = document.getElementById('vpc-save-status');
|
|
1441
|
-
status.textContent = t('vpc_saving');
|
|
1442
|
-
try {
|
|
1443
|
-
const res = await apiFetch('/admin/vpc', {
|
|
1444
|
-
method: 'PATCH',
|
|
1445
|
-
headers: adminHeaders(),
|
|
1446
|
-
body: JSON.stringify(payload)
|
|
1447
|
-
});
|
|
1448
|
-
const data = await res.json().catch(() => ({}));
|
|
1449
|
-
if (!res.ok) throw new Error(data.detail || t('vpc_save_fail'));
|
|
1450
|
-
fillVpcForm(data);
|
|
1451
|
-
status.textContent = t('vpc_saved');
|
|
1452
|
-
await loadDashboard();
|
|
1453
|
-
} catch (e) {
|
|
1454
|
-
status.textContent = e.message || t('vpc_save_fail');
|
|
1455
|
-
}
|
|
1456
|
-
}
|
|
1457
|
-
|
|
1458
|
-
async function logout() {
|
|
1459
|
-
try {
|
|
1460
|
-
await apiFetch('/logout', { method: 'POST' });
|
|
1461
|
-
} catch (e) { }
|
|
1462
|
-
localStorage.removeItem('ltcai_user_email');
|
|
1463
|
-
localStorage.removeItem('ltcai_user_nickname');
|
|
1464
|
-
localStorage.removeItem('ltcai_is_admin');
|
|
1465
|
-
window.location.href = '/';
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
document.getElementById('refresh-btn').addEventListener('click', loadDashboard);
|
|
1469
|
-
document.getElementById('save-vpc-btn').addEventListener('click', saveVpc);
|
|
1470
|
-
document.getElementById('logout-btn').addEventListener('click', logout);
|
|
1471
|
-
applyI18n();
|
|
1472
|
-
restoreSessionFromQuery();
|
|
1473
|
-
loadDashboard();
|
|
1474
|
-
</script>
|
|
339
|
+
<script src="/static/scripts/admin.js"></script>
|
|
1475
340
|
</body>
|
|
1476
341
|
|
|
1477
342
|
</html>
|