ltcai 0.1.2 → 0.1.4

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
@@ -521,6 +521,31 @@
521
521
  .two-col, .form-grid { grid-template-columns: 1fr; }
522
522
  .field.full { grid-column: auto; }
523
523
  }
524
+
525
+ .lang-picker { position: relative; }
526
+ .lang-picker-menu {
527
+ display: none;
528
+ position: absolute;
529
+ top: calc(100% + 6px);
530
+ right: 0;
531
+ background: #1a1f1d;
532
+ border: 1px solid rgba(255,255,255,0.1);
533
+ border-radius: 10px;
534
+ padding: 4px;
535
+ min-width: 130px;
536
+ z-index: 100;
537
+ box-shadow: 0 8px 24px rgba(0,0,0,0.4);
538
+ }
539
+ .lang-picker-menu.open { display: block; }
540
+ .lang-option {
541
+ padding: 7px 10px;
542
+ border-radius: 7px;
543
+ cursor: pointer;
544
+ font-size: 13px;
545
+ color: var(--muted);
546
+ }
547
+ .lang-option:hover { background: rgba(255,255,255,0.06); color: var(--text); }
548
+ .lang-option.active { color: var(--accent); font-weight: 600; }
524
549
  </style>
525
550
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
526
551
  </head>
@@ -532,25 +557,32 @@
532
557
  <div class="brand-mark"><i class="ti ti-shield-lock"></i></div>
533
558
  <div>
534
559
  <h1>Lattice AI Admin</h1>
535
- <p>관리자 대시보드</p>
560
+ <p data-i18n="admin_sub">관리자 대시보드</p>
536
561
  </div>
537
562
  </div>
538
563
  <div class="top-actions">
539
- <a class="btn" href="/"><i class="ti ti-arrow-left"></i> 채팅으로</a>
540
- <button class="btn primary" id="refresh-btn" type="button"><i class="ti ti-refresh"></i> 새로고침</button>
541
- <button class="btn danger" id="logout-btn" type="button"><i class="ti ti-logout"></i> 로그아웃</button>
564
+ <a class="btn" href="/"><i class="ti ti-arrow-left"></i> <span data-i18n="btn_back">채팅으로</span></a>
565
+ <div class="lang-picker" id="admin-lang-picker">
566
+ <button class="btn" id="admin-lang-btn" onclick="toggleLangMenu('admin-lang-picker')" title="Language">🌐</button>
567
+ <div class="lang-picker-menu" id="admin-lang-picker-menu">
568
+ <div class="lang-option" id="admin-lang-ko" onclick="setLang('ko')">🇰🇷 한국어</div>
569
+ <div class="lang-option" id="admin-lang-en" onclick="setLang('en')">🇺🇸 English</div>
570
+ </div>
571
+ </div>
572
+ <button class="btn primary" id="refresh-btn" type="button"><i class="ti ti-refresh"></i> <span data-i18n="btn_refresh">새로고침</span></button>
573
+ <button class="btn danger" id="logout-btn" type="button"><i class="ti ti-logout"></i> <span data-i18n="btn_logout">로그아웃</span></button>
542
574
  </div>
543
575
  </header>
544
576
 
545
577
  <main class="content">
546
578
  <section class="hero">
547
579
  <div>
548
- <h2>관리자 대시보드</h2>
549
- <p>사용자, 민감도 로그, Private VPC, 서버 상태를 한 화면에서 관리합니다. 채팅 화면과 분리되어 있고, 필요할 때만 따로 열어볼 수 있습니다.</p>
580
+ <h2 data-i18n="hero_title">관리자 대시보드</h2>
581
+ <p data-i18n="hero_desc">사용자, 민감도 로그, Private VPC, 서버 상태를 한 화면에서 관리합니다. 채팅 화면과 분리되어 있고, 필요할 때만 따로 열어볼 수 있습니다.</p>
550
582
  </div>
551
583
  <div class="session-card">
552
584
  <div class="label">Current Session</div>
553
- <div class="value" id="session-value">세션 확인 중...</div>
585
+ <div class="value" id="session-value" data-i18n="checking_session">세션 확인 중...</div>
554
586
  </div>
555
587
  </section>
556
588
 
