ltcai 0.3.0 → 0.3.2

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.
@@ -3051,24 +3051,49 @@ body.lattice-ref-graph {
3051
3051
  }
3052
3052
 
3053
3053
  .local-option-row {
3054
- display: flex;
3055
- flex-direction: column;
3056
- gap: 6px;
3054
+ display: grid;
3055
+ grid-template-columns: repeat(2, minmax(0, 1fr));
3056
+ gap: 7px;
3057
3057
  }
3058
3058
 
3059
- .local-option-row label {
3059
+ .local-option-btn {
3060
+ min-width: 0;
3061
+ height: 36px;
3062
+ border: 1px solid rgba(111,66,232,0.18);
3063
+ border-radius: 8px;
3064
+ background: #fff;
3065
+ color: var(--muted);
3066
+ cursor: pointer;
3060
3067
  display: inline-flex;
3061
3068
  align-items: center;
3069
+ justify-content: center;
3062
3070
  gap: 6px;
3063
- color: var(--muted);
3071
+ padding: 0 9px;
3064
3072
  font-size: 12px;
3065
- line-height: 1.3;
3066
- white-space: normal;
3073
+ font-weight: 650;
3074
+ line-height: 1;
3075
+ text-align: center;
3076
+ white-space: nowrap;
3067
3077
  }
3068
3078
 
3069
- .local-option-row input {
3070
- margin: 0;
3071
- accent-color: var(--accent);
3079
+ .local-option-btn span {
3080
+ min-width: 0;
3081
+ overflow: hidden;
3082
+ text-overflow: ellipsis;
3083
+ white-space: nowrap;
3084
+ }
3085
+
3086
+ .local-option-btn:hover {
3087
+ border-color: rgba(111,66,232,0.42);
3088
+ color: var(--accent);
3089
+ background: rgba(111,66,232,0.07);
3090
+ }
3091
+
3092
+ .local-option-btn.active {
3093
+ border-color: rgba(111,66,232,0.48);
3094
+ background: rgba(111,66,232,0.11);
3095
+ color: var(--accent);
3096
+ box-shadow: inset 0 0 0 1px rgba(111,66,232,0.12);
3072
3097
  }
3073
3098
 
3074
3099
  .local-audit-grid {
@@ -3815,7 +3840,7 @@ body.lattice-ref-graph {
3815
3840
  display: none;
3816
3841
  position: fixed;
3817
3842
  inset: 0;
3818
- background: rgba(14,16,42,0.45);
3843
+ background: rgba(111,66,232,0.18);
3819
3844
  backdrop-filter: blur(4px);
3820
3845
  z-index: 1000;
3821
3846
  align-items: center;
@@ -3828,7 +3853,7 @@ body.lattice-ref-graph {
3828
3853
  display: none;
3829
3854
  position: fixed;
3830
3855
  inset: 0;
3831
- background: rgba(14,16,42,0.45);
3856
+ background: rgba(111,66,232,0.18);
3832
3857
  backdrop-filter: blur(4px);
3833
3858
  z-index: 1000;
3834
3859
  align-items: center;
@@ -3836,8 +3861,8 @@ body.lattice-ref-graph {
3836
3861
  }
3837
3862
  .mcp-modal-overlay.open { display: flex; }
3838
3863
  .mcp-modal {
3839
- background: var(--surface, #1e293b);
3840
- border: 1px solid rgba(255,255,255,0.08);
3864
+ background: rgba(255,255,255,0.96);
3865
+ border: 1px solid rgba(111,66,232,0.16);
3841
3866
  border-radius: 16px;
3842
3867
  width: 100%;
3843
3868
  max-width: 560px;
@@ -3849,12 +3874,12 @@ body.lattice-ref-graph {
3849
3874
  }
3850
3875
  .mcp-modal-header {
3851
3876
  padding: 18px 20px;
3852
- border-bottom: 1px solid rgba(255,255,255,0.07);
3877
+ border-bottom: 1px solid rgba(111,66,232,0.12);
3853
3878
  display: flex; align-items: center; justify-content: space-between;
3854
3879
  }
3855
3880
  .mcp-modal-header h3 { font-size: 15px; font-weight: 700; color: var(--text); }
3856
3881
  .mcp-modal-close { background: none; border: none; color: var(--faint); cursor: pointer; font-size: 18px; padding: 2px 6px; border-radius: 6px; }
3857
- .mcp-modal-close:hover { color: var(--text); background: rgba(255,255,255,0.07); }
3882
+ .mcp-modal-close:hover { color: var(--text); background: rgba(111,66,232,0.07); }
3858
3883
  .mcp-modal-body.lattice-ref-chat { flex: 1; overflow-y: auto; padding: 16px 20px; }
3859
3884
  .mcp-section-label { font-size: 10px; font-weight: 700; color: var(--faint); text-transform: uppercase; letter-spacing: .08em; margin: 12px 0 8px; }
3860
3885
  .mcp-item {
@@ -3917,8 +3942,8 @@ body.lattice-ref-graph {
3917
3942
  }
3918
3943
  .mcp-delete-btn:hover { background: rgba(255,80,80,0.18); }
3919
3944
  .acct-modal {
3920
- background: var(--surface, #1e293b);
3921
- border: 1px solid rgba(255,255,255,0.08);
3945
+ background: rgba(255,255,255,0.96);
3946
+ border: 1px solid rgba(111,66,232,0.16);
3922
3947
  border-radius: 16px;
3923
3948
  width: 100%;
3924
3949
  max-width: 380px;
@@ -3929,24 +3954,24 @@ body.lattice-ref-graph {
3929
3954
  }
3930
3955
  .acct-tabs {
3931
3956
  display: flex;
3932
- border-bottom: 1px solid rgba(255,255,255,0.07);
3957
+ border-bottom: 1px solid rgba(111,66,232,0.12);
3933
3958
  }
3934
3959
  .acct-tab {
3935
3960
  flex: 1; padding: 14px; font-size: 13px; font-weight: 500;
3936
3961
  background: none; border: none; color: var(--muted); cursor: pointer;
3937
3962
  transition: all .15s; border-bottom: 2px solid transparent;
3938
3963
  }
3939
- .acct-tab.active { color: var(--text, #f8fafc); border-bottom-color: var(--accent, #6366f1); }
3964
+ .acct-tab.active { color: var(--text, #14162c); border-bottom-color: var(--accent, #6366f1); }
3940
3965
  .acct-body.lattice-ref-chat { padding: 24px; display: flex; flex-direction: column; gap: 14px; }
3941
3966
  .acct-tab-panel { display: none; flex-direction: column; gap: 14px; }
3942
3967
  .acct-tab-panel.active { display: flex; }
3943
3968
  .pw-field { display: flex; flex-direction: column; gap: 5px; }
3944
3969
  .pw-field label { font-size: 11px; color: var(--muted); }
3945
3970
  .pw-field input {
3946
- background: rgba(14,16,42,0.25);
3947
- border: 1px solid rgba(255,255,255,0.08);
3971
+ background: rgba(255,255,255,0.88);
3972
+ border: 1px solid rgba(111,66,232,0.18);
3948
3973
  border-radius: 8px;
3949
- color: var(--text, #f8fafc);
3974
+ color: var(--text, #14162c);
3950
3975
  padding: 8px 12px;
3951
3976
  font-size: 13px;
3952
3977
  outline: none;
@@ -3958,8 +3983,8 @@ body.lattice-ref-graph {
3958
3983
  flex: 1; padding: 8px; border-radius: 8px; border: none;
3959
3984
  cursor: pointer; font-size: 13px; font-weight: 500; transition: all .15s;
3960
3985
  }
3961
- .pw-cancel { background: rgba(255,255,255,0.06); color: var(--muted); }
3962
- .pw-cancel:hover { background: rgba(255,255,255,0.1); }
3986
+ .pw-cancel { background: rgba(111,66,232,0.08); color: var(--muted); }
3987
+ .pw-cancel:hover { background: rgba(111,66,232,0.13); }
3963
3988
  .pw-submit { background: var(--accent, #6366f1); color: #fff; }
3964
3989
  .pw-submit:hover { opacity: 0.85; }
3965
3990
  .pw-msg { font-size: 12px; min-height: 16px; }
@@ -3972,8 +3997,8 @@ body.lattice-ref-graph {
3972
3997
  position: absolute;
3973
3998
  top: calc(100% + 6px);
3974
3999
  right: 0;
3975
- background: #1e293b;
3976
- border: 1px solid rgba(255,255,255,0.1);
4000
+ background: rgba(255,255,255,0.96);
4001
+ border: 1px solid rgba(111,66,232,0.16);
3977
4002
  border-radius: 10px;
3978
4003
  overflow: hidden;
3979
4004
  box-shadow: 0 8px 24px rgba(88,72,150,0.16);
@@ -3986,7 +4011,7 @@ body.lattice-ref-graph {
3986
4011
  padding: 9px 14px; font-size: 13px; cursor: pointer;
3987
4012
  color: var(--muted); transition: background .12s;
3988
4013
  }
3989
- .lang-option:hover { background: rgba(255,255,255,0.06); color: var(--text, #f8fafc); }
4014
+ .lang-option:hover { background: rgba(111,66,232,0.07); color: var(--text, #14162c); }
3990
4015
  .lang-option.active { color: var(--accent, #6366f1); font-weight: 600; }
3991
4016
 
3992
4017
  .auth-lang-picker {
@@ -4188,13 +4213,13 @@ body.lattice-ref-graph {
4188
4213
  overflow: hidden;
4189
4214
  border-radius: var(--radius-sm);
4190
4215
  margin: 12px 0;
4191
- background: #060810;
4216
+ background: rgba(255,255,255,0.86);
4192
4217
  border: 1px solid rgba(129,140,248,0.15);
4193
- box-shadow: 0 4px 16px rgba(0,0,0,0.3);
4218
+ box-shadow: 0 4px 16px rgba(88,72,150,0.10);
4194
4219
  }
4195
4220
 
4196
4221
  .code-header {
4197
- background: rgba(10,12,20,0.9);
4222
+ background: rgba(111,66,232,0.08);
4198
4223
  color: var(--muted);
4199
4224
  font-size: 11px;
4200
4225
  padding: 8px 12px;
@@ -4221,7 +4246,7 @@ body.lattice-ref-graph {
4221
4246
  white-space: pre;
4222
4247
  padding: 16px;
4223
4248
  font-size: 13px;
4224
- background: #060810;
4249
+ background: rgba(255,255,255,0.86);
4225
4250
  scrollbar-width: thin;
4226
4251
  font-family: 'JetBrains Mono', 'Fira Code', monospace;
4227
4252
  line-height: 1.6;
@@ -4261,7 +4286,7 @@ body.lattice-ref-graph {
4261
4286
  display: none;
4262
4287
  position: fixed;
4263
4288
  inset: 0;
4264
- background: rgba(14,16,42,0.40);
4289
+ background: rgba(111,66,232,0.18);
4265
4290
  z-index: 99;
4266
4291
  backdrop-filter: blur(2px);
4267
4292
  }
@@ -4709,8 +4734,8 @@ body.lattice-ref-graph {
4709
4734
 
4710
4735
  .auth-card {
4711
4736
  width: min(400px, 100%);
4712
- background: rgba(12,16,22,0.9);
4713
- border: 1px solid rgba(255,255,255,0.08);
4737
+ background: rgba(255,255,255,0.94);
4738
+ border: 1px solid rgba(111,66,232,0.16);
4714
4739
  border-radius: 20px;
4715
4740
  padding: 36px 32px;
4716
4741
  box-shadow: var(--shadow), 0 0 0 1px rgba(111,66,232,0.05);
@@ -5349,9 +5374,10 @@ body.lattice-ref-graph {
5349
5374
  .mcp-dropdown {
5350
5375
  display: none;
5351
5376
  margin-top: 8px;
5352
- border: 1px solid rgba(48, 167, 137, 0.22);
5377
+ border: 1px solid rgba(111,66,232,0.16);
5353
5378
  border-radius: 10px;
5354
- background: rgba(23, 27, 32, 0.92);
5379
+ background: rgba(255,255,255,0.92);
5380
+ box-shadow: 0 16px 36px rgba(96,72,160,0.12);
5355
5381
  overflow: hidden;
5356
5382
  }
5357
5383
 
@@ -5365,7 +5391,12 @@ body.lattice-ref-graph {
5365
5391
  justify-content: space-between;
5366
5392
  gap: 12px;
5367
5393
  padding: 11px 14px;
5368
- border-bottom: 1px solid rgba(111,66,232,0.08);
5394
+ border-bottom: 1px solid rgba(111,66,232,0.10);
5395
+ color: var(--text);
5396
+ }
5397
+
5398
+ .mcp-dropdown-item:hover {
5399
+ background: rgba(111,66,232,0.05);
5369
5400
  }
5370
5401
 
5371
5402
  .mcp-dropdown-item:last-child {
@@ -5381,6 +5412,7 @@ body.lattice-ref-graph {
5381
5412
  display: block;
5382
5413
  font-size: 13px;
5383
5414
  margin-bottom: 2px;
5415
+ color: var(--text);
5384
5416
  }
5385
5417
 
5386
5418
  .mcp-dropdown-item-info span {
@@ -5827,7 +5859,7 @@ body.lattice-ref-graph {
5827
5859
  transition: transform 0.25s cubic-bezier(0.4,0,0.2,1);
5828
5860
  border-right: 1px solid var(--border-strong);
5829
5861
  box-shadow: 4px 0 32px rgba(88,72,150,0.18);
5830
- background: #141715;
5862
+ background: #f6f0ff;
5831
5863
  backdrop-filter: none;
5832
5864
  -webkit-backdrop-filter: none;
5833
5865
  }
@@ -5888,7 +5920,7 @@ body.lattice-ref-graph {
5888
5920
  position: fixed;
5889
5921
  inset: 0;
5890
5922
  z-index: 9000;
5891
- background: rgba(10, 12, 15, 0.88);
5923
+ background: rgba(111, 66, 232, 0.18);
5892
5924
  backdrop-filter: blur(14px);
5893
5925
  align-items: center;
5894
5926
  justify-content: center;
@@ -6243,8 +6275,8 @@ body.lattice-ref-graph {
6243
6275
 
6244
6276
  /* ── MCP 드롭다운 개선 ── */
6245
6277
  .mcp-dropdown {
6246
- background: rgba(10,14,20,0.97) !important;
6247
- border: 1px solid rgba(111,66,232,0.14) !important;
6278
+ background: rgba(255,255,255,0.96) !important;
6279
+ border: 1px solid rgba(111,66,232,0.16) !important;
6248
6280
  border-radius: var(--radius) !important;
6249
6281
  box-shadow: var(--shadow), 0 0 20px rgba(111,66,232,0.08) !important;
6250
6282
  }
@@ -6477,17 +6509,17 @@ body.lattice-ref-graph {
6477
6509
 
6478
6510
  .app-layout .code-container,
6479
6511
  .app-layout pre {
6480
- background: #171925;
6512
+ background: rgba(255,255,255,0.88);
6481
6513
  border-color: rgba(218,225,255,0.10);
6482
6514
  }
6483
6515
 
6484
6516
  .app-layout .code-header {
6485
- background: #1d2030;
6517
+ background: rgba(111,66,232,0.08);
6486
6518
  border-bottom-color: rgba(218,225,255,0.09);
6487
6519
  }
6488
6520
 
6489
6521
  .app-layout .input-area {
6490
- background: linear-gradient(0deg, rgba(39,41,55,0.98) 0%, rgba(39,41,55,0.78) 64%, transparent 100%);
6522
+ background: linear-gradient(0deg, rgba(246,240,255,0.98) 0%, rgba(246,240,255,0.78) 64%, transparent 100%);
6491
6523
  }
6492
6524
 
6493
6525
  .app-layout .input-box {
@@ -6692,7 +6724,7 @@ body.lattice-ref-graph {
6692
6724
  align-items: center;
6693
6725
  justify-content: center;
6694
6726
  padding: 24px;
6695
- background: rgba(25, 21, 47, 0.24);
6727
+ background: rgba(111, 66, 232, 0.18);
6696
6728
  backdrop-filter: blur(14px);
6697
6729
  }
6698
6730
  .workspace-modal-overlay.open,
@@ -6714,10 +6746,10 @@ body.lattice-ref-graph {
6714
6746
  .mode-modal {
6715
6747
  width: min(520px, 100%);
6716
6748
  background:
6717
- radial-gradient(circle at 18% 8%, rgba(111,75,246,0.28), transparent 32%),
6718
- linear-gradient(180deg, #211b3b, #151329);
6719
- color: #fff;
6720
- border-color: rgba(255,255,255,0.12);
6749
+ radial-gradient(circle at 18% 8%, rgba(111,75,246,0.18), transparent 32%),
6750
+ linear-gradient(180deg, #ffffff, #f6f0ff);
6751
+ color: var(--text);
6752
+ border-color: rgba(111,66,232,0.16);
6721
6753
  }
6722
6754
  .modal-kicker {
6723
6755
  color: var(--accent);
@@ -137,6 +137,12 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
137
137
  el.className = 'msg' + (ok ? ' ok' : '');
138
138
  }
139
139
 
140
+ function requestSetupAfterLogin() {
141
+ try {
142
+ sessionStorage.setItem('ltcai_force_setup_after_login', 'true');
143
+ } catch (_) {}
144
+ }
145
+
140
146
  async function doLogin() {
141
147
  const email = document.getElementById('login-email').value.trim();
142
148
  const password = document.getElementById('login-pw').value;
@@ -155,7 +161,8 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
155
161
  localStorage.setItem('ltcai_user_email', data.email);
156
162
  localStorage.setItem('ltcai_user_nickname', data.nickname || data.name || data.email);
157
163
  localStorage.setItem('ltcai_is_admin', data.is_admin ? 'true' : 'false');
158
- window.location.href = '/chat';
164
+ requestSetupAfterLogin();
165
+ window.location.href = '/chat?setup=1';
159
166
  } else {
160
167
  const data = await res.json().catch(() => ({}));
161
168
  setMsg('login-msg', data.detail || t('err_login_fail'));
@@ -197,7 +204,8 @@ const API_BASE = window.location.protocol === 'file:' ? 'http://localhost:4825'
197
204
  localStorage.setItem('ltcai_user_email', data.email);
198
205
  localStorage.setItem('ltcai_user_nickname', data.nickname || data.name || data.email);
199
206
  localStorage.setItem('ltcai_is_admin', data.is_admin ? 'true' : 'false');
200
- window.location.href = '/chat';
207
+ requestSetupAfterLogin();
208
+ window.location.href = '/chat?setup=1';
201
209
  }
202
210
  });
203
211
  } else {
@@ -1195,4 +1195,300 @@ document.querySelectorAll('[data-export-scope][data-export-format]').forEach(btn
1195
1195
  btn.addEventListener('click', () => exportAdminLogs(btn.dataset.exportScope, btn.dataset.exportFormat));
1196
1196
  });
1197
1197
 
1198
+ // ── Security & Audit Command Center (피드백 #5) ─────────────────────────────
1199
+
1200
+ function ccEscape(value) {
1201
+ if (value === null || value === undefined) return '';
1202
+ const str = String(value);
1203
+ return str
1204
+ .replace(/&/g, '&')
1205
+ .replace(/</g, '&lt;')
1206
+ .replace(/>/g, '&gt;')
1207
+ .replace(/"/g, '&quot;')
1208
+ .replace(/'/g, '&#39;');
1209
+ }
1210
+
1211
+ const CC_CARD_LABELS = {
1212
+ events_today: '오늘 이벤트',
1213
+ high_risk_events: 'High Risk',
1214
+ risky_chats: '위험 채팅',
1215
+ risky_files: '위험 파일',
1216
+ secret_blocks: 'Secret 차단',
1217
+ external_blocks: '외부 전송 차단',
1218
+ admin_raw_views: '관리자 원문 조회',
1219
+ review_required: '검토 필요',
1220
+ };
1221
+
1222
+ let ccUserChart = null;
1223
+ let ccFieldChart = null;
1224
+
1225
+ async function ccFetchJson(path) {
1226
+ try {
1227
+ const res = await apiFetch(path, { headers: adminHeaders() });
1228
+ if (!res.ok) {
1229
+ console.warn('Security CC fetch failed', path, res.status);
1230
+ return null;
1231
+ }
1232
+ return await res.json();
1233
+ } catch (e) {
1234
+ console.warn('Security CC fetch error', path, e);
1235
+ return null;
1236
+ }
1237
+ }
1238
+
1239
+ function renderCcCards(overview) {
1240
+ const root = document.getElementById('security-cc-cards');
1241
+ if (!root || !overview || !overview.cards) return;
1242
+ const html = Object.entries(overview.cards).map(([key, value]) => `
1243
+ <div class="audit-card">
1244
+ <div class="audit-card-label">${ccEscape(CC_CARD_LABELS[key] || key)}</div>
1245
+ <div class="audit-card-value">${ccEscape(value)}</div>
1246
+ </div>
1247
+ `).join('');
1248
+ root.innerHTML = html;
1249
+ }
1250
+
1251
+ function renderCcUsersTable(users) {
1252
+ const wrap = document.getElementById('security-cc-users');
1253
+ if (!wrap) return;
1254
+ if (!users || users.length === 0) {
1255
+ wrap.innerHTML = '<div class="preview" style="padding:14px">표시할 사용자가 없습니다.</div>';
1256
+ return;
1257
+ }
1258
+ const rows = users.slice(0, 25).map(u => `
1259
+ <tr data-cc-user="${ccEscape(u.email)}" style="cursor:pointer">
1260
+ <td>${ccEscape(u.user)}</td>
1261
+ <td>${ccEscape(u.total_chats)}</td>
1262
+ <td style="color:#2c8a3f">${ccEscape(u.compliant_chats)}</td>
1263
+ <td style="color:#b13030">${ccEscape(u.risky_chats)}</td>
1264
+ <td>${ccEscape(u.uploaded_files)}</td>
1265
+ <td style="color:#2c8a3f">${ccEscape(u.compliant_files)}</td>
1266
+ <td style="color:#b13030">${ccEscape(u.risky_files)}</td>
1267
+ <td>${ccEscape(u.high_risk_events)}</td>
1268
+ <td>${ccEscape(u.risk_rate)}%</td>
1269
+ <td>${ccEscape((u.last_activity_at || '').slice(0, 19).replace('T', ' '))}</td>
1270
+ </tr>
1271
+ `).join('');
1272
+ wrap.innerHTML = `
1273
+ <table class="data-table">
1274
+ <thead><tr>
1275
+ <th>사용자</th><th>총 채팅</th><th>준수 채팅</th><th>위험 채팅</th>
1276
+ <th>총 파일</th><th>준수 파일</th><th>위험 파일</th>
1277
+ <th>High</th><th>위험률</th><th>마지막 활동</th>
1278
+ </tr></thead>
1279
+ <tbody>${rows}</tbody>
1280
+ </table>`;
1281
+ wrap.querySelectorAll('tr[data-cc-user]').forEach(tr => {
1282
+ tr.addEventListener('click', () => ccShowUserDrillDown(tr.dataset.ccUser));
1283
+ });
1284
+ }
1285
+
1286
+ function renderCcUserChart(users) {
1287
+ const canvas = document.getElementById('security-cc-user-chart');
1288
+ if (!canvas || typeof Chart === 'undefined') return;
1289
+ const top = users.slice(0, 8);
1290
+ const labels = top.map(u => u.user);
1291
+ if (ccUserChart) { ccUserChart.destroy(); ccUserChart = null; }
1292
+ ccUserChart = new Chart(canvas, {
1293
+ type: 'bar',
1294
+ data: {
1295
+ labels,
1296
+ datasets: [
1297
+ { label: '준수 채팅', backgroundColor: '#5cb874', data: top.map(u => u.compliant_chats) },
1298
+ { label: '위험 채팅', backgroundColor: '#e8636e', data: top.map(u => u.risky_chats) },
1299
+ { label: '준수 파일', backgroundColor: '#7fb5e6', data: top.map(u => u.compliant_files) },
1300
+ { label: '위험 파일', backgroundColor: '#d94c4c', data: top.map(u => u.risky_files) },
1301
+ ]
1302
+ },
1303
+ options: {
1304
+ responsive: true,
1305
+ scales: { x: { stacked: true }, y: { stacked: true } },
1306
+ plugins: { legend: { position: 'bottom' } },
1307
+ }
1308
+ });
1309
+ }
1310
+
1311
+ function renderCcFieldChart(overview) {
1312
+ const canvas = document.getElementById('security-cc-field-chart');
1313
+ const legend = document.getElementById('security-cc-field-legend');
1314
+ if (!canvas || typeof Chart === 'undefined') return;
1315
+ const counts = overview?.field_counts || {};
1316
+ const labels = Object.keys(counts);
1317
+ const data = labels.map(l => counts[l]);
1318
+ if (ccFieldChart) { ccFieldChart.destroy(); ccFieldChart = null; }
1319
+ if (labels.length === 0) {
1320
+ if (legend) legend.textContent = '감지된 민감정보 유형이 없습니다.';
1321
+ return;
1322
+ }
1323
+ ccFieldChart = new Chart(canvas, {
1324
+ type: 'doughnut',
1325
+ data: { labels, datasets: [{ data, backgroundColor: ['#e8636e','#7fb5e6','#f0b14a','#5cb874','#9b6cd0','#3da9b6','#d18cd4','#a3a3a3'] }] },
1326
+ options: { plugins: { legend: { position: 'bottom' } } }
1327
+ });
1328
+ if (legend) {
1329
+ legend.innerHTML = labels.map((l, i) => `${ccEscape(l)}: ${ccEscape(data[i])}`).join(' · ');
1330
+ }
1331
+ }
1332
+
1333
+ async function ccShowUserDrillDown(email) {
1334
+ const data = await ccFetchJson(`/admin/security/events?user=${encodeURIComponent(email)}`);
1335
+ const wrap = document.getElementById('security-cc-timeline');
1336
+ if (!wrap) return;
1337
+ const events = (data && data.events) || [];
1338
+ if (!events.length) {
1339
+ wrap.innerHTML = `<div class="preview" style="padding:14px">${ccEscape(email)} 사용자에 대한 이벤트가 없습니다.</div>`;
1340
+ return;
1341
+ }
1342
+ const rows = events.slice(0, 40).map(e => `
1343
+ <tr>
1344
+ <td>${ccEscape((e.timestamp || '').slice(0, 19).replace('T', ' '))}</td>
1345
+ <td>${ccEscape(e.event_type || '')}</td>
1346
+ <td>${ccEscape(e.sensitivity || 'none')}</td>
1347
+ <td>${ccEscape((e.sensitive_labels || []).join(', '))}</td>
1348
+ <td>${ccEscape((e.content_preview || '').slice(0, 80))}</td>
1349
+ </tr>
1350
+ `).join('');
1351
+ wrap.innerHTML = `
1352
+ <div style="margin-bottom:8px;color:var(--muted-text);font-size:12px">${ccEscape(email)} 사용자의 보안 이벤트 ${events.length}건</div>
1353
+ <table class="data-table">
1354
+ <thead><tr><th>시각</th><th>유형</th><th>민감도</th><th>라벨</th><th>마스킹 preview</th></tr></thead>
1355
+ <tbody>${rows}</tbody>
1356
+ </table>`;
1357
+ }
1358
+
1359
+ async function loadSecurityCommandCenter() {
1360
+ const [overview, usersResp, eventsResp, filesResp] = await Promise.all([
1361
+ ccFetchJson('/admin/security/overview'),
1362
+ ccFetchJson('/admin/security/users'),
1363
+ ccFetchJson('/admin/security/events?limit=50'),
1364
+ ccFetchJson('/admin/security/files'),
1365
+ ]);
1366
+
1367
+ if (overview) {
1368
+ renderCcCards(overview);
1369
+ renderCcFieldChart(overview);
1370
+ }
1371
+ if (usersResp && Array.isArray(usersResp.users)) {
1372
+ renderCcUsersTable(usersResp.users);
1373
+ renderCcUserChart(usersResp.users);
1374
+ }
1375
+ if (eventsResp && Array.isArray(eventsResp.events)) {
1376
+ const chats = eventsResp.events.filter(e => (e.sensitivity || 'none') !== 'none' && e.event_type === 'chat_message').slice(0, 20);
1377
+ const chatWrap = document.getElementById('security-cc-chats');
1378
+ if (chatWrap) {
1379
+ chatWrap.innerHTML = chats.length ? `
1380
+ <table class="data-table">
1381
+ <thead><tr><th>시각</th><th>사용자</th><th>민감도</th><th>라벨</th><th>마스킹 preview</th></tr></thead>
1382
+ <tbody>${chats.map(e => `
1383
+ <tr>
1384
+ <td>${ccEscape((e.timestamp || '').slice(0, 19).replace('T', ' '))}</td>
1385
+ <td>${ccEscape(e.user_nickname || e.user_email || 'Unknown')}</td>
1386
+ <td>${ccEscape(e.sensitivity)}</td>
1387
+ <td>${ccEscape((e.sensitive_labels || []).join(', '))}</td>
1388
+ <td>${ccEscape((e.content_preview || '').slice(0, 100))}</td>
1389
+ </tr>`).join('')}
1390
+ </tbody>
1391
+ </table>` : '<div class="preview" style="padding:14px">감지된 민감 채팅이 없습니다.</div>';
1392
+ }
1393
+ const timelineWrap = document.getElementById('security-cc-timeline');
1394
+ if (timelineWrap && !timelineWrap.querySelector('table')) {
1395
+ const rows = eventsResp.events.slice(0, 30).map(e => `
1396
+ <tr>
1397
+ <td>${ccEscape((e.timestamp || '').slice(0, 19).replace('T', ' '))}</td>
1398
+ <td>${ccEscape(e.event_type || '')}</td>
1399
+ <td>${ccEscape(e.user_nickname || e.user_email || 'Unknown')}</td>
1400
+ <td>${ccEscape(e.sensitivity || 'none')}</td>
1401
+ </tr>
1402
+ `).join('');
1403
+ timelineWrap.innerHTML = rows ? `
1404
+ <table class="data-table">
1405
+ <thead><tr><th>시각</th><th>유형</th><th>사용자</th><th>민감도</th></tr></thead>
1406
+ <tbody>${rows}</tbody>
1407
+ </table>` : '<div class="preview" style="padding:14px">감사 이벤트가 없습니다.</div>';
1408
+ }
1409
+ }
1410
+ if (filesResp && Array.isArray(filesResp.files)) {
1411
+ const files = filesResp.files.filter(f => (f.sensitivity || 'none') !== 'none' || (f.sensitive_labels || []).length > 0).slice(0, 20);
1412
+ const fileWrap = document.getElementById('security-cc-files');
1413
+ if (fileWrap) {
1414
+ fileWrap.innerHTML = files.length ? `
1415
+ <table class="data-table">
1416
+ <thead><tr><th>파일</th><th>업로드 사용자</th><th>민감도</th><th>라벨</th><th>크기</th></tr></thead>
1417
+ <tbody>${files.map(f => `
1418
+ <tr>
1419
+ <td>${ccEscape(f.filename || f.file_id)}</td>
1420
+ <td>${ccEscape(f.user_nickname || f.user_email || 'Unknown')}</td>
1421
+ <td>${ccEscape(f.sensitivity || 'none')}</td>
1422
+ <td>${ccEscape((f.sensitive_labels || []).join(', '))}</td>
1423
+ <td>${ccEscape(f.bytes || '')}</td>
1424
+ </tr>`).join('')}
1425
+ </tbody>
1426
+ </table>` : '<div class="preview" style="padding:14px">위험 등급 파일이 없습니다.</div>';
1427
+ }
1428
+ }
1429
+ }
1430
+
1431
+ async function ccLoadRaw(scope) {
1432
+ const pre = document.getElementById('security-cc-raw');
1433
+ if (!pre) return;
1434
+ pre.textContent = '불러오는 중...';
1435
+ try {
1436
+ const res = await apiFetch(`/admin/security/raw?scope=${encodeURIComponent(scope)}`, { headers: adminHeaders() });
1437
+ if (!res.ok) { pre.textContent = `요청 실패 (HTTP ${res.status})`; return; }
1438
+ const text = await res.text();
1439
+ try {
1440
+ pre.textContent = JSON.stringify(JSON.parse(text), null, 2);
1441
+ } catch (_) {
1442
+ pre.textContent = text;
1443
+ }
1444
+ } catch (e) {
1445
+ pre.textContent = String(e);
1446
+ }
1447
+ }
1448
+
1449
+ async function ccExport(scope, format) {
1450
+ try {
1451
+ const res = await apiFetch('/admin/security/export', {
1452
+ method: 'POST',
1453
+ headers: { ...adminHeaders(), 'Content-Type': 'application/json' },
1454
+ body: JSON.stringify({ scope, format }),
1455
+ });
1456
+ if (!res.ok) {
1457
+ alert('보안 리포트 추출 실패 (HTTP ' + res.status + ')');
1458
+ return;
1459
+ }
1460
+ const blob = await res.blob();
1461
+ const url = URL.createObjectURL(blob);
1462
+ const a = document.createElement('a');
1463
+ a.href = url;
1464
+ a.download = `security_${scope}.${format === 'xlsx' ? 'xlsx' : format}`;
1465
+ a.click();
1466
+ setTimeout(() => URL.revokeObjectURL(url), 5000);
1467
+ } catch (e) {
1468
+ alert(String(e));
1469
+ }
1470
+ }
1471
+
1472
+ document.getElementById('security-cc-export-toggle')?.addEventListener('click', () => {
1473
+ const opts = document.getElementById('security-cc-export-options');
1474
+ if (opts) opts.classList.toggle('open');
1475
+ });
1476
+ document.querySelectorAll('[data-cc-scope][data-cc-format]').forEach(btn => {
1477
+ btn.addEventListener('click', () => ccExport(btn.dataset.ccScope, btn.dataset.ccFormat));
1478
+ });
1479
+ document.querySelectorAll('[data-cc-raw]').forEach(btn => {
1480
+ btn.addEventListener('click', () => ccLoadRaw(btn.dataset.ccRaw));
1481
+ });
1482
+
1483
+ // 보안 탭 진입 시 자동 로드
1484
+ document.querySelectorAll('[data-admin-nav="security"]').forEach(el => {
1485
+ el.addEventListener('click', () => { setTimeout(loadSecurityCommandCenter, 50); });
1486
+ });
1487
+ // 메뉴 셀렉터를 모를 수도 있으니 hash 변경 시에도 시도
1488
+ window.addEventListener('hashchange', () => {
1489
+ if (location.hash.indexOf('security') >= 0) loadSecurityCommandCenter();
1490
+ });
1491
+
1198
1492
  loadDashboard();
1493
+ // 보안 콘솔도 첫 진입 시 로드 시도 (실패해도 무해)
1494
+ setTimeout(loadSecurityCommandCenter, 600);