/** * Rule Manager - Multi-Project Governance Dashboard * Handles filtering, sorting, pagination, and CRUD operations for rules */ // Auth check const token = localStorage.getItem('admin_token'); const user = JSON.parse(localStorage.getItem('admin_user') || '{}'); if (!token) { window.location.href = '/admin/login.html'; } // Display admin name document.getElementById('admin-name').textContent = user.email || 'Admin'; // Logout document.getElementById('logout-btn').addEventListener('click', () => { localStorage.removeItem('admin_token'); localStorage.removeItem('admin_user'); window.location.href = '/admin/login.html'; }); /** * API request helper with automatic auth header injection and token refresh * * @param {string} endpoint - API endpoint path (e.g., '/api/admin/rules') * @param {Object} [options={}] - Fetch options (method, body, headers, etc.) * @returns {Promise} JSON response from API * * @description * - Automatically adds Authorization header with Bearer token * - Redirects to login on 401 (unauthorized) * - Handles JSON response parsing */ async function apiRequest(endpoint, options = {}) { const response = await fetch(endpoint, { ...options, headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json', ...options.headers } }); if (response.status === 401) { localStorage.removeItem('admin_token'); window.location.href = '/admin/login.html'; return; } return response.json(); } // State management let currentPage = 1; const pageSize = 20; let totalRules = 0; let selectedProjectId = null; // Track selected project for variable substitution let filters = { scope: '', quadrant: '', persistence: '', validation: '', active: 'true', search: '', sort: 'priority', order: 'desc' }; /** * Load and display dashboard statistics * Fetches rule counts, validation status, and average clarity scores * * @async * @description * Updates the following stat cards: * - Total rules * - Universal rules count * - Validated rules count * - Average clarity score */ async function loadStatistics() { try { const response = await apiRequest('/api/admin/rules/stats'); if (!response.success || !response.stats) { console.error('Invalid stats response:', response); return; } const stats = response.stats; document.getElementById('stat-total').textContent = stats.total || 0; document.getElementById('stat-universal').textContent = stats.byScope?.UNIVERSAL || 0; document.getElementById('stat-validated').textContent = stats.byValidationStatus?.PASSED || 0; const avgClarity = stats.averageScores?.clarity; document.getElementById('stat-clarity').textContent = avgClarity ? avgClarity.toFixed(0) + '%' : 'N/A'; } catch (error) { console.error('Failed to load statistics:', error); showToast('Failed to load statistics', 'error'); } } /** * Load and render rules based on current filters, sorting, and pagination * * @async * @description * - Builds query parameters from current filter state * - Fetches rules from API * - Renders rule cards in grid layout * - Updates pagination UI * - Shows loading/empty/error states * * @fires loadRules - Called on filter change, sort change, or page change */ async function loadRules() { const container = document.getElementById('rules-grid'); try { // Show loading state container.innerHTML = `

Loading rules...

`; setProgressBarWidths(container); // Build query parameters const params = new URLSearchParams({ page: currentPage, limit: pageSize, sort: filters.sort, order: filters.order }); if (filters.scope) params.append('scope', filters.scope); if (filters.quadrant) params.append('quadrant', filters.quadrant); if (filters.persistence) params.append('persistence', filters.persistence); if (filters.validation) params.append('validationStatus', filters.validation); if (filters.active) params.append('active', filters.active); if (filters.search) params.append('search', filters.search); // Include project ID for variable substitution if (selectedProjectId) params.append('projectId', selectedProjectId); const response = await apiRequest(`/api/admin/rules?${params.toString()}`); if (!response.success) { throw new Error('Failed to load rules'); } const rules = response.rules || []; totalRules = response.pagination?.total || 0; // Update results count document.getElementById('filter-results').textContent = `Showing ${rules.length} of ${totalRules} rules`; // Render rules if (rules.length === 0) { container.innerHTML = `

No rules found

Try adjusting your filters or create a new rule.