@@ -582,8 +614,8 @@
582
614
  <section class="panel" style="margin-bottom:0;">
583
615
  <div class="panel-header">
584
616
  <div>
585
- <h3>메시지 활동 (최근 14일)</h3>
586
- <p>사용자 메시지와 AI 응답 수를 날짜별로 표시합니다.</p>
617
+ <h3 data-i18n="chart_title">메시지 활동 (최근 14일)</h3>
618
+ <p data-i18n="chart_desc">사용자 메시지와 AI 응답 수를 날짜별로 표시합니다.</p>
587
619
  </div>
588
620
  </div>
589
621
  <div class="panel-body" style="padding:16px 20px;">
@@ -596,7 +628,7 @@
596
628
  <div class="panel-header">
597
629
  <div>
598
630
  <h3>Private VPC</h3>
599
- <p>네트워크 프로필과 운영 상태를 수정합니다.</p>
631
+ <p data-i18n="vpc_desc">네트워크 프로필과 운영 상태를 수정합니다.</p>
600
632
  </div>
601
633
  <span class="tag" id="admin-pill"><i class="ti ti-user-cog"></i> Admin</span>
602
634
  </div>
@@ -632,12 +664,12 @@
632
664
  </div>
633
665
  <div class="field full">
634
666
  <label for="vpc-notes">Notes</label>
635
- <textarea id="vpc-notes" placeholder="운영 메모"></textarea>
667
+ <textarea id="vpc-notes" data-i18n-ph="vpc_notes_ph" placeholder="운영 메모"></textarea>
636
668
  </div>
637
669
  </div>
638
670
  <div class="toolbar">
639
- <div class="status-copy" id="vpc-save-status">불러오는 중...</div>
640
- <button class="btn primary" id="save-vpc-btn" type="button"><i class="ti ti-device-floppy"></i> 저장</button>
671
+ <div class="status-copy" id="vpc-save-status" data-i18n="vpc_loading">불러오는 중...</div>
672
+ <button class="btn primary" id="save-vpc-btn" type="button"><i class="ti ti-device-floppy"></i> <span data-i18n="vpc_save">저장</span></button>
641
673
  </div>
642
674
  </div>
643
675
  </article>
@@ -646,13 +678,13 @@
646
678
  <div class="panel-header">
647
679
  <div>
648
680
  <h3>Current Session</h3>
649
- <p>현재 로그인한 계정과 서버 상태를 빠르게 확인합니다.</p>
681
+ <p data-i18n="session_desc">현재 로그인한 계정과 서버 상태를 빠르게 확인합니다.</p>
650
682
  </div>
651
683
  </div>
652
684
  <div class="panel-body">
653
685
  <div class="tag-row" id="session-tags"></div>
654
686
  <div class="footer-space"></div>
655
- <div class="notice" id="session-help">
687
+ <div class="notice" id="session-help" data-i18n="session_help_fail">
656
688
  로그인 정보가 없으면 이 화면의 관리자 API를 사용할 수 없습니다.
657
689
  </div>
658
690
  </div>
@@ -662,8 +694,8 @@
662
694
  <section class="panel">
663
695
  <div class="panel-header">
664
696
  <div>
665
- <h3>민감도 분석</h3>
666
- <p>감지된 위험 메시지와 준수 메시지를 분리해서 보여줍니다.</p>
697
+ <h3 data-i18n="sensitivity_title">민감도 분석</h3>
698
+ <p data-i18n="sensitivity_desc">감지된 위험 메시지와 준수 메시지를 분리해서 보여줍니다.</p>
667
699
  </div>
668
700
  <div class="tag-row" id="sensitivity-summary"></div>
669
701
  </div>
@@ -684,8 +716,8 @@
684
716
  <section class="panel">
685
717
  <div class="panel-header">
686
718
  <div>
687
- <h3>초대 링크</h3>
688
- <p>새 사용자를 초대할 링크를 확인하고 복사합니다.</p>
719
+ <h3 data-i18n="invite_title">초대 링크</h3>
720
+ <p data-i18n="invite_desc">새 사용자를 초대할 링크를 확인하고 복사합니다.</p>
689
721
  </div>
690
722
  </div>
691
723
  <div class="panel-body">
