ltcai 0.1.1 → 0.1.3

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,7 +521,33 @@
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>
550
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
525
551
  </head>
526
552
 
527
553
  <body>
@@ -531,25 +557,32 @@
531
557
  <div class="brand-mark"><i class="ti ti-shield-lock"></i></div>
532
558
  <div>
533
559
  <h1>Lattice AI Admin</h1>
534
- <p>관리자 대시보드</p>
560
+ <p data-i18n="admin_sub">관리자 대시보드</p>
535
561
  </div>
536
562
  </div>
537
563
  <div class="top-actions">
538
- <a class="btn" href="/"><i class="ti ti-arrow-left"></i> 채팅으로</a>
539
- <button class="btn primary" id="refresh-btn" type="button"><i class="ti ti-refresh"></i> 새로고침</button>
540
- <button class="btn danger" id="logout-btn" type="button"><i class="ti ti-logout"></i> 로그아웃</button>
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>
541
574
  </div>
542
575
  </header>
543
576
 
544
577
  <main class="content">
545
578
  <section class="hero">
546
579
  <div>
547
- <h2>관리자 대시보드</h2>
548
- <p>사용자, 민감도 로그, Private VPC, 서버 상태를 한 화면에서 관리합니다. 채팅 화면과 분리되어 있고, 필요할 때만 따로 열어볼 수 있습니다.</p>
580
+ <h2 data-i18n="hero_title">관리자 대시보드</h2>
581
+ <p data-i18n="hero_desc">사용자, 민감도 로그, Private VPC, 서버 상태를 한 화면에서 관리합니다. 채팅 화면과 분리되어 있고, 필요할 때만 따로 열어볼 수 있습니다.</p>
549
582
  </div>
550
583
  <div class="session-card">
551
584
  <div class="label">Current Session</div>
552
- <div class="value" id="session-value">세션 확인 중...</div>
585
+ <div class="value" id="session-value" data-i18n="checking_session">세션 확인 중...</div>
553
586
  </div>
554
587
  </section>
555
588
 
@@ -578,12 +611,24 @@
578
611
  </div>
579
612
  </section>
580
613
 
614
+ <section class="panel" style="margin-bottom:0;">
615
+ <div class="panel-header">
616
+ <div>
617
+ <h3 data-i18n="chart_title">메시지 활동 (최근 14일)</h3>
618
+ <p data-i18n="chart_desc">사용자 메시지와 AI 응답 수를 날짜별로 표시합니다.</p>
619
+ </div>
620
+ </div>
621
+ <div class="panel-body" style="padding:16px 20px;">
622
+ <canvas id="activity-chart" height="80"></canvas>
623
+ </div>
624
+ </section>
625
+
581
626
  <section class="panel-grid">
582
627
  <article class="panel">
583
628
  <div class="panel-header">
584
629
  <div>
585
630
  <h3>Private VPC</h3>
586
- <p>네트워크 프로필과 운영 상태를 수정합니다.</p>
631
+ <p data-i18n="vpc_desc">네트워크 프로필과 운영 상태를 수정합니다.</p>
587
632
  </div>
588
633
  <span class="tag" id="admin-pill"><i class="ti ti-user-cog"></i> Admin</span>
589
634
  </div>
@@ -619,12 +664,12 @@
619
664
  </div>
620
665
  <div class="field full">
621
666
  <label for="vpc-notes">Notes</label>
622
- <textarea id="vpc-notes" placeholder="운영 메모"></textarea>
667
+ <textarea id="vpc-notes" data-i18n-ph="vpc_notes_ph" placeholder="운영 메모"></textarea>
623
668
  </div>
624
669
  </div>
625
670
  <div class="toolbar">
626
- <div class="status-copy" id="vpc-save-status">불러오는 중...</div>
627
- <button class="btn primary" id="save-vpc-btn" type="button"><i class="ti ti-device-floppy"></i> 저장</button>
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>
628
673
  </div>
629
674
  </div>
630
675
  </article>
@@ -633,13 +678,13 @@
633
678
  <div class="panel-header">
634
679
  <div>
635
680
  <h3>Current Session</h3>
636
- <p>현재 로그인한 계정과 서버 상태를 빠르게 확인합니다.</p>
681
+ <p data-i18n="session_desc">현재 로그인한 계정과 서버 상태를 빠르게 확인합니다.</p>
637
682
  </div>
638
683
  </div>
639
684
  <div class="panel-body">
640
685
  <div class="tag-row" id="session-tags"></div>
641
686
  <div class="footer-space"></div>
