ltcai 0.1.4 → 0.1.8

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/static/admin.html CHANGED
@@ -3,8 +3,13 @@
3
3
 
4
4
  <head>
5
5
  <meta charset="UTF-8">
6
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
7
7
  <title>Lattice AI Admin</title>
8
+ <link rel="manifest" href="/manifest.json">
9
+ <meta name="theme-color" content="#2d5a3d">
10
+ <meta name="apple-mobile-web-app-capable" content="yes">
11
+ <link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
12
+ <link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png">
8
13
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@tabler/icons-webfont@latest/tabler-icons.min.css">
9
14
  <style>
10
15
  @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
@@ -381,6 +386,43 @@
381
386
  gap: 12px;
382
387
  }
383
388
 
389
+ .audit-grid {
390
+ display: grid;
391
+ grid-template-columns: repeat(5, minmax(0, 1fr));
392
+ gap: 10px;
393
+ margin-bottom: 14px;
394
+ }
395
+
396
+ .audit-metric {
397
+ border: 1px solid var(--border);
398
+ border-radius: 12px;
399
+ background: rgba(255,255,255,0.025);
400
+ padding: 12px;
401
+ min-width: 0;
402
+ }
403
+
404
+ .audit-metric .label {
405
+ color: var(--faint);
406
+ font-size: 11px;
407
+ font-weight: 800;
408
+ letter-spacing: 0.06em;
409
+ text-transform: uppercase;
410
+ margin-bottom: 8px;
411
+ }
412
+
413
+ .audit-metric .value {
414
+ font-size: 24px;
415
+ font-weight: 800;
416
+ line-height: 1;
417
+ }
418
+
419
+ .audit-metric .meta {
420
+ color: var(--muted);
421
+ font-size: 12px;
422
+ margin-top: 7px;
423
+ line-height: 1.4;
424
+ }
425
+
384
426
  .subpanel {
385
427
  border: 1px solid var(--border);
386
428
  border-radius: 12px;
@@ -505,6 +547,7 @@
505
547
 
506
548
  @media (max-width: 1100px) {
507
549
  .summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
550
+ .audit-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
508
551
  .panel-grid { grid-template-columns: 1fr; }
509
552
  }
510
553
 
@@ -518,6 +561,7 @@
518
561
  .content { padding: 16px; gap: 14px; }
519
562
  .hero h2 { font-size: 22px; }
520
563
  .summary-grid { grid-template-columns: 1fr; }
564
+ .audit-grid { grid-template-columns: 1fr; }
521
565
  .two-col, .form-grid { grid-template-columns: 1fr; }
522
566
  .field.full { grid-column: auto; }
523
567
  }
@@ -623,6 +667,33 @@
623
667
  </div>
624
668
  </section>
625
669
 
670
+ <section class="panel">
671
+ <div class="panel-header">
672
+ <div>
673
+ <h3>Audit & Data Governance</h3>
674
+ <p>사용자별 AI 사용량, 문서 업로드, 민감정보 감지, 삭제/정리 이벤트를 보존합니다.</p>
675
+ </div>
676
+ <div class="tag-row" id="audit-summary-tags"></div>
677
+ </div>
678
+ <div class="panel-body">
679
+ <div class="audit-grid" id="audit-metrics"></div>
680
+ <div class="two-col">
681
+ <div class="subpanel">
682
+ <h4><i class="ti ti-users"></i> User Usage & Risk</h4>
683
+ <div class="table-wrap" id="audit-user-table">
684
+ <div class="preview" style="padding:14px">불러오는 중...</div>
685
+ </div>
686
+ </div>
687
+ <div class="subpanel">
688
+ <h4><i class="ti ti-history"></i> Audit Trail</h4>
689
+ <div class="table-wrap" id="audit-event-table">
690
+ <div class="preview" style="padding:14px">불러오는 중...</div>
691
+ </div>
692
+ </div>
693
+ </div>
694
+ </div>
695
+ </section>
696
+
626
697
  <section class="panel-grid">
627
698
  <article class="panel">
628
699
  <div class="panel-header">
@@ -753,7 +824,10 @@
753
824
  const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825' : '';
754
825
 