@@ -694,7 +726,7 @@
694
726
  style="flex:1;background:rgba(0,0,0,0.3);border:1px solid rgba(255,255,255,0.08);border-radius:8px;color:#f8fafc;padding:8px 12px;font-size:13px;outline:none;">
695
727
  <button onclick="copyInviteLink()" id="copy-invite-btn"
696
728
  style="background:#6366f1;color:#fff;border:none;border-radius:8px;padding:8px 16px;cursor:pointer;font-size:13px;white-space:nowrap;transition:opacity .15s;">
697
- 복사
729
+ <span data-i18n="btn_copy">복사</span>
698
730
  </button>
699
731
  </div>
700
732
  <div id="invite-gate-info" style="font-size:12px;color:#94a3b8;margin-top:8px;"></div>
@@ -704,13 +736,13 @@
704
736
  <section class="panel">
705
737
  <div class="panel-header">
706
738
  <div>
707
- <h3>사용자 관리</h3>
708
- <p>역할 변경, 비활성화, 삭제를 처리합니다.</p>
739
+ <h3 data-i18n="users_title">사용자 관리</h3>
740
+ <p data-i18n="users_desc">역할 변경, 비활성화, 삭제를 처리합니다.</p>
709
741
  </div>
710
742
  </div>
711
743
  <div class="panel-body">
712
744
  <div class="table-wrap" id="user-table-wrap">
713
- <div class="preview" style="padding: 14px;">불러오는 중...</div>
745
+ <div class="preview" style="padding: 14px;" data-i18n="loading">불러오는 중...</div>
714
746
  </div>
715
747
  </div>
716
748
  </section>
@@ -743,6 +775,120 @@
743
775
  };
744
776
  }
745
777
 