642
- <div class="notice" id="session-help">
687
+ <div class="notice" id="session-help" data-i18n="session_help_fail">
643
688
  로그인 정보가 없으면 이 화면의 관리자 API를 사용할 수 없습니다.
644
689
  </div>
645
690
  </div>
@@ -649,8 +694,8 @@
649
694
  <section class="panel">
650
695
  <div class="panel-header">
651
696
  <div>
652
- <h3>민감도 분석</h3>
653
- <p>감지된 위험 메시지와 준수 메시지를 분리해서 보여줍니다.</p>
697
+ <h3 data-i18n="sensitivity_title">민감도 분석</h3>
698
+ <p data-i18n="sensitivity_desc">감지된 위험 메시지와 준수 메시지를 분리해서 보여줍니다.</p>
654
699
  </div>
655
700
  <div class="tag-row" id="sensitivity-summary"></div>
656
701
  </div>
@@ -671,13 +716,33 @@
671
716
  <section class="panel">
672
717
  <div class="panel-header">
673
718
  <div>
674
- <h3>사용자 관리</h3>
675
- <p>역할 변경, 비활성화, 삭제를 처리합니다.</p>
719
+ <h3 data-i18n="invite_title">초대 링크</h3>
720
+ <p data-i18n="invite_desc">새 사용자를 초대할 링크를 확인하고 복사합니다.</p>
721
+ </div>
722
+ </div>
723
+ <div class="panel-body">
724
+ <div style="display:flex;gap:8px;align-items:center;">
725
+ <input id="invite-link-input" type="text" readonly
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;">
727
+ <button onclick="copyInviteLink()" id="copy-invite-btn"
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;">
729
+ <span data-i18n="btn_copy">복사</span>
730
+ </button>
731
+ </div>
732
+ <div id="invite-gate-info" style="font-size:12px;color:#94a3b8;margin-top:8px;"></div>
733
+ </div>
734
+ </section>
735
+
736
+ <section class="panel">
737
+ <div class="panel-header">
738
+ <div>
739
+ <h3 data-i18n="users_title">사용자 관리</h3>
740
+ <p data-i18n="users_desc">역할 변경, 비활성화, 삭제를 처리합니다.</p>
676
741
  </div>
677
742
  </div>
678
743
  <div class="panel-body">
679
744
  <div class="table-wrap" id="user-table-wrap">
680
- <div class="preview" style="padding: 14px;">불러오는 중...</div>
745
+ <div class="preview" style="padding: 14px;" data-i18n="loading">불러오는 중...</div>
681
746
  </div>
682
747
  </div>
683
748
  </section>
@@ -710,6 +775,159 @@
710
775
  };
711
776
  }
712
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
+
892
+ let activityChartInstance = null;
893
+ function renderActivityChart(daily) {
894
+ const labels = daily.map(d => d.date);
895
+ const userData = daily.map(d => d.user);
896
+ const aiData = daily.map(d => d.assistant);
897
+ const ctx = document.getElementById('activity-chart').getContext('2d');
898
+ if (activityChartInstance) activityChartInstance.destroy();
899
+ activityChartInstance = new Chart(ctx, {
900
+ type: 'bar',
901
+ data: {
902
+ labels,
903
+ datasets: [
904
+ { label: t('label_user'), data: userData, backgroundColor: 'rgba(99,102,241,0.7)', borderRadius: 4 },
905
+ { label: 'AI', data: aiData, backgroundColor: 'rgba(168,85,247,0.5)', borderRadius: 4 }
906
+ ]
907
+ },
908
+ options: {
909
+ responsive: true,
910
+ plugins: { legend: { labels: { color: '#94a3b8', font: { size: 12 } } } },
911
+ scales: {
912
+ x: { ticks: { color: '#64748b' }, grid: { color: 'rgba(255,255,255,0.04)' } },
913
+ y: { ticks: { color: '#64748b', stepSize: 1 }, grid: { color: 'rgba(255,255,255,0.04)' }, beginAtZero: true }
914
+ }
915
+ }
916
+ });
917
+ }
918
+
919
+ async function copyInviteLink() {
920
+ const url = document.getElementById('invite-link-input').value;
921
+ const btn = document.getElementById('copy-invite-btn');
922
+ try {
923
+ await navigator.clipboard.writeText(url);
924
+ btn.textContent = t('copied');
925
+ setTimeout(() => btn.textContent = t('btn_copy'), 2000);
926
+ } catch {
927
+ document.getElementById('invite-link-input').select();
928
+ }
929
+ }
930
+
713
931
  function esc(value) {
714
932
  return String(value ?? '')
715
933
  .replace(/&/g, '&amp;')
@@ -726,21 +944,21 @@
726
944
  }