755
826
  function apiFetch(path, options = {}) {
756
- return fetch(`${API_BASE}${path}`, options);
827
+ const headers = { ...(options.headers || {}) };
828
+ const token = localStorage.getItem('ltcai_session_token') || '';
829
+ if (token && !headers.Authorization) headers.Authorization = `Bearer ${token}`;
830
+ return fetch(`${API_BASE}${path}`, { credentials: 'include', ...options, headers });
757
831
  }
758
832
 
759
833
  function currentUserEmail() {
@@ -768,10 +842,26 @@
768
842
  return localStorage.getItem('ltcai_is_admin') === 'true';
769
843
  }
770
844
 
845
+ function restoreSessionFromQuery() {
846
+ const raw = sessionStorage.getItem('ltcai_admin_handoff');
847
+ if (!raw) return;
848
+ sessionStorage.removeItem('ltcai_admin_handoff');
849
+ let data;
850
+ try { data = JSON.parse(raw); } catch { return; }
851
+ const { email, nickname, is_admin, token } = data;
852
+ if (!email) return;
853
+ localStorage.setItem('ltcai_user_email', email);
854
+ if (nickname) localStorage.setItem('ltcai_user_nickname', nickname);
855
+ if (is_admin === 'true' || is_admin === 'false') localStorage.setItem('ltcai_is_admin', is_admin);
856
+ if (token) localStorage.setItem('ltcai_session_token', token);
857
+ }
858
+
771
859
  function adminHeaders() {
860
+ const token = localStorage.getItem('ltcai_session_token') || '';
772
861
  return {
773
862
  'Content-Type': 'application/json',
774
- 'X-Admin-Email': currentUserEmail()
863
+ 'X-Admin-Email': currentUserEmail(),
864
+ ...(token ? { Authorization: `Bearer ${token}` } : {})
775
865
  };
776
866
  }
777
867
 
@@ -854,6 +944,7 @@
854
944
 
855
945
  function applyI18n() {
856
946
  document.querySelectorAll('[data-i18n]').forEach(el => {
947
+ if (el.id === 'session-help') return;
857
948
  const val = t(el.dataset.i18n);
858
949
  if (val) el.textContent = val;
859
950
  });
@@ -1056,6 +1147,128 @@
1056
1147
  document.getElementById('sensitivity-summary').insertAdjacentHTML('beforeend', fieldTags.join('') + userTags.join(''));
1057
1148
  }
1058
1149
 