778
+ // ── i18n ─────────────────────────────────────────────────────────────
779
+ const A18N = {
780
+ ko: {
781
+ admin_sub: '관리자 대시보드', btn_back: '채팅으로', btn_refresh: '새로고침', btn_logout: '로그아웃',
782
+ hero_title: '관리자 대시보드',
783
+ hero_desc: '사용자, 민감도 로그, Private VPC, 서버 상태를 한 화면에서 관리합니다. 채팅 화면과 분리되어 있고, 필요할 때만 따로 열어볼 수 있습니다.',
784
+ checking_session: '세션 확인 중...',
785
+ meta_user_accounts: '사용자 계정 수', meta_recent_activity: '최근 대화 활동',
786
+ meta_need_admin: '관리자 권한 필요', meta_msg_unavailable: '최근 메시지 정보를 불러올 수 없음',
787
+ chart_title: '메시지 활동 (최근 14일)', chart_desc: '사용자 메시지와 AI 응답 수를 날짜별로 표시합니다.',
788
+ label_user: '사용자', label_email: '이메일', label_perm: '권한', label_none: '없음',
789
+ vpc_desc: '네트워크 프로필과 운영 상태를 수정합니다.',
790
+ vpc_notes_ph: '운영 메모', vpc_save: '저장', vpc_loading: '불러오는 중...',
791
+ vpc_saving: '저장 중...', vpc_saved: '저장되었습니다.', vpc_save_fail: '저장 실패',
792
+ vpc_default_profile: '기본 VPC 프로필을 사용 중입니다.', vpc_last_saved: '마지막 저장:',
793
+ vpc_standby: '대기', vpc_connected: '연결됨', vpc_needs_setup: '설정 필요',
794
+ session_desc: '현재 로그인한 계정과 서버 상태를 빠르게 확인합니다.',
795
+ session_no_info: '세션 정보가 없습니다',
796
+ session_help_ok: '이메일 헤더가 설정되어 관리자 API를 호출할 수 있습니다.',
797
+ session_help_fail: '채팅 화면에서 로그인한 뒤 이 화면을 열어야 관리자 API를 사용할 수 있습니다.',
798
+ sensitivity_title: '민감도 분석', sensitivity_desc: '감지된 위험 메시지와 준수 메시지를 분리해서 보여줍니다.',
799
+ sensitivity_risk: '위험', sensitivity_compliant: '준수', sensitivity_risk_rate: '위험률', sensitivity_high: '높음',
800
+ no_risk_fields: '감지된 위험 필드가 없습니다.', no_compliance_fields: '준수 항목이 없습니다.',
801
+ invite_title: '초대 링크', invite_desc: '새 사용자를 초대할 링크를 확인하고 복사합니다.',
802
+ btn_copy: '복사', copied: '복사됨 ✅',
803
+ invite_gate_active: '초대 게이트 활성화됨', invite_gate_inactive: '초대 게이트 비활성화 — 링크 없이도 접근 가능합니다.',
804
+ users_title: '사용자 관리', users_desc: '역할 변경, 비활성화, 삭제를 처리합니다.',
805
+ loading: '불러오는 중...', no_users: '사용자 데이터가 없습니다.',
806
+ status_active: '활성', status_inactive: '비활성',
807
+ btn_grant_admin: '관리자 지정', btn_revoke_admin: '권한 해제',
808
+ btn_activate: '활성화', btn_deactivate: '비활성화', btn_delete: '삭제',
809
+ confirm_delete: '사용자를 삭제할까요?',
810
+ err_no_admin: '관리자 권한이 없습니다. 채팅 화면에서 관리자 계정으로 로그인한 뒤 다시 열어주세요.',
811
+ err_partial: '일부 섹션을 불러오지 못했습니다:',
812
+ err_network: '네트워크 연결을 확인해 주세요.', err_load: '대시보드를 불러오지 못했습니다.',
813
+ section_summary: '요약', section_users: '사용자 목록', section_sensitivity: '민감 정보 분석',
814
+ },
815
+ en: {
816
+ admin_sub: 'Admin Dashboard', btn_back: 'Chat', btn_refresh: 'Refresh', btn_logout: 'Logout',
817
+ hero_title: 'Admin Dashboard',
818
+ hero_desc: 'Manage users, sensitivity logs, Private VPC, and server state — all in one screen.',
819
+ checking_session: 'Checking session...',
820
+ meta_user_accounts: 'User accounts', meta_recent_activity: 'Recent activity',
821
+ meta_need_admin: 'Admin permission required', meta_msg_unavailable: 'Could not load recent message info',
822
+ chart_title: 'Message Activity (Last 14 days)', chart_desc: 'User messages and AI responses by day.',
823
+ label_user: 'User', label_email: 'Email', label_perm: 'Role', label_none: 'None',
824
+ vpc_desc: 'Edit network profile and operating state.',
825
+ vpc_notes_ph: 'Operations notes', vpc_save: 'Save', vpc_loading: 'Loading...',
826
+ vpc_saving: 'Saving...', vpc_saved: 'Saved.', vpc_save_fail: 'Save failed',
827
+ vpc_default_profile: 'Using default VPC profile.', vpc_last_saved: 'Last saved:',
828
+ vpc_standby: 'Standby', vpc_connected: 'Connected', vpc_needs_setup: 'Setup required',
829
+ session_desc: 'Quickly check the current login account and server state.',
830
+ session_no_info: 'No session info',
831
+ session_help_ok: 'Email header is set — admin API calls are available.',
832
+ session_help_fail: 'Log in from the chat screen first, then open this screen.',
833
+ sensitivity_title: 'Sensitivity Analysis', sensitivity_desc: 'Shows detected risk messages and compliant messages separately.',
834
+ sensitivity_risk: 'Risk', sensitivity_compliant: 'Compliant', sensitivity_risk_rate: 'Risk rate', sensitivity_high: 'High',
835
+ no_risk_fields: 'No risk fields detected.', no_compliance_fields: 'No compliance items.',
836
+ invite_title: 'Invite Link', invite_desc: 'View and copy the link to invite new users.',
837
+ btn_copy: 'Copy', copied: 'Copied ✅',
838
+ invite_gate_active: 'Invite gate active', invite_gate_inactive: 'Invite gate disabled — accessible without link.',
839
+ users_title: 'User Management', users_desc: 'Handle role changes, disables, and deletions.',
840
+ loading: 'Loading...', no_users: 'No user data.',
841
+ status_active: 'Active', status_inactive: 'Inactive',
842
+ btn_grant_admin: 'Make Admin', btn_revoke_admin: 'Remove Admin',
843
+ btn_activate: 'Activate', btn_deactivate: 'Deactivate', btn_delete: 'Delete',
844
+ confirm_delete: 'Delete this user?',
845
+ err_no_admin: 'No admin permission. Log in as admin from the chat screen.',
846
+ err_partial: 'Failed to load some sections:',
847
+ err_network: 'Please check your network connection.', err_load: 'Could not load dashboard.',
848
+ section_summary: 'Summary', section_users: 'User list', section_sensitivity: 'Sensitivity report',
849
+ }
850
+ };
851
+
852
+ let currentLang = localStorage.getItem('ltcai_lang') || 'ko';
853
+ function t(key) { return (A18N[currentLang] || A18N.ko)[key] || key; }
854
+
855
+ function applyI18n() {
856
+ document.querySelectorAll('[data-i18n]').forEach(el => {
857
+ const val = t(el.dataset.i18n);
858
+ if (val) el.textContent = val;
859
+ });
860
+ document.querySelectorAll('[data-i18n-ph]').forEach(el => {
861
+ const val = t(el.dataset.i18nPh);
862
+ if (val) el.placeholder = val;
863
+ });
864
+ ['ko', 'en'].forEach(lang => {
865
+ const el = document.getElementById(`admin-lang-${lang}`);
866
+ if (el) el.classList.toggle('active', lang === currentLang);
867
+ });
868
+ }
869
+
870
+ function toggleLangMenu(pickerId) {
871
+ const menu = document.getElementById(`${pickerId}-menu`);
872
+ const isOpen = menu.classList.contains('open');
873
+ document.querySelectorAll('.lang-picker-menu').forEach(m => m.classList.remove('open'));
874
+ if (!isOpen) menu.classList.add('open');
875
+ }
876
+
877
+ function setLang(lang) {
878
+ currentLang = lang;
879
+ localStorage.setItem('ltcai_lang', lang);
880
+ document.querySelectorAll('.lang-picker-menu').forEach(m => m.classList.remove('open'));
881
+ applyI18n();
882
+ loadDashboard();
883
+ }
884
+
885
+ document.addEventListener('click', e => {
886
+ if (!e.target.closest('.lang-picker')) {
887
+ document.querySelectorAll('.lang-picker-menu').forEach(m => m.classList.remove('open'));
888
+ }
889
+ });
890
+ // ─────────────────────────────────────────────────────────────────────
891
+
746
892
  let activityChartInstance = null;
