Changeset 0.8.2 (#45)
This commit is contained in:
@@ -1044,6 +1044,18 @@ button { font-family: var(--font); cursor: pointer; }
|
||||
.stat-card .stat-value { font-size: 1.6rem; font-weight: 700; color: var(--text); line-height: 1.2; }
|
||||
.stat-card .stat-label { font-size: 0.78rem; color: var(--text-3); margin-top: 4px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
|
||||
/* Audit log */
|
||||
.audit-row { padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
.audit-row:last-child { border-bottom: none; }
|
||||
.audit-main { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
|
||||
.audit-action { font-weight: 600; color: var(--accent); font-family: var(--font-mono, monospace); font-size: 12px; }
|
||||
.audit-actor { color: var(--text); }
|
||||
.audit-resource { color: var(--text-3); font-family: var(--font-mono, monospace); font-size: 11px; }
|
||||
.audit-meta { display: flex; gap: 10px; margin-top: 2px; font-size: 11px; color: var(--text-3); flex-wrap: wrap; }
|
||||
.audit-details { color: var(--text-2); }
|
||||
.audit-ip { font-family: var(--font-mono, monospace); }
|
||||
.pagination-row { display: flex; align-items: center; justify-content: center; gap: 12px; }
|
||||
|
||||
/* Banner preview */
|
||||
.banner-preview {
|
||||
margin-top: 0.75rem; padding: 6px 16px; text-align: center;
|
||||
|
||||
@@ -224,6 +224,7 @@
|
||||
<button class="settings-tab" data-stab="appearance" onclick="UI.switchSettingsTab('appearance')">Appearance</button>
|
||||
<button class="settings-tab" data-stab="providers" onclick="UI.switchSettingsTab('providers')">Providers</button>
|
||||
<button class="settings-tab" data-stab="models" onclick="UI.switchSettingsTab('models')">Models</button>
|
||||
<button class="settings-tab" data-stab="teams" onclick="UI.switchSettingsTab('teams')" id="settingsTeamsTabBtn" style="display:none">Teams</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<!-- General Tab -->
|
||||
@@ -307,6 +308,49 @@
|
||||
<p class="section-hint">Models available from your providers and global configs.</p>
|
||||
<div id="userModelList" class="model-list-grid"><div class="empty-hint">Loading models...</div></div>
|
||||
</div>
|
||||
<!-- Teams Tab (visible to team admins) -->
|
||||
<div class="settings-tab-content" id="settingsTeamsTab" style="display:none">
|
||||
<div id="settingsTeamPicker"></div>
|
||||
<div id="settingsTeamManage" style="display:none">
|
||||
<button class="notes-back-btn" id="settingsTeamBackBtn">← Back to teams</button>
|
||||
<h3 id="settingsTeamManageName" style="margin:8px 0 4px;font-size:15px"></h3>
|
||||
<!-- Members -->
|
||||
<section class="settings-section">
|
||||
<h4 style="font-size:13px;margin-bottom:6px">Members</h4>
|
||||
<div id="settingsTeamMembers"></div>
|
||||
<div id="settingsTeamAddMember" style="display:none;margin-top:8px" class="admin-inline-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>User</label><select id="settingsTeamMemberUser"></select></div>
|
||||
<div class="form-group"><label>Role</label><select id="settingsTeamMemberRole"><option value="member">Member</option><option value="admin">Team Admin</option></select></div>
|
||||
</div>
|
||||
<div class="form-row"><button class="btn-small btn-primary" id="settingsTeamAddMemberSubmit">Add</button><button class="btn-small" id="settingsTeamCancelMember">Cancel</button></div>
|
||||
</div>
|
||||
<button class="btn-small" id="settingsTeamAddMemberBtn" style="margin-top:6px">+ Add Member</button>
|
||||
</section>
|
||||
<!-- Team Presets -->
|
||||
<section class="settings-section">
|
||||
<h4 style="font-size:13px;margin-bottom:6px">Team Presets</h4>
|
||||
<div id="settingsTeamPresets"></div>
|
||||
<div id="settingsTeamAddPreset" style="display:none;margin-top:8px" class="admin-inline-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Name</label><input type="text" id="settingsTeamPresetName" placeholder="Code Reviewer"></div>
|
||||
<div class="form-group"><label>Icon</label><input type="text" id="settingsTeamPresetIcon" placeholder="🔍" maxlength="4" style="width:60px"></div>
|
||||
</div>
|
||||
<div class="form-group"><label>Description</label><input type="text" id="settingsTeamPresetDesc" placeholder="Reviews code for best practices"></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Base Model</label><select id="settingsTeamPresetModel"></select></div>
|
||||
</div>
|
||||
<div class="form-group"><label>System Prompt</label><textarea id="settingsTeamPresetPrompt" rows="3" placeholder="You are a code reviewer..."></textarea></div>
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Temperature</label><input type="number" id="settingsTeamPresetTemp" step="0.1" min="0" max="2" placeholder="default"></div>
|
||||
<div class="form-group"><label>Max Tokens</label><input type="number" id="settingsTeamPresetMaxTokens" placeholder="default"></div>
|
||||
</div>
|
||||
<div class="form-row"><button class="btn-small btn-primary" id="settingsTeamCreatePresetBtn">Create</button><button class="btn-small" id="settingsTeamCancelPreset">Cancel</button></div>
|
||||
</div>
|
||||
<button class="btn-small" id="settingsTeamAddPresetBtn" style="margin-top:6px">+ New Team Preset</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer"><button class="btn-primary" id="settingsSaveBtn">Save Settings</button></div>
|
||||
</div>
|
||||
@@ -324,6 +368,7 @@
|
||||
<button class="admin-tab" data-tab="teams">Teams</button>
|
||||
<button class="admin-tab" data-tab="settings">Settings</button>
|
||||
<button class="admin-tab" data-tab="stats">Stats</button>
|
||||
<button class="admin-tab" data-tab="audit">Audit</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="admin-tab-content" id="adminUsersTab">
|
||||
@@ -479,6 +524,26 @@
|
||||
<button class="btn-primary btn-small" id="adminSaveSettings">Save Settings</button>
|
||||
</div>
|
||||
<div class="admin-tab-content" id="adminStatsTab" style="display:none"><div id="adminStats"></div></div>
|
||||
<div class="admin-tab-content" id="adminAuditTab" style="display:none">
|
||||
<div class="admin-toolbar" style="margin-bottom:8px">
|
||||
<select id="auditFilterAction" style="min-width:140px"><option value="">All actions</option></select>
|
||||
<select id="auditFilterResource" style="min-width:120px">
|
||||
<option value="">All types</option>
|
||||
<option value="user">user</option>
|
||||
<option value="team">team</option>
|
||||
<option value="preset">preset</option>
|
||||
<option value="channel">channel</option>
|
||||
<option value="config">config</option>
|
||||
</select>
|
||||
<button class="btn-small" id="auditRefreshBtn">Refresh</button>
|
||||
</div>
|
||||
<div id="adminAuditList"></div>
|
||||
<div class="pagination-row" id="auditPagination" style="display:none;margin-top:8px">
|
||||
<button class="btn-small" id="auditPrevBtn">← Prev</button>
|
||||
<span id="auditPageInfo" style="font-size:12px;color:var(--text-3)"></span>
|
||||
<button class="btn-small" id="auditNextBtn">Next →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -292,6 +292,18 @@ const API = {
|
||||
|
||||
// ── Admin Teams ─────────────────────────
|
||||
adminListTeams() { return this._get('/api/v1/admin/teams'); },
|
||||
|
||||
// ── Admin Audit ─────────────────────────
|
||||
adminListAudit(params) {
|
||||
const q = new URLSearchParams();
|
||||
if (params?.page) q.set('page', params.page);
|
||||
if (params?.per_page) q.set('per_page', params.per_page);
|
||||
if (params?.action) q.set('action', params.action);
|
||||
if (params?.actor_id) q.set('actor_id', params.actor_id);
|
||||
if (params?.resource_type) q.set('resource_type', params.resource_type);
|
||||
return this._get(`/api/v1/admin/audit?${q}`);
|
||||
},
|
||||
adminListAuditActions() { return this._get('/api/v1/admin/audit/actions'); },
|
||||
adminCreateTeam(name, description) { return this._post('/api/v1/admin/teams', { name, description }); },
|
||||
adminGetTeam(id) { return this._get(`/api/v1/admin/teams/${id}`); },
|
||||
adminUpdateTeam(id, updates) { return this._put(`/api/v1/admin/teams/${id}`, updates); },
|
||||
|
||||
@@ -1119,6 +1119,58 @@ function initListeners() {
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
|
||||
// Settings — Team management (team admin self-service)
|
||||
document.getElementById('settingsTeamBackBtn')?.addEventListener('click', () => {
|
||||
UI.loadTeamsTab();
|
||||
});
|
||||
document.getElementById('settingsTeamAddMemberBtn')?.addEventListener('click', async () => {
|
||||
document.getElementById('settingsTeamAddMember').style.display = '';
|
||||
// Load available users (requires sys-admin; team admins use their own knowledge)
|
||||
const sel = document.getElementById('settingsTeamMemberUser');
|
||||
sel.innerHTML = '<option value="">Loading...</option>';
|
||||
try {
|
||||
const resp = await API.teamListMembers(UI._managingTeamId);
|
||||
const existingIds = new Set((resp.data || []).map(m => m.user_id));
|
||||
// Team admins can't list all users — provide an email input fallback
|
||||
sel.innerHTML = '<option value="">Enter user ID or ask sys-admin</option>';
|
||||
} catch (e) { sel.innerHTML = '<option value="">Error</option>'; }
|
||||
});
|
||||
document.getElementById('settingsTeamCancelMember')?.addEventListener('click', () => {
|
||||
document.getElementById('settingsTeamAddMember').style.display = 'none';
|
||||
});
|
||||
document.getElementById('settingsTeamAddPresetBtn')?.addEventListener('click', () => {
|
||||
document.getElementById('settingsTeamAddPreset').style.display = '';
|
||||
UI.loadTeamPresetModelDropdown();
|
||||
});
|
||||
document.getElementById('settingsTeamCancelPreset')?.addEventListener('click', () => {
|
||||
document.getElementById('settingsTeamAddPreset').style.display = 'none';
|
||||
});
|
||||
document.getElementById('settingsTeamCreatePresetBtn')?.addEventListener('click', async () => {
|
||||
const teamId = UI._managingTeamId;
|
||||
const name = document.getElementById('settingsTeamPresetName').value.trim();
|
||||
const baseModelId = document.getElementById('settingsTeamPresetModel').value;
|
||||
if (!name) return UI.toast('Name required', 'error');
|
||||
if (!baseModelId) return UI.toast('Select a base model', 'error');
|
||||
const preset = {
|
||||
name,
|
||||
base_model_id: baseModelId,
|
||||
description: document.getElementById('settingsTeamPresetDesc').value.trim(),
|
||||
icon: document.getElementById('settingsTeamPresetIcon').value.trim(),
|
||||
system_prompt: document.getElementById('settingsTeamPresetPrompt').value,
|
||||
temperature: parseFloat(document.getElementById('settingsTeamPresetTemp').value) || undefined,
|
||||
max_tokens: parseInt(document.getElementById('settingsTeamPresetMaxTokens').value) || undefined,
|
||||
};
|
||||
try {
|
||||
await API.teamCreatePreset(teamId, preset);
|
||||
document.getElementById('settingsTeamAddPreset').style.display = 'none';
|
||||
['settingsTeamPresetName','settingsTeamPresetDesc','settingsTeamPresetIcon','settingsTeamPresetPrompt','settingsTeamPresetTemp','settingsTeamPresetMaxTokens']
|
||||
.forEach(id => { const el = document.getElementById(id); if (el) el.value = ''; });
|
||||
UI.toast('Team preset created');
|
||||
await UI.loadTeamManagePresets(teamId);
|
||||
await fetchModels(); // refresh model selector
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
});
|
||||
|
||||
// Admin — banner controls
|
||||
document.getElementById('adminBannerEnabled')?.addEventListener('change', (e) => {
|
||||
document.getElementById('bannerConfigFields').style.display = e.target.checked ? '' : 'none';
|
||||
@@ -1143,6 +1195,13 @@ function initListeners() {
|
||||
document.getElementById('adminBannerBgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerBg').value = e.target.value; UI.updateBannerPreview(); }});
|
||||
document.getElementById('adminBannerFgHex')?.addEventListener('input', (e) => { if (/^#[0-9a-f]{6}$/i.test(e.target.value)) { document.getElementById('adminBannerFg').value = e.target.value; UI.updateBannerPreview(); }});
|
||||
|
||||
// Admin — audit log
|
||||
document.getElementById('auditRefreshBtn')?.addEventListener('click', () => UI.loadAuditLog(1));
|
||||
document.getElementById('auditFilterAction')?.addEventListener('change', () => UI.loadAuditLog(1));
|
||||
document.getElementById('auditFilterResource')?.addEventListener('change', () => UI.loadAuditLog(1));
|
||||
document.getElementById('auditPrevBtn')?.addEventListener('click', () => { if (UI._auditPage > 1) UI.loadAuditLog(UI._auditPage - 1); });
|
||||
document.getElementById('auditNextBtn')?.addEventListener('click', () => UI.loadAuditLog(UI._auditPage + 1));
|
||||
|
||||
// Input
|
||||
const input = document.getElementById('messageInput');
|
||||
input.addEventListener('keydown', (e) => {
|
||||
@@ -1722,6 +1781,36 @@ async function removeTeamMember(teamId, memberId, email) {
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// Settings-side team management (uses team admin API, not sys-admin)
|
||||
async function settingsUpdateTeamMember(teamId, memberId, role) {
|
||||
try {
|
||||
await API.teamUpdateMember(teamId, memberId, role);
|
||||
UI.toast('Role updated');
|
||||
} catch (e) {
|
||||
UI.toast(e.message, 'error');
|
||||
await UI.loadTeamManageMembers(teamId);
|
||||
}
|
||||
}
|
||||
|
||||
async function settingsRemoveTeamMember(teamId, memberId, email) {
|
||||
if (!confirm(`Remove ${email} from team?`)) return;
|
||||
try {
|
||||
await API.teamRemoveMember(teamId, memberId);
|
||||
UI.toast('Member removed');
|
||||
await UI.loadTeamManageMembers(teamId);
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function settingsDeleteTeamPreset(teamId, presetId, name) {
|
||||
if (!confirm(`Delete team preset "${name}"?`)) return;
|
||||
try {
|
||||
await API.teamDeletePreset(teamId, presetId);
|
||||
UI.toast('Preset deleted');
|
||||
await UI.loadTeamManagePresets(teamId);
|
||||
await fetchModels();
|
||||
} catch (e) { UI.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
// ── Notes Panel ─────────────────────────────
|
||||
|
||||
var _editingNoteId = null;
|
||||
|
||||
185
src/js/ui.js
185
src/js/ui.js
@@ -640,6 +640,7 @@ const UI = {
|
||||
}
|
||||
if (tab === 'models') UI.loadUserModels();
|
||||
if (tab === 'appearance') UI.loadAppearanceSettings();
|
||||
if (tab === 'teams') UI.loadTeamsTab();
|
||||
},
|
||||
|
||||
// ── Appearance Settings ─────────────────
|
||||
@@ -710,10 +711,17 @@ const UI = {
|
||||
async loadMyTeams() {
|
||||
const section = document.getElementById('settingsTeamsSection');
|
||||
const el = document.getElementById('settingsTeamsList');
|
||||
const tabBtn = document.getElementById('settingsTeamsTabBtn');
|
||||
if (!section || !el) return;
|
||||
try {
|
||||
const resp = await API.listMyTeams();
|
||||
const teams = resp.data || [];
|
||||
UI._myTeams = teams;
|
||||
|
||||
// Show/hide the Teams tab for team admins
|
||||
const isTeamAdmin = teams.some(t => t.my_role === 'admin');
|
||||
if (tabBtn) tabBtn.style.display = isTeamAdmin ? '' : 'none';
|
||||
|
||||
if (teams.length === 0) {
|
||||
section.style.display = 'none';
|
||||
return;
|
||||
@@ -729,7 +737,102 @@ const UI = {
|
||||
<div class="text-muted" style="font-size:11px">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (e) { section.style.display = 'none'; }
|
||||
} catch (e) { section.style.display = 'none'; if (tabBtn) tabBtn.style.display = 'none'; }
|
||||
},
|
||||
|
||||
_myTeams: [],
|
||||
_managingTeamId: null,
|
||||
|
||||
loadTeamsTab() {
|
||||
const picker = document.getElementById('settingsTeamPicker');
|
||||
const manage = document.getElementById('settingsTeamManage');
|
||||
if (!picker) return;
|
||||
manage.style.display = 'none';
|
||||
picker.style.display = '';
|
||||
|
||||
const adminTeams = UI._myTeams.filter(t => t.my_role === 'admin');
|
||||
if (adminTeams.length === 0) {
|
||||
picker.innerHTML = '<div class="empty-hint">You are not an admin of any teams.</div>';
|
||||
return;
|
||||
}
|
||||
picker.innerHTML = adminTeams.map(t => `
|
||||
<div class="team-card" style="cursor:pointer" onclick="UI.openTeamManage('${t.id}', '${esc(t.name)}')">
|
||||
<div class="team-card-info">
|
||||
<strong>${esc(t.name)}</strong>
|
||||
<span class="badge-admin">admin</span>
|
||||
<span style="margin-left:auto;color:var(--text-3);font-size:12px">Manage →</span>
|
||||
</div>
|
||||
${t.description ? `<div class="text-muted" style="font-size:12px">${esc(t.description)}</div>` : ''}
|
||||
<div class="text-muted" style="font-size:11px">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
},
|
||||
|
||||
async openTeamManage(teamId, teamName) {
|
||||
UI._managingTeamId = teamId;
|
||||
document.getElementById('settingsTeamPicker').style.display = 'none';
|
||||
const manage = document.getElementById('settingsTeamManage');
|
||||
manage.style.display = '';
|
||||
document.getElementById('settingsTeamManageName').textContent = teamName;
|
||||
document.getElementById('settingsTeamAddMember').style.display = 'none';
|
||||
document.getElementById('settingsTeamAddPreset').style.display = 'none';
|
||||
await Promise.all([UI.loadTeamManageMembers(teamId), UI.loadTeamManagePresets(teamId)]);
|
||||
},
|
||||
|
||||
async loadTeamManageMembers(teamId) {
|
||||
const el = document.getElementById('settingsTeamMembers');
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const resp = await API.teamListMembers(teamId);
|
||||
const members = resp.data || [];
|
||||
el.innerHTML = members.map(m => `
|
||||
<div class="admin-user-row" style="padding:6px 0">
|
||||
<div class="admin-user-info">
|
||||
<strong style="font-size:13px">${esc(m.display_name || m.email)}</strong>
|
||||
<span class="badge-${m.role === 'admin' ? 'admin' : 'user'}" style="font-size:10px">${m.role}</span>
|
||||
</div>
|
||||
<div class="admin-user-actions">
|
||||
<select onchange="settingsUpdateTeamMember('${teamId}','${m.id}',this.value)" class="inline-select">
|
||||
<option value="member" ${m.role === 'member' ? 'selected' : ''}>Member</option>
|
||||
<option value="admin" ${m.role === 'admin' ? 'selected' : ''}>Admin</option>
|
||||
</select>
|
||||
<button class="btn-delete" onclick="settingsRemoveTeamMember('${teamId}','${m.id}','${esc(m.email)}')" title="Remove">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('') || '<div class="empty-hint">No members</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadTeamManagePresets(teamId) {
|
||||
const el = document.getElementById('settingsTeamPresets');
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
try {
|
||||
const resp = await API.teamListPresets(teamId);
|
||||
const presets = resp.presets || [];
|
||||
el.innerHTML = presets.map(p => {
|
||||
const icon = p.icon || '';
|
||||
return `<div class="admin-preset-row" style="padding:6px 0">
|
||||
<div class="preset-info">
|
||||
<strong>${icon ? icon + ' ' : ''}${esc(p.name)}</strong>
|
||||
<div class="preset-meta" style="font-size:11px">${esc(p.base_model_id)}</div>
|
||||
</div>
|
||||
<button class="btn-delete" onclick="settingsDeleteTeamPreset('${teamId}','${p.id}','${esc(p.name)}')" title="Delete">✕</button>
|
||||
</div>`;
|
||||
}).join('') || '<div class="empty-hint">No team presets — create one to give your team preconfigured models</div>';
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
async loadTeamPresetModelDropdown() {
|
||||
const sel = document.getElementById('settingsTeamPresetModel');
|
||||
if (!sel) return;
|
||||
sel.innerHTML = '<option value="">Select base model...</option>';
|
||||
const models = App.models.filter(m => !m.isPreset);
|
||||
models.forEach(m => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = m.baseModelId || m.id;
|
||||
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
},
|
||||
|
||||
// ── Providers ────────────────────────────
|
||||
@@ -778,6 +881,7 @@ const UI = {
|
||||
|
||||
if (tab === 'users') await this.loadAdminUsers();
|
||||
if (tab === 'stats') await this.loadAdminStats();
|
||||
if (tab === 'audit') await this.loadAuditLog();
|
||||
if (tab === 'providers') await this.loadAdminProviders();
|
||||
if (tab === 'models') await this.loadAdminModels();
|
||||
if (tab === 'presets') await this.loadAdminPresets();
|
||||
@@ -840,6 +944,85 @@ const UI = {
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
_auditPage: 1,
|
||||
_auditPerPage: 30,
|
||||
|
||||
async loadAuditLog(page) {
|
||||
if (page) UI._auditPage = page;
|
||||
const el = document.getElementById('adminAuditList');
|
||||
el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
|
||||
// Populate action filter on first load
|
||||
const actionSel = document.getElementById('auditFilterAction');
|
||||
if (actionSel && actionSel.options.length <= 1) {
|
||||
try {
|
||||
const resp = await API.adminListAuditActions();
|
||||
(resp.actions || []).forEach(a => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = a;
|
||||
opt.textContent = a;
|
||||
actionSel.appendChild(opt);
|
||||
});
|
||||
} catch (e) { /* optional */ }
|
||||
}
|
||||
|
||||
const params = {
|
||||
page: UI._auditPage,
|
||||
per_page: UI._auditPerPage,
|
||||
action: document.getElementById('auditFilterAction')?.value || '',
|
||||
resource_type: document.getElementById('auditFilterResource')?.value || '',
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await API.adminListAudit(params);
|
||||
const entries = resp.data || [];
|
||||
const total = resp.total || 0;
|
||||
const totalPages = Math.ceil(total / UI._auditPerPage);
|
||||
|
||||
if (entries.length === 0) {
|
||||
el.innerHTML = '<div class="empty-hint">No audit entries found</div>';
|
||||
} else {
|
||||
el.innerHTML = entries.map(e => {
|
||||
const ts = new Date(e.created_at);
|
||||
const timeStr = ts.toLocaleDateString() + ' ' + ts.toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
|
||||
const actor = e.actor_name || 'system';
|
||||
const meta = UI._formatAuditMeta(e.metadata);
|
||||
return `<div class="audit-row">
|
||||
<div class="audit-main">
|
||||
<span class="audit-action">${esc(e.action)}</span>
|
||||
<span class="audit-actor">${esc(actor)}</span>
|
||||
<span class="audit-resource">${esc(e.resource_type)}${e.resource_id ? ':' + esc(e.resource_id).slice(0,8) : ''}</span>
|
||||
</div>
|
||||
<div class="audit-meta">
|
||||
${meta ? `<span class="audit-details">${meta}</span>` : ''}
|
||||
<span class="audit-time">${timeStr}</span>
|
||||
${e.ip_address ? `<span class="audit-ip">${esc(e.ip_address)}</span>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Pagination
|
||||
const pgEl = document.getElementById('auditPagination');
|
||||
if (totalPages > 1) {
|
||||
pgEl.style.display = 'flex';
|
||||
document.getElementById('auditPageInfo').textContent = `Page ${UI._auditPage} of ${totalPages} (${total} entries)`;
|
||||
document.getElementById('auditPrevBtn').disabled = UI._auditPage <= 1;
|
||||
document.getElementById('auditNextBtn').disabled = UI._auditPage >= totalPages;
|
||||
} else {
|
||||
pgEl.style.display = 'none';
|
||||
}
|
||||
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
|
||||
},
|
||||
|
||||
_formatAuditMeta(metaStr) {
|
||||
try {
|
||||
const m = typeof metaStr === 'string' ? JSON.parse(metaStr) : metaStr;
|
||||
if (!m || Object.keys(m).length === 0) return '';
|
||||
return Object.entries(m).map(([k,v]) => `${esc(k)}=${esc(String(v))}`).join(' · ');
|
||||
} catch { return ''; }
|
||||
},
|
||||
|
||||
async loadAdminProviders(quiet) {
|
||||
const el = document.getElementById('adminProviderList');
|
||||
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
|
||||
|
||||
Reference in New Issue
Block a user