1150
+ function formatAuditTime(value) {
1151
+ if (!value) return '-';
1152
+ const date = new Date(value);
1153
+ if (Number.isNaN(date.getTime())) return value;
1154
+ return date.toLocaleString();
1155
+ }
1156
+
1157
+ function auditEventLabel(event) {
1158
+ const type = event?.event_type || '-';
1159
+ const labels = {
1160
+ chat_message: event?.role === 'assistant' ? 'AI response' : 'User message',
1161
+ document_upload: 'Document upload',
1162
+ clear_command: 'Chat clear',
1163
+ conversation_delete: 'Conversation delete',
1164
+ history_delete: 'History delete',
1165
+ user_delete: 'User delete'
1166
+ };
1167
+ return labels[type] || type;
1168
+ }
1169
+
1170
+ function auditTarget(event) {
1171
+ if (!event) return '-';
1172
+ if (event.filename) return event.filename;
1173
+ if (event.target_email) return `target: ${event.target_email}`;
1174
+ if (event.command) return `${event.command} · ${event.scope || '-'} · removed ${event.removed || 0}`;
1175
+ if (event.event_type === 'history_delete') return `history · removed ${event.removed || 0} · kept ${event.kept || 0}`;
1176
+ if (event.conversation_id) return `conversation ${String(event.conversation_id).slice(0, 18)}`;
1177
+ return event.content_preview || '-';
1178
+ }
1179
+
1180
+ function renderAudit(audit) {
1181
+ const summary = audit?.summary || {};
1182
+ const graph = audit?.graph || {};
1183
+ const metrics = [
1184
+ ['Total Events', summary.total_events || 0, `${summary.chat_events || 0} chat events`],
1185
+ ['AI Usage', `${summary.user_messages || 0}/${summary.assistant_messages || 0}`, 'user / assistant'],
1186
+ ['Uploads', summary.document_uploads || 0, `${graph.nodes || 0} graph nodes`],
1187
+ ['Clear Events', summary.clear_events || 0, 'screen cleanup only'],
1188
+ ['Sensitive', summary.sensitive_events || 0, `${summary.high_sensitive_events || 0} high risk`],
1189
+ ];
1190
+ document.getElementById('audit-metrics').innerHTML = metrics.map(([label, value, meta]) => `
1191
+ <div class="audit-metric">
1192
+ <div class="label">${esc(label)}</div>
1193
+ <div class="value">${esc(value)}</div>
1194
+ <div class="meta">${esc(meta)}</div>
1195
+ </div>
1196
+ `).join('');
1197
+
1198
+ const tags = [
1199
+ ['low', `Graph nodes ${graph.nodes || 0}`],
1200
+ ['low', `Edges ${graph.edges || 0}`],
1201
+ ['medium', `Deletes ${summary.delete_events || 0}`],
1202
+ [summary.high_sensitive_events ? 'high' : 'low', `High risk ${summary.high_sensitive_events || 0}`]
1203
+ ];
1204
+ document.getElementById('audit-summary-tags').innerHTML = tags.map(([tone, label]) => `<span class="tag ${tone}">${esc(label)}</span>`).join('');
1205
+
1206
+ const users = audit?.per_user || [];
1207
+ document.getElementById('audit-user-table').innerHTML = users.length ? `
1208
+ <table>
1209
+ <thead>
1210
+ <tr>
1211
+ <th>User</th>
1212
+ <th>AI Use</th>
1213
+ <th>Uploads</th>
1214
+ <th>Sensitive</th>
1215
+ <th>Clear/Delete</th>
1216
+ <th>Last Active</th>
1217
+ </tr>
1218
+ </thead>
1219
+ <tbody>
1220
+ ${users.map(user => `
1221
+ <tr>
1222
+ <td>
1223
+ <strong>${esc(user.nickname || user.email || 'Unknown')}</strong>
1224
+ <div class="preview">${esc(user.email || '')}</div>
1225
+ </td>
1226
+ <td>${esc(user.user_messages || 0)} / ${esc(user.assistant_messages || 0)}</td>
1227
+ <td>${esc(user.document_uploads || 0)}</td>
1228
+ <td>
1229
+ <span class="tag ${(user.high_sensitive_events || 0) ? 'high' : ((user.sensitive_events || 0) ? 'medium' : 'low')}">
1230
+ ${esc(user.sensitive_events || 0)}
1231
+ </span>
1232
+ </td>
1233
+ <td>${esc(user.clear_events || 0)} / ${esc(user.delete_events || 0)}</td>
1234
+ <td>${esc(formatAuditTime(user.last_activity_at))}</td>
1235
+ </tr>
1236
+ `).join('')}
1237
+ </tbody>
1238
+ </table>
1239
+ ` : '<div class="preview" style="padding:14px">감사 데이터가 아직 없습니다.</div>';
1240
+
1241
+ const events = audit?.recent_events || [];
1242
+ document.getElementById('audit-event-table').innerHTML = events.length ? `
1243
+ <table>
1244
+ <thead>
1245
+ <tr>
1246
+ <th>Time</th>
1247
+ <th>Event</th>
1248
+ <th>User</th>
1249
+ <th>Target/Data</th>
1250
+ <th>Risk</th>
1251
+ </tr>
1252
+ </thead>
1253
+ <tbody>
1254
+ ${events.map(event => `
1255
+ <tr>
1256
+ <td>${esc(formatAuditTime(event.timestamp))}</td>
1257
+ <td>${esc(auditEventLabel(event))}</td>
1258
+ <td>${esc(event.user_nickname || event.user_email || 'Unknown')}</td>
1259
+ <td>${esc(auditTarget(event))}</td>
1260
+ <td>
1261
+ <span class="tag ${event.sensitivity === 'high' ? 'high' : (event.sensitivity && event.sensitivity !== 'none' ? 'medium' : 'low')}">
1262
+ ${esc(event.sensitivity || 'none')}
1263
+ </span>
1264
+ </td>
1265
+ </tr>
1266
+ `).join('')}
1267
+ </tbody>
1268
+ </table>
1269
+ ` : '<div class="preview" style="padding:14px">최근 감사 이벤트가 없습니다.</div>';
1270
+ }
1271
+
1059
1272
  function renderUsers(users) {
1060
1273
  const wrap = document.getElementById('user-table-wrap');
1061
1274
  if (!Array.isArray(users) || !users.length) {
@@ -1137,21 +1350,22 @@
1137
1350
  });