`; setProgressBarWidths(container); document.getElementById('pagination').classList.add('hidden'); return; } // Render rule cards container.innerHTML = `
${rules.map(rule => renderRuleCard(rule)).join('')}
`; setProgressBarWidths(container); // Update pagination updatePagination(response.pagination); } catch (error) { console.error('Failed to load rules:', error); container.innerHTML = `

Failed to load rules. Please try again.

`; setProgressBarWidths(container); showToast('Failed to load rules', 'error'); } } /** * Render a single rule as an HTML card * * @param {Object} rule - Rule object from API * @param {string} rule._id - MongoDB ObjectId * @param {string} rule.id - Rule ID (inst_xxx) * @param {string} rule.text - Rule text * @param {string} rule.scope - UNIVERSAL | PROJECT_SPECIFIC * @param {string} rule.quadrant - STRATEGIC | OPERATIONAL | TACTICAL | SYSTEM | STORAGE * @param {string} rule.persistence - HIGH | MEDIUM | LOW * @param {number} rule.priority - Priority (0-100) * @param {number} [rule.clarityScore] - Clarity score (0-100) * @param {Array} [rule.variables] - Detected variables * @param {Object} [rule.usageStats] - Usage statistics * * @returns {string} HTML string for rule card * * @description * Generates a card with: * - Scope, quadrant, persistence, validation status badges * - Rule text (truncated to 2 lines) * - Priority, variable count, enforcement count * - Clarity score progress bar * - View/Edit/Delete action buttons */ function renderRuleCard(rule) { const scopeBadgeColor = rule.scope === 'UNIVERSAL' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'; const quadrantBadgeColor = getQuadrantColor(rule.quadrant); const persistenceBadgeColor = getPersistenceColor(rule.persistence); const validationBadgeColor = getValidationColor(rule.validationStatus); const clarityScore = rule.clarityScore || 0; const clarityColor = clarityScore >= 80 ? 'bg-green-500' : clarityScore >= 60 ? 'bg-yellow-500' : 'bg-red-500'; return `
${rule.scope} ${rule.quadrant} ${rule.persistence} ${rule.validationStatus !== 'NOT_VALIDATED' ? ` ${rule.validationStatus} ` : ''}
${rule.id}
${rule.renderedText ? `
Template

${escapeHtml(rule.text)}

Rendered (${rule.projectContext || 'Unknown'})

${escapeHtml(rule.renderedText)}

` : `

${escapeHtml(rule.text)}

`}
Priority: ${rule.priority}
${rule.variables && rule.variables.length > 0 ? `
${rule.variables.length} var${rule.variables.length !== 1 ? 's' : ''}
` : ''} ${rule.usageStats?.timesEnforced > 0 ? `
${rule.usageStats.timesEnforced} enforcements
` : ''}
${rule.clarityScore !== null ? `
Clarity:
${clarityScore}%
` : ''}
`; } /** * Update pagination UI with page numbers and navigation buttons * * @param {Object} pagination - Pagination metadata from API * @param {number} pagination.page - Current page number * @param {number} pagination.limit - Items per page * @param {number} pagination.total - Total number of items * @param {number} pagination.pages - Total number of pages * * @description * - Shows/hides pagination based on total items * - Generates smart page number buttons (shows first, last, and pages around current) * - Adds ellipsis (...) for gaps in page numbers * - Enables/disables prev/next buttons based on current page */ function updatePagination(pagination) { const paginationDiv = document.getElementById('pagination'); if (!pagination || pagination.total === 0) { paginationDiv.classList.add('hidden'); return; } paginationDiv.classList.remove('hidden'); const start = (pagination.page - 1) * pagination.limit + 1; const end = Math.min(pagination.page * pagination.limit, pagination.total); document.getElementById('page-start').textContent = start; document.getElementById('page-end').textContent = end; document.getElementById('page-total').textContent = pagination.total; // Update page buttons const prevBtn = document.getElementById('prev-page'); const nextBtn = document.getElementById('next-page'); prevBtn.disabled = pagination.page <= 1; nextBtn.disabled = pagination.page >= pagination.pages; // Generate page numbers const pageNumbers = document.getElementById('page-numbers'); const pages = []; const currentPage = pagination.page; const totalPages = pagination.pages; // Always show first page pages.push(1); // Show pages around current page for (let i = Math.max(2, currentPage - 1); i <= Math.min(totalPages - 1, currentPage + 1); i++) { if (!pages.includes(i)) pages.push(i); } // Always show last page if (totalPages > 1 && !pages.includes(totalPages)) { pages.push(totalPages); } pageNumbers.innerHTML = pages.map((page, index) => { const prev = pages[index - 1]; const gap = prev && page - prev > 1 ? '...' : ''; const active = page === currentPage ? 'bg-indigo-600 text-white' : 'border border-gray-300 text-gray-700 hover:bg-gray-50'; return ` ${gap} `; }).join(''); } // Pagination handlers function goToPage(page) { currentPage = page; loadRules(); window.scrollTo({ top: 0, behavior: 'smooth' }); } document.getElementById('prev-page')?.addEventListener('click', () => { if (currentPage > 1) { goToPage(currentPage - 1); } }); document.getElementById('next-page')?.addEventListener('click', () => { const maxPage = Math.ceil(totalRules / pageSize); if (currentPage < maxPage) { goToPage(currentPage + 1); } }); // Filter handlers function applyFilters() { currentPage = 1; // Reset to first page when filters change loadRules(); } document.getElementById('filter-scope')?.addEventListener('change', (e) => { filters.scope = e.target.value; applyFilters(); }); document.getElementById('filter-quadrant')?.addEventListener('change', (e) => { filters.quadrant = e.target.value; applyFilters(); }); document.getElementById('filter-persistence')?.addEventListener('change', (e) => { filters.persistence = e.target.value; applyFilters(); }); document.getElementById('filter-validation')?.addEventListener('change', (e) => { filters.validation = e.target.value; applyFilters(); }); document.getElementById('filter-active')?.addEventListener('change', (e) => { filters.active = e.target.value; applyFilters(); }); document.getElementById('sort-by')?.addEventListener('change', (e) => { filters.sort = e.target.value; applyFilters(); }); document.getElementById('sort-order')?.addEventListener('change', (e) => { filters.order = e.target.value; applyFilters(); }); // Search with debouncing let searchTimeout; document.getElementById('search-box')?.addEventListener('input', (e) => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { filters.search = e.target.value; applyFilters(); }, 500); // 500ms debounce }); // Clear filters document.getElementById('clear-filters-btn')?.addEventListener('click', () => { filters = { scope: '', quadrant: '', persistence: '', validation: '', active: 'true', search: '', sort: 'priority', order: 'desc' }; document.getElementById('filter-scope').value = ''; document.getElementById('filter-quadrant').value = ''; document.getElementById('filter-persistence').value = ''; document.getElementById('filter-validation').value = ''; document.getElementById('filter-active').value = 'true'; document.getElementById('search-box').value = ''; document.getElementById('sort-by').value = 'priority'; document.getElementById('sort-order').value = 'desc'; applyFilters(); }); // CRUD operations async function viewRule(ruleId) { if (window.ruleEditor) { window.ruleEditor.openView(ruleId); } else { showToast('Rule editor not loaded', 'error'); } } async function editRule(ruleId) { if (window.ruleEditor) { window.ruleEditor.openEdit(ruleId); } else { showToast('Rule editor not loaded', 'error'); } } async function deleteRule(ruleId, ruleName) { if (!confirm(`Delete rule "${ruleName}"? This will deactivate the rule (soft delete).`)) { return; } try { const response = await apiRequest(`/api/admin/rules/${ruleId}`, { method: 'DELETE' }); if (response.success) { showToast('Rule deleted successfully', 'success'); loadRules(); loadStatistics(); } else { showToast(response.message || 'Failed to delete rule', 'error'); } } catch (error) { console.error('Delete error:', error); showToast('Failed to delete rule', 'error'); } } // New rule button document.getElementById('new-rule-btn')?.addEventListener('click', () => { if (window.ruleEditor) { window.ruleEditor.openCreate(); } else { showToast('Rule editor not loaded', 'error'); } }); /** * Show a toast notification message * * @param {string} message - Message to display * @param {string} [type='info'] - Toast type (success | error | warning | info) * * @description * - Creates animated toast notification in top-right corner * - Auto-dismisses after 5 seconds * - Can be manually dismissed by clicking X button * - Color-coded by type (green=success, red=error, yellow=warning, blue=info) */ function showToast(message, type = 'info') { const container = document.getElementById('toast-container'); const colors = { success: 'bg-green-500', error: 'bg-red-500', warning: 'bg-yellow-500', info: 'bg-blue-500' }; const toast = document.createElement('div'); toast.className = `${colors[type]} text-white px-6 py-3 rounded-lg shadow-lg flex items-center space-x-2 transition-all duration-300 ease-in-out`; toast.style.opacity = '0'; toast.style.transform = 'translateX(100px)'; toast.innerHTML = ` ${escapeHtml(message)} `; container.appendChild(toast); // Trigger animation setTimeout(() => { toast.style.opacity = '1'; toast.style.transform = 'translateX(0)'; }, 10); // Auto-remove after 5 seconds setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = 'translateX(100px)'; setTimeout(() => toast.remove(), 300); }, 5000); } // Utility functions function getQuadrantColor(quadrant) { const colors = { STRATEGIC: 'bg-purple-100 text-purple-800', OPERATIONAL: 'bg-green-100 text-green-800', TACTICAL: 'bg-yellow-100 text-yellow-800', SYSTEM: 'bg-blue-100 text-blue-800', STORAGE: 'bg-gray-100 text-gray-800' }; return colors[quadrant] || 'bg-gray-100 text-gray-800'; } function getPersistenceColor(persistence) { const colors = { HIGH: 'bg-red-100 text-red-800', MEDIUM: 'bg-orange-100 text-orange-800', LOW: 'bg-yellow-100 text-yellow-800' }; return colors[persistence] || 'bg-gray-100 text-gray-800'; } function getValidationColor(status) { const colors = { PASSED: 'bg-green-100 text-green-800', FAILED: 'bg-red-100 text-red-800', NEEDS_REVIEW: 'bg-yellow-100 text-yellow-800', NOT_VALIDATED: 'bg-gray-100 text-gray-800' }; return colors[status] || 'bg-gray-100 text-gray-800'; } function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } // Make functions global for onclick handlers window.viewRule = viewRule; window.editRule = editRule; window.deleteRule = deleteRule; window.goToPage = goToPage; /** * Initialize project selector for variable substitution * When a project is selected, rules will show both template and rendered text */ const projectSelector = new ProjectSelector('project-selector-container', { showAllOption: true, allOptionText: 'All Projects (Template View)', label: 'Project Context for Variable Substitution', showLabel: true, compact: false, onChange: (projectId, project) => { // Update selected project state selectedProjectId = projectId; // Reload rules with new project context currentPage = 1; // Reset to first page loadRules(); // Show toast notification if (projectId && project) { showToast(`Viewing rules with ${project.name} context`, 'info'); } else { showToast('Viewing template rules (no variable substitution)', 'info'); } } }); // Initialize on page load loadStatistics(); loadRules(); // Set widths/heights from data attributes (CSP compliance) function setProgressBarWidths(container) { const elements = container.querySelectorAll('[data-width], [data-height]'); elements.forEach(el => { if (el.dataset.width) el.style.width = el.dataset.width + '%'; if (el.dataset.height) el.style.height = el.dataset.height + '%'; }); } // Event delegation for data-action buttons (CSP compliance) document.addEventListener('click', (e) => { const button = e.target.closest('[data-action]'); if (!button) return; const action = button.dataset.action; const arg0 = button.dataset.arg0; const arg1 = button.dataset.arg1; switch (action) { case 'viewRule': viewRule(arg0); break; case 'editRule': editRule(arg0); break; case 'deleteRule': deleteRule(arg0, arg1); break; case 'goToPage': goToPage(parseInt(arg0)); break; case 'remove-parent': button.parentElement.remove(); break; } });