747
893
  function renderActivityChart(daily) {
748
894
  const labels = daily.map(d => d.date);
@@ -755,7 +901,7 @@
755
901
  data: {
756
902
  labels,
757
903
  datasets: [
758
- { label: '사용자', data: userData, backgroundColor: 'rgba(99,102,241,0.7)', borderRadius: 4 },
904
+ { label: t('label_user'), data: userData, backgroundColor: 'rgba(99,102,241,0.7)', borderRadius: 4 },
759
905
  { label: 'AI', data: aiData, backgroundColor: 'rgba(168,85,247,0.5)', borderRadius: 4 }
760
906
  ]
761
907
  },
@@ -775,8 +921,8 @@
775
921
  const btn = document.getElementById('copy-invite-btn');
776
922
  try {
777
923
  await navigator.clipboard.writeText(url);
778
- btn.textContent = '복사됨 ✅';
779
- setTimeout(() => btn.textContent = '복사', 2000);
924
+ btn.textContent = t('copied');
925
+ setTimeout(() => btn.textContent = t('btn_copy'), 2000);
780
926
  } catch {
781
927
  document.getElementById('invite-link-input').select();
782
928
  }
@@ -798,21 +944,21 @@
798
944
  }
799
945
 
800
946
  function vpcHealthText(config) {
801
- if (!config) return '대기';
802
- if (config.vpn_status === 'connected' || config.peering_status === 'active') return '연결됨';
803
- if (config.vpn_status === 'standby') return '대기';
804
- return config.vpn_status || config.peering_status || '설정 필요';
947
+ if (!config) return t('vpc_standby');
948
+ if (config.vpn_status === 'connected' || config.peering_status === 'active') return t('vpc_connected');
949
+ if (config.vpn_status === 'standby') return t('vpc_standby');
950
+ return config.vpn_status || config.peering_status || t('vpc_needs_setup');
805
951
  }
806
952
 