1138
1351
 
1139
1352
  async function loadDashboard() {
1140
- setSessionInfo();
1141
1353
  applyI18n();
1354
+ setSessionInfo();
1142
1355
 
1143
1356
  const access = document.getElementById('access-notice');
1144
1357
  access.style.display = 'none';
1145
1358
 
1146
1359
  try {
1147
- const [healthRes, vpcRes, summaryRes, usersRes, sensitivityRes, inviteRes, statsRes] = await Promise.all([
1360
+ const [healthRes, vpcRes, summaryRes, usersRes, sensitivityRes, inviteRes, statsRes, auditRes] = await Promise.all([
1148
1361
  apiFetch('/health'),
1149
1362
  apiFetch('/vpc/status'),
1150
1363
  apiFetch('/admin/summary', { headers: adminHeaders() }),
1151
1364
  apiFetch('/admin/users', { headers: adminHeaders() }),
1152
1365
  apiFetch('/admin/sensitivity', { headers: adminHeaders() }),
1153
1366
  apiFetch('/admin/invite-link', { headers: adminHeaders() }),
1154
- apiFetch('/admin/stats', { headers: adminHeaders() })
1367
+ apiFetch('/admin/stats', { headers: adminHeaders() }),
1368
+ apiFetch('/admin/audit', { headers: adminHeaders() })
1155
1369
  ]);
1156
1370
 
1157
1371
  const health = healthRes.ok ? await healthRes.json() : null;
@@ -1161,11 +1375,13 @@
1161
1375
  const sensitivity = sensitivityRes.ok ? await sensitivityRes.json() : null;
1162
1376
  const invite = inviteRes.ok ? await inviteRes.json() : null;
1163
1377
  const stats = statsRes.ok ? await statsRes.json() : null;
1378
+ const audit = auditRes.ok ? await auditRes.json() : null;
1164
1379
 
1165
1380
  renderSummary(health, summary, vpc);
1166
1381
  fillVpcForm(vpc);
1167
1382
  renderUsers(users);
1168
1383
  renderSensitivity(sensitivity);
1384
+ renderAudit(audit);
1169
1385
  if (invite) {
1170
1386
  document.getElementById('invite-link-input').value = invite.invite_url;
1171
1387
  document.getElementById('invite-gate-info').textContent = invite.gate_enabled
@@ -1178,6 +1394,7 @@
1178
1394
  if (!summaryRes.ok) failedSections.push(t('section_summary'));
1179
1395
  if (!usersRes.ok) failedSections.push(t('section_users'));
1180
1396
  if (!sensitivityRes.ok) failedSections.push(t('section_sensitivity'));
1397
+ if (!auditRes.ok) failedSections.push('audit');
1181
1398
 
1182
1399
  if (failedSections.length) {
1183
1400
  access.style.display = 'block';
@@ -1229,6 +1446,7 @@
1229
1446
  localStorage.removeItem('ltcai_user_email');
1230
1447
  localStorage.removeItem('ltcai_user_nickname');
1231
1448
  localStorage.removeItem('ltcai_is_admin');
1449
+ localStorage.removeItem('ltcai_session_token');
1232
1450
  window.location.href = '/';
1233
1451
  }
1234
1452
 
@@ -1236,6 +1454,7 @@
1236
1454
  document.getElementById('save-vpc-btn').addEventListener('click', saveVpc);
1237
1455
  document.getElementById('logout-btn').addEventListener('click', logout);
1238
1456
  applyI18n();
1457
+ restoreSessionFromQuery();
1239
1458
  loadDashboard();
1240
1459
  </script>
1241
1460
  </body>