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/README.md +24 -0
- package/package.json +3 -3
- package/server.py +75 -5
- package/static/account.html +449 -0
- package/static/admin.html +289 -59
- package/static/{indexd.html → chat.html} +359 -142
- package/static/index.html +0 -270
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>
|
|
539
|
-
<
|
|
540
|
-
|
|
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>
|
|
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
|
|
675
|
-
<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, '&')
|
|
@@ -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
|
-
['
|
|
742
|
-
['
|
|
743
|
-
['
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
?
|
|
769
|
-
: '
|
|
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',
|
|
800
|
-
['low',
|
|
801
|
-
['medium',
|
|
802
|
-
['high',
|
|
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
|
-
:
|
|
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
|
-
:
|
|
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 =
|
|
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 ? '
|
|
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)}"
|
|
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}'
|
|
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
|
-
:
|
|
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>
|