727
945
 
728
946
  function vpcHealthText(config) {
729
- if (!config) return '대기';
730
- if (config.vpn_status === 'connected' || config.peering_status === 'active') return '연결됨';
731
- if (config.vpn_status === 'standby') return '대기';
732
- return config.vpn_status || config.peering_status || '설정 필요';
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');
733
951
  }
734
952
 
735
953
  function setSessionInfo() {
736
954
  const email = currentUserEmail();
737
955
  const nick = currentUserNickname();
738
956
  const isAdmin = currentUserIsAdmin();
739
- document.getElementById('session-value').textContent = email ? `${nick} <${email}>` : '세션 정보가 없습니다';
957
+ document.getElementById('session-value').textContent = email ? `${nick} <${email}>` : t('session_no_info');
740
958
  const tags = [
741
- ['사용자', nick, 'low'],
742
- ['이메일', email || '없음', 'medium'],
743
- ['권한', 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']
744
962
  ];
745
963
  document.getElementById('session-tags').innerHTML = tags.map(([label, value, tone]) => `
746
964
  <span class="tag ${tone}"><span>${esc(label)}</span> ${esc(value)}</span>
@@ -748,10 +966,9 @@
748
966
  document.getElementById('admin-pill').innerHTML = isAdmin
749
967
  ? '<i class="ti ti-shield-check"></i> Admin'
750
968
  : '<i class="ti ti-lock"></i> Read only';
751
- const help = document.getElementById('session-help');
752
- help.innerHTML = email
753
- ? '이메일 헤더가 설정되어 관리자 API를 호출할 수 있습니다.'
754
- : '채팅 화면에서 로그인한 뒤 이 화면을 열어야 관리자 API를 사용할 수 있습니다.';
969
+ document.getElementById('session-help').textContent = email
970
+ ? t('session_help_ok')
971
+ : t('session_help_fail');
755
972
  }
756
973
 
757
974
  function fillVpcForm(config) {
@@ -765,8 +982,8 @@
765
982
  document.getElementById('vpc-subnets').value = (config.private_subnets || []).join(', ');
766
983
  document.getElementById('vpc-notes').value = config.notes || '';
767
984
  document.getElementById('vpc-save-status').textContent = config.updated_at
768
- ? `마지막 저장: ${new Date(config.updated_at).toLocaleString()}`
769
- : '기본 VPC 프로필을 사용 중입니다.';
985
+ ? `${t('vpc_last_saved')} ${new Date(config.updated_at).toLocaleString()}`
986
+ : t('vpc_default_profile');
770
987
 
771
988
  const provider = config.provider || 'VPC';
772
989
  const region = config.region || '-';
@@ -778,11 +995,11 @@
778
995
  document.getElementById('total-users').textContent = summary ? summary.total_users : '-';
779
996
  document.getElementById('total-users-meta').textContent = summary
780
997
  ? `${summary.active_users} active · ${summary.admin_users} admins`
781
- : '관리자 권한 필요';
998
+ : t('meta_need_admin');
782
999
  document.getElementById('total-messages').textContent = summary ? summary.total_messages : '-';
783
1000
  document.getElementById('total-messages-meta').textContent = summary
784
1001
  ? `user ${summary.user_messages} · assistant ${summary.assistant_messages}`
785
- : '최근 메시지 정보를 불러올 수 없음';
1002
+ : t('meta_msg_unavailable');
786
1003
  document.getElementById('current-model').textContent = compactModelName(health?.current_model);
787
1004
  document.getElementById('current-model-meta').textContent = `${health?.loaded_models?.length || 0} loaded · ${health?.device || 'local runtime'}`;
788
1005
  document.getElementById('vpc-status').textContent = `${vpc?.provider || '-'} ${vpc?.region || '-'}`;
@@ -796,10 +1013,10 @@
796
1013
  const userCounts = summary.user_counts || {};
797
1014
 
798
1015
  const tags = [
799
- ['risk', `위험 ${summary.risky_messages || 0}`],
800
- ['low', `준수 ${summary.compliant_messages || 0}`],
801
- ['medium', `위험률 ${summary.risk_rate || 0}%`],
802
- ['high', `높음 ${severity.high || 0}`]
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}`]
803
1020
  ];
804
1021
  document.getElementById('sensitivity-summary').innerHTML = tags.map(([tone, label]) => `<span class="tag ${tone}">${esc(label)}</span>`).join('');
805
1022
 
@@ -818,7 +1035,7 @@
818
1035
  <div class="preview">${esc(item.preview || '')}</div>
819
1036
  </div>
820
1037
  `).join('')
821
- : '<div class="preview">감지된 위험 필드가 없습니다.</div>';
1038
+ : `<div class="preview">${t('no_risk_fields')}</div>`;
822
1039
 
823
1040
  document.getElementById('compliance-fields').innerHTML = complianceList.length
824
1041
  ? complianceList.slice().reverse().map(item => `
@@ -832,7 +1049,7 @@
832
1049
  <div class="preview">${esc(item.preview || '')}</div>
833
1050
  </div>
834
1051
  `).join('')
835
- : '<div class="preview">준수 항목이 없습니다.</div>';
1052
+ : `<div class="preview">${t('no_compliance_fields')}</div>`;
836
1053
 
837
1054
  const fieldTags = Object.entries(fieldCounts).map(([label, count]) => `<span class="tag medium">${esc(label)} ${esc(count)}</span>`);
838
1055
  const userTags = Object.entries(userCounts).map(([label, count]) => `<span class="tag high">${esc(label)} ${esc(count)}</span>`);
@@ -842,7 +1059,7 @@
842
1059
  function renderUsers(users) {
843
1060
  const wrap = document.getElementById('user-table-wrap');
844
1061
  if (!Array.isArray(users) || !users.length) {
845
- wrap.innerHTML = '<div class="preview" style="padding:14px">사용자 데이터가 없습니다.</div>';
1062
+ wrap.innerHTML = `<div class="preview" style="padding:14px">${t('no_users')}</div>`;
846
1063
  return;
847
1064
  }
848
1065
  wrap.innerHTML = `
@@ -864,24 +1081,24 @@
864
1081
  <td>${esc(user.name || '-')}</td>
865
1082
  <td>${esc(user.nickname || '-')}</td>
866
1083
  <td><span class="role">${esc(user.role || '-')}</span></td>
867
- <td>${user.disabled ? '비활성' : '활성'}</td>
1084
+ <td>${user.disabled ? t('status_inactive') : t('status_active')}</td>
868
1085
  <td>
869
1086
  <div class="actions">
870
1087
  <button class="table-btn"
871
1088
  data-action="role"
872
1089
  data-email="${esc(user.email)}"
873
1090
  data-next-role="${user.role === 'admin' ? 'user' : 'admin'}">
874
- ${user.role === 'admin' ? '권한 해제' : '관리자 지정'}
1091
+ ${user.role === 'admin' ? t('btn_revoke_admin') : t('btn_grant_admin')}
875
1092
  </button>
876
1093
  <button class="table-btn"
877
1094
  data-action="disable"
878
1095
  data-email="${esc(user.email)}"
879
1096
  data-disabled="${user.disabled ? 'false' : 'true'}">
880
- ${user.disabled ? '활성화' : '비활성화'}
1097
+ ${user.disabled ? t('btn_activate') : t('btn_deactivate')}
881
1098
  </button>
882
1099
  <button class="table-btn danger"
883
1100
  data-action="delete"
884
- data-email="${esc(user.email)}">삭제</button>
1101
+ data-email="${esc(user.email)}">${t('btn_delete')}</button>
885
1102
  </div>
886
1103
  </td>
887
1104
  </tr>
@@ -911,7 +1128,7 @@
911
1128
  });