807
953
  function setSessionInfo() {
808
954
  const email = currentUserEmail();
809
955
  const nick = currentUserNickname();
810
956
  const isAdmin = currentUserIsAdmin();
811
- document.getElementById('session-value').textContent = email ? `${nick} <${email}>` : '세션 정보가 없습니다';
957
+ document.getElementById('session-value').textContent = email ? `${nick} <${email}>` : t('session_no_info');
812
958
  const tags = [
813
- ['사용자', nick, 'low'],
814
- ['이메일', email || '없음', 'medium'],
815
- ['권한', isAdmin ? 'admin' : 'user', isAdmin ? 'low' : 'medium']
959
+ [t('label_user'), nick, 'low'],
960
+ [t('label_email'), email || t('label_none'), 'medium'],
961
+ [t('label_perm'), isAdmin ? 'admin' : 'user', isAdmin ? 'low' : 'medium']
816
962
  ];
817
963
  document.getElementById('session-tags').innerHTML = tags.map(([label, value, tone]) => `
818
964
  <span class="tag ${tone}"><span>${esc(label)}</span> ${esc(value)}</span>
@@ -820,10 +966,9 @@
820
966
  document.getElementById('admin-pill').innerHTML = isAdmin
821
967
  ? '<i class="ti ti-shield-check"></i> Admin'
822
968
  : '<i class="ti ti-lock"></i> Read only';
823
- const help = document.getElementById('session-help');
824
- help.innerHTML = email
825
- ? '이메일 헤더가 설정되어 관리자 API를 호출할 수 있습니다.'
826
- : '채팅 화면에서 로그인한 뒤 이 화면을 열어야 관리자 API를 사용할 수 있습니다.';
969
+ document.getElementById('session-help').textContent = email
970
+ ? t('session_help_ok')
971
+ : t('session_help_fail');
827
972
  }
828
973
 
829
974
  function fillVpcForm(config) {
@@ -837,8 +982,8 @@
837
982
  document.getElementById('vpc-subnets').value = (config.private_subnets || []).join(', ');
838
983
  document.getElementById('vpc-notes').value = config.notes || '';
839
984
  document.getElementById('vpc-save-status').textContent = config.updated_at
840
- ? `마지막 저장: ${new Date(config.updated_at).toLocaleString()}`
841
- : '기본 VPC 프로필을 사용 중입니다.';
985
+ ? `${t('vpc_last_saved')} ${new Date(config.updated_at).toLocaleString()}`
986
+ : t('vpc_default_profile');
842
987
 
843
988
  const provider = config.provider || 'VPC';
844
989
  const region = config.region || '-';
@@ -850,11 +995,11 @@
850
995
  document.getElementById('total-users').textContent = summary ? summary.total_users : '-';
851
996
  document.getElementById('total-users-meta').textContent = summary
852
997
  ? `${summary.active_users} active · ${summary.admin_users} admins`
853
- : '관리자 권한 필요';
998
+ : t('meta_need_admin');
854
999
  document.getElementById('total-messages').textContent = summary ? summary.total_messages : '-';
855
1000
  document.getElementById('total-messages-meta').textContent = summary
856
1001
  ? `user ${summary.user_messages} · assistant ${summary.assistant_messages}`
857
- : '최근 메시지 정보를 불러올 수 없음';
1002
+ : t('meta_msg_unavailable');
858
1003
  document.getElementById('current-model').textContent = compactModelName(health?.current_model);
859
1004
  document.getElementById('current-model-meta').textContent = `${health?.loaded_models?.length || 0} loaded · ${health?.device || 'local runtime'}`;
860
1005
  document.getElementById('vpc-status').textContent = `${vpc?.provider || '-'} ${vpc?.region || '-'}`;
@@ -868,10 +1013,10 @@
868
1013
  const userCounts = summary.user_counts || {};
869
1014
 
870
1015
  const tags = [
871
- ['risk', `위험 ${summary.risky_messages || 0}`],
872
- ['low', `준수 ${summary.compliant_messages || 0}`],
873
- ['medium', `위험률 ${summary.risk_rate || 0}%`],
874
- ['high', `높음 ${severity.high || 0}`]
1016
+ ['risk', `${t('sensitivity_risk')} ${summary.risky_messages || 0}`],
1017
+ ['low', `${t('sensitivity_compliant')} ${summary.compliant_messages || 0}`],
1018
+ ['medium', `${t('sensitivity_risk_rate')} ${summary.risk_rate || 0}%`],
1019
+ ['high', `${t('sensitivity_high')} ${severity.high || 0}`]
875
1020
  ];
876
1021
  document.getElementById('sensitivity-summary').innerHTML = tags.map(([tone, label]) => `<span class="tag ${tone}">${esc(label)}</span>`).join('');
877
1022
 
@@ -890,7 +1035,7 @@
890
1035
  <div class="preview">${esc(item.preview || '')}</div>
891
1036
  </div>
892
1037
  `).join('')
893
- : '<div class="preview">감지된 위험 필드가 없습니다.</div>';
1038
+ : `<div class="preview">${t('no_risk_fields')}</div>`;
894
1039
 
895
1040
  document.getElementById('compliance-fields').innerHTML = complianceList.length
896
1041
  ? complianceList.slice().reverse().map(item => `
@@ -904,7 +1049,7 @@
904
1049
  <div class="preview">${esc(item.preview || '')}</div>
905
1050
  </div>
906
1051
  `).join('')
907
- : '<div class="preview">준수 항목이 없습니다.</div>';
1052
+ : `<div class="preview">${t('no_compliance_fields')}</div>`;
908
1053
 
909
1054
  const fieldTags = Object.entries(fieldCounts).map(([label, count]) => `<span class="tag medium">${esc(label)} ${esc(count)}</span>`);
910
1055
  const userTags = Object.entries(userCounts).map(([label, count]) => `<span class="tag high">${esc(label)} ${esc(count)}</span>`);
@@ -914,7 +1059,7 @@
914
1059
  function renderUsers(users) {
915
1060
  const wrap = document.getElementById('user-table-wrap');
916
1061
  if (!Array.isArray(users) || !users.length) {
917
- wrap.innerHTML = '<div class="preview" style="padding:14px">사용자 데이터가 없습니다.</div>';
1062
+ wrap.innerHTML = `<div class="preview" style="padding:14px">${t('no_users')}</div>`;
918
1063
  return;
919
1064
  }
920
1065
  wrap.innerHTML = `
@@ -936,24 +1081,24 @@
936
1081
  <td>${esc(user.name || '-')}</td>
937
1082
  <td>${esc(user.nickname || '-')}</td>
938
1083
  <td><span class="role">${esc(user.role || '-')}</span></td>
939
- <td>${user.disabled ? '비활성' : '활성'}</td>
1084
+ <td>${user.disabled ? t('status_inactive') : t('status_active')}</td>
940
1085
  <td>
941
1086
  <div class="actions">
942
1087
  <button class="table-btn"
943
1088
  data-action="role"
944
1089
  data-email="${esc(user.email)}"
945
1090
  data-next-role="${user.role === 'admin' ? 'user' : 'admin'}">
946
- ${user.role === 'admin' ? '권한 해제' : '관리자 지정'}
1091
+ ${user.role === 'admin' ? t('btn_revoke_admin') : t('btn_grant_admin')}
947
1092
  </button>
948
1093
  <button class="table-btn"
949
1094
  data-action="disable"
950
1095
  data-email="${esc(user.email)}"
951
1096
  data-disabled="${user.disabled ? 'false' : 'true'}">
952
- ${user.disabled ? '활성화' : '비활성화'}
1097
+ ${user.disabled ? t('btn_activate') : t('btn_deactivate')}
953
1098
  </button>
954
1099
  <button class="table-btn danger"
955
1100
  data-action="delete"
956
- data-email="${esc(user.email)}">삭제</button>
1101
+ data-email="${esc(user.email)}">${t('btn_delete')}</button>
957
1102
  </div>
958
1103
  </td>
959
1104
  </tr>
@@ -983,7 +1128,7 @@
983
1128
  });
984
1129
  await loadDashboard();
985
1130
  } else if (action === 'delete') {
986
- if (!confirm(`'${email}' 사용자를 삭제할까요?`)) return;
1131
+ if (!confirm(`'${email}' ${t('confirm_delete')}`)) return;
987
1132
  await apiFetch(`/admin/users/${encodedEmail}`, {
988
1133
  method: 'DELETE', headers: adminHeaders()
989
1134
  });
@@ -993,6 +1138,7 @@
993
1138
 
994
1139
  async function loadDashboard() {
995
1140
  setSessionInfo();
1141
+ applyI18n();
996
1142
 
997
1143
  const access = document.getElementById('access-notice');
998
1144
  access.style.display = 'none';
@@ -1023,27 +1169,27 @@
1023
1169
  if (invite) {
1024
1170
  document.getElementById('invite-link-input').value = invite.invite_url;
1025
1171
  document.getElementById('invite-gate-info').textContent = invite.gate_enabled
1026
- ? `초대 코드: ${invite.invite_code} — 초대 게이트 활성화됨`
1027
- : '초대 게이트 비활성화 상태 — 링크 없이도 접근 가능합니다.';
1172
+ ? `${t('invite_gate_active')} ${invite.invite_code}`
1173
+ : t('invite_gate_inactive');
1028
1174
  }
1029
1175
  if (stats) renderActivityChart(stats.daily);
1030
1176
 
1031
1177
  const failedSections = [];
1032
- if (!summaryRes.ok) failedSections.push('요약');
1033
- if (!usersRes.ok) failedSections.push('사용자 목록');
1034
- if (!sensitivityRes.ok) failedSections.push('민감 정보 분석');
1178
+ if (!summaryRes.ok) failedSections.push(t('section_summary'));
1179
+ if (!usersRes.ok) failedSections.push(t('section_users'));
1180
+ if (!sensitivityRes.ok) failedSections.push(t('section_sensitivity'));
1035
1181
 
1036
1182
  if (failedSections.length) {
1037
1183
  access.style.display = 'block';
1038
1184
  access.textContent = summaryRes.status === 403
1039
- ? '관리자 권한이 없습니다. 채팅 화면에서 관리자 계정으로 로그인한 뒤 다시 열어주세요.'
1040
- : `일부 섹션을 불러오지 못했습니다: ${failedSections.join(', ')}`;
1185
+ ? t('err_no_admin')
1186
+ : `${t('err_partial')} ${failedSections.join(', ')}`;
1041
1187
  }
1042
1188
  } catch (e) {
1043
1189
  access.style.display = 'block';
1044
1190
  access.textContent = !navigator.onLine
1045
- ? '네트워크 연결을 확인해 주세요.'
1046
- : (e.message || '대시보드를 불러오지 못했습니다.');
1191
+ ? t('err_network')
1192
+ : (e.message || t('err_load'));
1047
1193
  }
1048
1194
  }
1049
1195
 
@@ -1059,7 +1205,7 @@
1059
1205
  notes: document.getElementById('vpc-notes').value.trim()
1060
1206
  };
1061
1207
  const status = document.getElementById('vpc-save-status');
1062
- status.textContent = '저장 중...';
1208
+ status.textContent = t('vpc_saving');
1063
1209
  try {
1064
1210
  const res = await apiFetch('/admin/vpc', {
1065
1211
  method: 'PATCH',
@@ -1067,12 +1213,12 @@
1067
1213
  body: JSON.stringify(payload)
1068
1214
  });
1069
1215
  const data = await res.json().catch(() => ({}));
1070
- if (!res.ok) throw new Error(data.detail || '저장 실패');
1216
+ if (!res.ok) throw new Error(data.detail || t('vpc_save_fail'));
1071
1217
  fillVpcForm(data);
1072
- status.textContent = '저장되었습니다.';
1218
+ status.textContent = t('vpc_saved');
1073
1219
  await loadDashboard();
1074
1220
  } catch (e) {
1075
- status.textContent = e.message || '저장 실패';
1221
+ status.textContent = e.message || t('vpc_save_fail');
1076
1222
  }
1077
1223
  }
1078
1224
 
@@ -1089,6 +1235,7 @@
1089
1235
  document.getElementById('refresh-btn').addEventListener('click', loadDashboard);
1090
1236
  document.getElementById('save-vpc-btn').addEventListener('click', saveVpc);
1091
1237
  document.getElementById('logout-btn').addEventListener('click', logout);
1238
+ applyI18n();
1092
1239
  loadDashboard();
1093
1240
  </script>
1094
1241
  </body>