912
1129
  await loadDashboard();
913
1130
  } else if (action === 'delete') {
914
- if (!confirm(`'${email}' 사용자를 삭제할까요?`)) return;
1131
+ if (!confirm(`'${email}' ${t('confirm_delete')}`)) return;
915
1132
  await apiFetch(`/admin/users/${encodedEmail}`, {
916
1133
  method: 'DELETE', headers: adminHeaders()
917
1134
  });
@@ -921,17 +1138,20 @@
921
1138
 
922
1139
  async function loadDashboard() {
923
1140
  setSessionInfo();
1141
+ applyI18n();
924
1142
 
925
1143
  const access = document.getElementById('access-notice');
926
1144
  access.style.display = 'none';
927
1145
 
928
1146
  try {
929
- const [healthRes, vpcRes, summaryRes, usersRes, sensitivityRes] = await Promise.all([
1147
+ const [healthRes, vpcRes, summaryRes, usersRes, sensitivityRes, inviteRes, statsRes] = await Promise.all([
930
1148
  apiFetch('/health'),
931
1149
  apiFetch('/vpc/status'),
932
1150
  apiFetch('/admin/summary', { headers: adminHeaders() }),
933
1151
  apiFetch('/admin/users', { headers: adminHeaders() }),
934
- apiFetch('/admin/sensitivity', { headers: adminHeaders() })
1152
+ apiFetch('/admin/sensitivity', { headers: adminHeaders() }),
1153
+ apiFetch('/admin/invite-link', { headers: adminHeaders() }),
1154
+ apiFetch('/admin/stats', { headers: adminHeaders() })
935
1155
  ]);
936
1156
 
937
1157
  const health = healthRes.ok ? await healthRes.json() : null;
@@ -939,28 +1159,37 @@
939
1159
  const summary = summaryRes.ok ? await summaryRes.json() : null;
940
1160
  const users = usersRes.ok ? await usersRes.json() : null;
941
1161
  const sensitivity = sensitivityRes.ok ? await sensitivityRes.json() : null;
1162
+ const invite = inviteRes.ok ? await inviteRes.json() : null;
1163
+ const stats = statsRes.ok ? await statsRes.json() : null;
942
1164
 
943
1165
  renderSummary(health, summary, vpc);
944
1166
  fillVpcForm(vpc);
945
1167
  renderUsers(users);
946
1168
  renderSensitivity(sensitivity);
1169
+ if (invite) {
1170
+ document.getElementById('invite-link-input').value = invite.invite_url;
1171
+ document.getElementById('invite-gate-info').textContent = invite.gate_enabled
1172
+ ? `${t('invite_gate_active')} — ${invite.invite_code}`
1173
+ : t('invite_gate_inactive');
1174
+ }
1175
+ if (stats) renderActivityChart(stats.daily);
947
1176
 
948
1177
  const failedSections = [];
949
- if (!summaryRes.ok) failedSections.push('요약');
950
- if (!usersRes.ok) failedSections.push('사용자 목록');
951
- 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'));
952
1181
 
953
1182
  if (failedSections.length) {
954
1183
  access.style.display = 'block';
955
1184
  access.textContent = summaryRes.status === 403
956
- ? '관리자 권한이 없습니다. 채팅 화면에서 관리자 계정으로 로그인한 뒤 다시 열어주세요.'
957
- : `일부 섹션을 불러오지 못했습니다: ${failedSections.join(', ')}`;
1185
+ ? t('err_no_admin')
1186
+ : `${t('err_partial')} ${failedSections.join(', ')}`;
958
1187
  }
959
1188
  } catch (e) {
960
1189
  access.style.display = 'block';
961
1190
  access.textContent = !navigator.onLine
962
- ? '네트워크 연결을 확인해 주세요.'
963
- : (e.message || '대시보드를 불러오지 못했습니다.');
1191
+ ? t('err_network')
1192
+ : (e.message || t('err_load'));
964
1193
  }
965
1194
  }
966
1195
 
@@ -976,7 +1205,7 @@
976
1205
  notes: document.getElementById('vpc-notes').value.trim()
977
1206
  };
978
1207
  const status = document.getElementById('vpc-save-status');
979
- status.textContent = '저장 중...';
1208
+ status.textContent = t('vpc_saving');
980
1209
  try {
981
1210
  const res = await apiFetch('/admin/vpc', {
982
1211
  method: 'PATCH',
@@ -984,12 +1213,12 @@
984
1213
  body: JSON.stringify(payload)
985
1214
  });
986
1215
  const data = await res.json().catch(() => ({}));
987
- if (!res.ok) throw new Error(data.detail || '저장 실패');
1216
+ if (!res.ok) throw new Error(data.detail || t('vpc_save_fail'));
988
1217
  fillVpcForm(data);
989
- status.textContent = '저장되었습니다.';
1218
+ status.textContent = t('vpc_saved');
990
1219
  await loadDashboard();
991
1220
  } catch (e) {
992
- status.textContent = e.message || '저장 실패';
1221
+ status.textContent = e.message || t('vpc_save_fail');
993
1222
  }
994
1223
  }
995
1224
 
@@ -1006,6 +1235,7 @@
1006
1235
  document.getElementById('refresh-btn').addEventListener('click', loadDashboard);
1007
1236
  document.getElementById('save-vpc-btn').addEventListener('click', saveVpc);
1008
1237
  document.getElementById('logout-btn').addEventListener('click', logout);
1238
+ applyI18n();
1009
1239
  loadDashboard();
1010
1240
  </script>
1011
1241
  </body>