Adds RBAC matrix API/UI behind feature flags, agent×role toggles with audit, and extends Agentic Ops with streaming chat, Kimi LLM support, and UI updates.
651 lines
24 KiB
JavaScript
651 lines
24 KiB
JavaScript
(function () {
|
||
'use strict';
|
||
|
||
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
|
||
const LEVEL_SHORT = {
|
||
full: 'ADM',
|
||
read: 'READ',
|
||
link: 'LINK',
|
||
api: 'API',
|
||
system: 'SYS',
|
||
none: '—',
|
||
};
|
||
|
||
const state = {
|
||
data: null,
|
||
tab: 'overview',
|
||
selectedRole: 'super_admin',
|
||
softwareFilter: 'all',
|
||
saving: false,
|
||
saveError: null,
|
||
};
|
||
|
||
async function patchApi(path, body) {
|
||
const r = await fetchWithTimeout(`/api/v1${path}`, {
|
||
method: 'PATCH',
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!r.ok) throw new Error(`${r.status} ${(await r.text()).slice(0, 200)}`);
|
||
return r.json();
|
||
}
|
||
|
||
function bindingMeta(agentId, roleId, relation) {
|
||
const b = state.data?.agent_bindings_matrix?.agents?.[agentId]?.[roleId]?.[relation];
|
||
return {
|
||
enabled: !!b?.enabled,
|
||
locked: !!b?.locked,
|
||
};
|
||
}
|
||
|
||
function capMeta(capId, roleId) {
|
||
const c = state.data?.agent_bindings_matrix?.caps?.[capId]?.[roleId];
|
||
return { enabled: !!c?.enabled, locked: !!c?.locked };
|
||
}
|
||
|
||
async function toggleBinding(agentId, roleId, relation) {
|
||
const meta = bindingMeta(agentId, roleId, relation);
|
||
if (meta.locked) return;
|
||
state.saving = true;
|
||
state.saveError = null;
|
||
try {
|
||
await patchApi('/rbac/agent-bindings', {
|
||
agent_id: agentId,
|
||
role_id: roleId,
|
||
relation,
|
||
enabled: !meta.enabled,
|
||
});
|
||
state.data = await api('/rbac/matrix');
|
||
const root = document.getElementById('access-matrix-content');
|
||
if (root) paint(root);
|
||
} catch (e) {
|
||
state.saveError = e.message;
|
||
const root = document.getElementById('access-matrix-content');
|
||
if (root) paint(root);
|
||
} finally {
|
||
state.saving = false;
|
||
}
|
||
}
|
||
|
||
async function toggleCap(capId, roleId) {
|
||
const meta = capMeta(capId, roleId);
|
||
if (meta.locked) return;
|
||
state.saving = true;
|
||
state.saveError = null;
|
||
try {
|
||
await patchApi('/rbac/agent-governance', {
|
||
cap_id: capId,
|
||
role_id: roleId,
|
||
enabled: !meta.enabled,
|
||
});
|
||
state.data = await api('/rbac/matrix');
|
||
const root = document.getElementById('access-matrix-content');
|
||
if (root) paint(root);
|
||
} catch (e) {
|
||
state.saveError = e.message;
|
||
const root = document.getElementById('access-matrix-content');
|
||
if (root) paint(root);
|
||
} finally {
|
||
state.saving = false;
|
||
}
|
||
}
|
||
|
||
async function api(path) {
|
||
const r = await fetchWithTimeout(`/api/v1${path}`, {
|
||
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||
});
|
||
if (!r.ok) throw new Error(`${r.status} ${(await r.text()).slice(0, 200)}`);
|
||
return r.json();
|
||
}
|
||
|
||
function levelMeta(level) {
|
||
return state.data.legend[level] || state.data.legend.none;
|
||
}
|
||
|
||
function badgeHtml(level, opts = {}) {
|
||
const meta = levelMeta(level);
|
||
const short = LEVEL_SHORT[meta.access] || '—';
|
||
const cls = opts.compact ? ' am-badge--compact' : '';
|
||
return `<span class="am-badge am-badge--${meta.access}${cls}" title="${esc(meta.label)}">${short}</span>`;
|
||
}
|
||
|
||
function serviceLabel(svc) {
|
||
return state.data.service_labels?.[svc] || svc;
|
||
}
|
||
|
||
function rolesByCategory(catalog, categoryLabels) {
|
||
const groups = {};
|
||
Object.values(catalog.roles || {}).forEach((role) => {
|
||
const cat = role.category || 'platform';
|
||
if (!groups[cat]) groups[cat] = [];
|
||
groups[cat].push(role);
|
||
});
|
||
const order = ['ops', 'commercial', 'business', 'platform', 'system'];
|
||
return order
|
||
.filter((c) => groups[c]?.length)
|
||
.map((c) => ({ id: c, label: categoryLabels[c] || c, roles: groups[c] }));
|
||
}
|
||
|
||
function roleDeskRows() {
|
||
const roleId = state.selectedRole;
|
||
return (state.data.desk_modules || []).map((row) => ({
|
||
...row,
|
||
level: row.levels[roleId] || 'none',
|
||
})).filter((r) => r.level !== 'none');
|
||
}
|
||
|
||
function renderRoleSidebar() {
|
||
const groups = rolesByCategory(state.data.catalog, state.data.category_labels || {});
|
||
return groups.map((g) => `
|
||
<div class="am-role-group">
|
||
<h4>${esc(g.label)}</h4>
|
||
${g.roles.map((r) => `
|
||
<button type="button" class="am-role-btn${state.selectedRole === r.id ? ' active' : ''}"
|
||
data-am-role="${esc(r.id)}">${esc(r.label)}</button>
|
||
`).join('')}
|
||
</div>`).join('');
|
||
}
|
||
|
||
function renderScopeBar() {
|
||
return `<div class="am-scope-bar">
|
||
${(state.data.scope_layers || []).map((l) => `
|
||
<div class="am-scope-item">
|
||
<span class="am-scope-label">${esc(l.label)}</span>
|
||
<span class="am-scope-host">${esc(l.host)}</span>
|
||
<span class="am-scope-desc">${esc(l.desc)}</span>
|
||
</div>`).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
function renderMatrixTable(rows, rowLabel, idKey) {
|
||
const { role_columns, legend } = state.data;
|
||
const cols = role_columns || [];
|
||
const head = cols.map((c) => {
|
||
const label = state.data.catalog.roles[c]?.label || c;
|
||
const hl = c === state.selectedRole ? ' am-col-active' : '';
|
||
return `<th class="${hl.trim()}" title="${esc(c)}">${esc(label.split(' ')[0])}</th>`;
|
||
}).join('');
|
||
|
||
const body = rows.map((row) => {
|
||
const cells = cols.map((role) => {
|
||
const lv = row.levels[role] || 'none';
|
||
const tdHl = role === state.selectedRole ? ' am-row-highlight' : '';
|
||
return `<td class="${tdHl.trim()}">${badgeHtml(lv)}</td>`;
|
||
}).join('');
|
||
const sub = row.product
|
||
? `<span class="am-row-sub">${esc(row.product)}</span>`
|
||
: `<code class="am-row-code">${esc(row[idKey] || '')}</code>`;
|
||
return `<tr>
|
||
<td class="am-sticky">
|
||
<span class="am-row-title">${esc(row.label)}</span>
|
||
${sub}
|
||
</td>${cells}</tr>`;
|
||
}).join('');
|
||
|
||
return `
|
||
<div class="am-table-wrap">
|
||
<table class="am-matrix-table">
|
||
<thead><tr><th class="am-sticky">${esc(rowLabel)}</th>${head}</tr></thead>
|
||
<tbody>${body}</tbody>
|
||
</table>
|
||
</div>`;
|
||
}
|
||
|
||
function renderDeskMatrix() {
|
||
return `
|
||
<p class="am-section-lead">Grelha completa — módulos internos do Desk (VM122). Use <strong>Visão da função</strong> para resumo ou <strong>Software & Infra</strong> para VM112/123.</p>
|
||
${renderMatrixTable(state.data.desk_modules || [], 'Módulo Desk', 'id')}`;
|
||
}
|
||
|
||
function renderBindingCards(bindings, title) {
|
||
if (!bindings.length) {
|
||
return `<p class="am-empty">${title ? esc(title) + ' — ' : ''}sem registos para esta função.</p>`;
|
||
}
|
||
const byService = {};
|
||
bindings.forEach((b) => {
|
||
if (!byService[b.service]) byService[b.service] = [];
|
||
byService[b.service].push(b);
|
||
});
|
||
return `
|
||
${title ? `<h4 class="am-block-title">${esc(title)}</h4>` : ''}
|
||
<div class="am-api-grid">
|
||
${Object.entries(byService).flatMap(([svc, items]) =>
|
||
items.map((b) => `
|
||
<article class="am-api-card am-svc-${esc(b.service)}">
|
||
<header class="am-api-card-head">
|
||
<span class="am-api-service">${esc(serviceLabel(svc))}</span>
|
||
${badgeHtml(b.access || 'full', { compact: true })}
|
||
</header>
|
||
<div class="am-api-type">${esc(b.type || 'binding')}</div>
|
||
<code class="am-api-value">${esc(b.value)}</code>
|
||
<footer class="am-api-foot">${esc(svc)}</footer>
|
||
</article>`)
|
||
).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
function renderSoftwareCards(rows, title) {
|
||
if (!rows.length) {
|
||
return `<p class="am-empty">${esc(title || 'Software')} — sem acesso directo.</p>`;
|
||
}
|
||
return `
|
||
${title ? `<h4 class="am-block-title">${esc(title)}</h4>` : ''}
|
||
<div class="am-sw-grid">
|
||
${rows.map((r) => {
|
||
const meta = levelMeta(r.level);
|
||
return `<article class="am-sw-card">
|
||
<header class="am-sw-card-head">
|
||
<div>
|
||
<strong>${esc(r.label)}</strong>
|
||
<span class="am-sw-product">${esc(r.product)}</span>
|
||
</div>
|
||
${badgeHtml(r.level, { compact: true })}
|
||
</header>
|
||
<p class="am-sw-group">${esc(r.group)}</p>
|
||
${r.host && r.host !== '—' ? `<code class="am-sw-host">${esc(r.host)}</code>` : ''}
|
||
</article>`;
|
||
}).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
function renderDeskChips() {
|
||
const rows = roleDeskRows();
|
||
if (!rows.length) {
|
||
return '<p class="am-empty">Sem módulos Desk activos para esta função.</p>';
|
||
}
|
||
return `<div class="am-desk-chips">
|
||
${rows.map((r) => `
|
||
<span class="am-desk-chip" title="${esc(r.id)}">
|
||
${esc(r.label)}
|
||
${badgeHtml(r.level, { compact: true })}
|
||
</span>`).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
function renderRoleStats(role) {
|
||
const sw = state.data.role_software_summaries?.[state.selectedRole] || [];
|
||
const bindings = role?.bindings || [];
|
||
const desk = roleDeskRows();
|
||
return `<div class="am-stats">
|
||
<div class="am-stat"><span class="am-stat-n">${desk.length}</span><span class="am-stat-l">Módulos Desk</span></div>
|
||
<div class="am-stat"><span class="am-stat-n">${sw.length}</span><span class="am-stat-l">Software</span></div>
|
||
<div class="am-stat"><span class="am-stat-n">${bindings.length}</span><span class="am-stat-l">APIs / Grupos</span></div>
|
||
<div class="am-stat"><span class="am-stat-n">${(state.data.role_agents_summaries?.[state.selectedRole] || []).length}</span><span class="am-stat-l">Agentes</span></div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderRoleOverview() {
|
||
const role = state.data.catalog.roles[state.selectedRole];
|
||
const sw = state.data.role_software_summaries?.[state.selectedRole] || [];
|
||
const bindings = role?.bindings || [];
|
||
|
||
return `
|
||
${renderScopeBar()}
|
||
${renderRoleStats(role)}
|
||
<section class="am-block">
|
||
<h4 class="am-block-title">Desk VM122 — módulos activos</h4>
|
||
${renderDeskChips()}
|
||
</section>
|
||
<section class="am-block">
|
||
${renderSoftwareCards(sw, 'Software & infra em escopo')}
|
||
</section>
|
||
<section class="am-block">
|
||
${renderBindingCards(bindings, 'APIs, grupos Odoo e permissões')}
|
||
</section>
|
||
<section class="am-block">
|
||
<h4 class="am-block-title">Agentics — capacidades da função</h4>
|
||
${renderRoleAgentCaps()}
|
||
${renderRoleAgentsSummary('')}
|
||
</section>`;
|
||
}
|
||
|
||
function renderSoftwareMatrix() {
|
||
const { software_groups } = state.data;
|
||
const groups = (software_groups || []).filter((g) =>
|
||
state.softwareFilter === 'all' || g.id === state.softwareFilter
|
||
);
|
||
|
||
const filters = [
|
||
{ id: 'all', label: 'Todas as camadas' },
|
||
...(software_groups || []).map((g) => ({ id: g.id, label: g.label.split('—')[0].trim() })),
|
||
];
|
||
|
||
const filterBar = `
|
||
<div class="am-filter-bar">
|
||
${filters.map((f) => `
|
||
<button type="button" class="am-filter${state.softwareFilter === f.id ? ' active' : ''}"
|
||
data-am-sw-filter="${esc(f.id)}">${esc(f.label)}</button>`).join('')}
|
||
</div>`;
|
||
|
||
const sections = groups.map((grp) => {
|
||
const meta = [grp.host, grp.url].filter(Boolean).join(' · ');
|
||
return `
|
||
<section class="am-sw-section">
|
||
<header class="am-sw-section-head">
|
||
<div>
|
||
<h4>${esc(grp.label)}</h4>
|
||
${meta ? `<span class="am-sw-meta">${esc(meta)}</span>` : ''}
|
||
</div>
|
||
</header>
|
||
${renderMatrixTable(grp.items || [], 'Recurso', 'id')}
|
||
</section>`;
|
||
}).join('');
|
||
|
||
const sw = state.data.role_software_summaries?.[state.selectedRole] || [];
|
||
return `
|
||
<p class="am-section-lead">Matriz de software fora do Desk — VM112 (onboard/mail), VM123 (finance/hosting) e consolas de infra.</p>
|
||
${renderSoftwareCards(sw, 'Resumo da função seleccionada')}
|
||
${filterBar}${sections}`;
|
||
}
|
||
|
||
function renderBindings() {
|
||
const role = state.data.catalog.roles[state.selectedRole];
|
||
if (!role) return '<p class="am-empty">Função não encontrada</p>';
|
||
return `
|
||
<p class="am-section-lead">Bindings estilo Odoo — grupos, roles e permissões provisionados por função.</p>
|
||
${renderBindingCards(role.bindings || [], '')}`;
|
||
}
|
||
|
||
function roleLabel(roleId) {
|
||
return state.data.catalog.roles[roleId]?.label || roleId;
|
||
}
|
||
|
||
function renderRelationChip(rel) {
|
||
const labels = state.data.agent_relation_labels || {
|
||
approve: 'Aprova',
|
||
focus: 'Operador',
|
||
ui: 'UI',
|
||
};
|
||
return `<span class="am-rel am-rel--${esc(rel)}">${esc(labels[rel] || rel)}</span>`;
|
||
}
|
||
|
||
function renderRoleChips(roleIds) {
|
||
return (roleIds || []).map((r) =>
|
||
`<span class="am-role-chip${r === state.selectedRole ? ' am-role-chip--sel' : ''}" title="${esc(r)}">${esc(roleLabel(r))}</span>`
|
||
).join('');
|
||
}
|
||
|
||
function renderAgentGovernanceLegend() {
|
||
const labels = state.data.agent_relation_labels || {};
|
||
return `<div class="am-agent-legend">
|
||
${Object.entries(labels).map(([k, v]) =>
|
||
`<span class="am-agent-legend-item">${renderRelationChip(k)} ${esc(v)}</span>`
|
||
).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
function renderRoleAgentCaps() {
|
||
const capsDef = [
|
||
['use_ui', 'UI Agentics'],
|
||
['trigger_runs', 'Disparar cenários'],
|
||
['approve_runbooks', 'Aprovar runbooks'],
|
||
['configure_models', 'Configurar LLM'],
|
||
];
|
||
const roleId = state.selectedRole;
|
||
if (!state.data.editable) {
|
||
const caps = state.data.role_agentic_caps?.[roleId] || [];
|
||
if (!caps.length) {
|
||
return '<p class="am-empty">Esta função não acede ao módulo Agentics.</p>';
|
||
}
|
||
return `<div class="am-cap-chips">
|
||
${caps.map((c) => `<span class="am-cap-chip">${esc(c.label)}</span>`).join('')}
|
||
</div>`;
|
||
}
|
||
return `<div class="am-cap-toggles">
|
||
${capsDef.map(([id, label]) => {
|
||
const meta = capMeta(id, roleId);
|
||
return `<button type="button"
|
||
class="am-cap-toggle${meta.enabled ? ' on' : ''}${meta.locked ? ' locked' : ''}"
|
||
${meta.locked ? 'disabled' : ''}
|
||
data-am-cap="${esc(id)}|${esc(roleId)}"
|
||
title="${esc(label)}">${esc(label)}</button>`;
|
||
}).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
function renderRoleAgentsSummary(title) {
|
||
const rows = state.data.role_agents_summaries?.[state.selectedRole] || [];
|
||
if (!rows.length) {
|
||
return `<p class="am-empty">${esc(title || 'Agentes')} — sem interacção directa.</p>`;
|
||
}
|
||
return `
|
||
${title ? `<h4 class="am-block-title">${esc(title)}</h4>` : ''}
|
||
<div class="am-agent-mini-grid">
|
||
${rows.map((a) => `
|
||
<article class="am-agent-mini${a.is_approver ? ' am-agent-mini--approve' : ''}">
|
||
<header>
|
||
<span class="am-agent-id">${esc(a.id)}</span>
|
||
<strong>${esc(a.name)}</strong>
|
||
</header>
|
||
<p class="am-agent-role">${esc(a.role)}</p>
|
||
<div class="am-rel-row">${(a.relations || []).map(renderRelationChip).join('')}</div>
|
||
</article>`).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
function renderAgentCard(a) {
|
||
const rels = a.role_relations?.[state.selectedRole] || [];
|
||
const hit = rels.length > 0;
|
||
return `
|
||
<article class="am-agent-card${hit ? ' am-agent-card--hit' : ''}">
|
||
<header class="am-agent-top">
|
||
<span class="am-agent-id">${esc(a.id)}</span>
|
||
<div>
|
||
<strong>${esc(a.name)}</strong>
|
||
<span class="am-agent-codename">${esc(a.codename)}</span>
|
||
</div>
|
||
</header>
|
||
<p class="am-agent-role">${esc(a.role)}</p>
|
||
${hit ? `<div class="am-rel-row am-rel-row--you">${rels.map(renderRelationChip).join('')} <span class="am-you">esta função</span></div>` : ''}
|
||
<div class="am-agent-section">
|
||
<span class="am-agent-lbl">Aprova</span>
|
||
${renderRoleChips(a.approvers)}
|
||
</div>
|
||
<div class="am-agent-section">
|
||
<span class="am-agent-lbl">Operadores</span>
|
||
${renderRoleChips(a.operators)}
|
||
</div>
|
||
${(a.reads || []).length ? `
|
||
<div class="am-agent-section">
|
||
<span class="am-agent-lbl">Lê</span>
|
||
<span class="am-agent-meta">${esc(a.reads.slice(0, 3).join(' · '))}</span>
|
||
</div>` : ''}
|
||
<footer class="am-agent-approval">
|
||
<span>Regra Spec 027</span>
|
||
<code>${esc(a.approval_text)}</code>
|
||
</footer>
|
||
</article>`;
|
||
}
|
||
|
||
function renderRelationToggle(agentId, roleId, relation) {
|
||
const meta = bindingMeta(agentId, roleId, relation);
|
||
const short = { ui: 'UI', focus: 'OP', approve: 'AP' }[relation] || relation;
|
||
return `<button type="button"
|
||
class="am-toggle${meta.enabled ? ' on' : ''}${meta.locked ? ' locked' : ''}"
|
||
${meta.locked ? 'disabled' : ''}
|
||
data-am-toggle="${esc(agentId)}|${esc(roleId)}|${relation}"
|
||
title="${esc(relation)}${meta.locked ? ' (obrigatório)' : ''}">${short}</button>`;
|
||
}
|
||
|
||
function renderAgentMatrixCell(agentId, role, rels) {
|
||
const tdHl = role === state.selectedRole ? ' am-row-highlight' : '';
|
||
if (!state.data.editable) {
|
||
const inner = rels.length
|
||
? rels.map(renderRelationChip).join('')
|
||
: '<span class="am-rel am-rel--none">—</span>';
|
||
return `<td class="${tdHl.trim()}">${inner}</td>`;
|
||
}
|
||
return `<td class="am-cell-toggles${tdHl.trim()}">
|
||
${renderRelationToggle(agentId, role, 'ui')}
|
||
${renderRelationToggle(agentId, role, 'focus')}
|
||
${renderRelationToggle(agentId, role, 'approve')}
|
||
</td>`;
|
||
}
|
||
|
||
function renderAgentsMatrix() {
|
||
const agents = state.data.agents || [];
|
||
const cols = state.data.role_columns || [];
|
||
const head = cols.map((c) => {
|
||
const hl = c === state.selectedRole ? ' am-col-active' : '';
|
||
return `<th class="${hl.trim()}" title="${esc(c)}">${esc(roleLabel(c).split(' ')[0])}</th>`;
|
||
}).join('');
|
||
|
||
const body = agents.map((a) => {
|
||
const cells = cols.map((role) => {
|
||
const rels = a.role_relations?.[role] || [];
|
||
return renderAgentMatrixCell(a.id, role, rels);
|
||
}).join('');
|
||
return `<tr>
|
||
<td class="am-sticky">
|
||
<span class="am-row-title">${esc(a.id)} · ${esc(a.name)}</span>
|
||
<span class="am-row-sub">${esc(a.role)}</span>
|
||
</td>${cells}</tr>`;
|
||
}).join('');
|
||
|
||
return `
|
||
<h4 class="am-block-title">Matriz agente × função</h4>
|
||
${renderAgentGovernanceLegend()}
|
||
<div class="am-table-wrap">
|
||
<table class="am-matrix-table">
|
||
<thead><tr><th class="am-sticky">Agente</th>${head}</tr></thead>
|
||
<tbody>${body}</tbody>
|
||
</table>
|
||
</div>`;
|
||
}
|
||
|
||
function renderAgents() {
|
||
const caps = state.data.role_agentic_caps?.[state.selectedRole] || [];
|
||
return `
|
||
<p class="am-section-lead">
|
||
Agentes A0–A7 usam conta <code>agent_system</code>.
|
||
${state.data.editable
|
||
? 'Clique <strong>UI / OP / AP</strong> para ligar ou desligar atribuições (audit activo).'
|
||
: 'Três relações: UI · Operador · Aprova.'}
|
||
</p>
|
||
${state.saveError ? `<p class="am-save-error">${esc(state.saveError)}</p>` : ''}
|
||
${(state.data.editable || caps.length) ? `<section class="am-block"><h4 class="am-block-title">Capacidades — ${esc(roleLabel(state.selectedRole))}</h4>${renderRoleAgentCaps()}</section>` : ''}
|
||
${renderAgentsMatrix()}
|
||
<section class="am-block">
|
||
<h4 class="am-block-title">Detalhe por agente</h4>
|
||
<div class="am-agents-grid">
|
||
${(state.data.agents || []).map(renderAgentCard).join('')}
|
||
</div>
|
||
</section>`;
|
||
}
|
||
|
||
function renderLegend() {
|
||
const legend = state.data.legend || {};
|
||
return Object.entries(legend).map(([key, l]) =>
|
||
`<span class="am-legend-item">${badgeHtml(key, { compact: true })} ${esc(l.label)}</span>`
|
||
).join('');
|
||
}
|
||
|
||
function activeTabHint() {
|
||
const tab = (state.data.tabs || []).find((t) => t.id === state.tab);
|
||
return tab?.hint || '';
|
||
}
|
||
|
||
function renderMainPanel() {
|
||
const role = state.data.catalog.roles[state.selectedRole];
|
||
let body = '';
|
||
if (state.tab === 'overview') body = renderRoleOverview();
|
||
else if (state.tab === 'vm122') body = renderDeskMatrix();
|
||
else if (state.tab === 'software') body = renderSoftwareMatrix();
|
||
else if (state.tab === 'bindings') body = renderBindings();
|
||
else if (state.tab === 'agents') body = renderAgents();
|
||
|
||
return `
|
||
<div class="am-panel">
|
||
<header class="am-panel-head">
|
||
<div class="am-panel-title">
|
||
<span class="am-panel-kicker">Spec 027 · RBAC</span>
|
||
<h3>${esc(role?.label || state.selectedRole)}</h3>
|
||
<p class="am-panel-desc">${esc(role?.description || '')}</p>
|
||
${activeTabHint() ? `<p class="am-tab-hint">${esc(activeTabHint())}</p>` : ''}
|
||
</div>
|
||
<div class="am-legend">${renderLegend()}</div>
|
||
</header>
|
||
<div class="am-panel-body">${body}</div>
|
||
</div>`;
|
||
}
|
||
|
||
function renderTabs() {
|
||
return (state.data.tabs || []).map((t) =>
|
||
`<button type="button" class="am-tab${state.tab === t.id ? ' active' : ''}" data-am-tab="${esc(t.id)}">${esc(t.label)}</button>`
|
||
).join('');
|
||
}
|
||
|
||
function bindEvents(root) {
|
||
root.querySelectorAll('[data-am-role]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
state.selectedRole = btn.dataset.amRole;
|
||
paint(root);
|
||
});
|
||
});
|
||
root.querySelectorAll('[data-am-tab]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
state.tab = btn.dataset.amTab;
|
||
paint(root);
|
||
});
|
||
});
|
||
root.querySelectorAll('[data-am-sw-filter]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
state.softwareFilter = btn.dataset.amSwFilter;
|
||
paint(root);
|
||
});
|
||
});
|
||
root.querySelectorAll('[data-am-toggle]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
if (state.saving || !state.data?.editable) return;
|
||
const [agentId, roleId, relation] = btn.dataset.amToggle.split('|');
|
||
toggleBinding(agentId, roleId, relation);
|
||
});
|
||
});
|
||
root.querySelectorAll('[data-am-cap]').forEach((btn) => {
|
||
btn.addEventListener('click', () => {
|
||
if (state.saving || !state.data?.editable) return;
|
||
const [capId, roleId] = btn.dataset.amCap.split('|');
|
||
toggleCap(capId, roleId);
|
||
});
|
||
});
|
||
}
|
||
|
||
function paint(root) {
|
||
root.innerHTML = `
|
||
<div class="am-wrap">
|
||
<div class="am-header">
|
||
<div>
|
||
<h2 class="am-page-title">Matriz de Acessos</h2>
|
||
<p class="am-page-sub">Quatro camadas: Desk VM122 · VM112 · VM123 · Infra externa</p>
|
||
</div>
|
||
<span class="am-preview-tag">${state.data?.editable ? 'Edição · audit ON' : 'Preview · read-only'}${state.saving ? ' · …' : ''}</span>
|
||
</div>
|
||
${state.saveError ? `<p class="am-save-error">${esc(state.saveError)}</p>` : ''}
|
||
<nav class="am-subnav">${renderTabs()}</nav>
|
||
<div class="am-layout">
|
||
<aside class="am-role-list">${renderRoleSidebar()}</aside>
|
||
${renderMainPanel()}
|
||
</div>
|
||
</div>`;
|
||
bindEvents(root);
|
||
}
|
||
|
||
async function renderAccessMatrix() {
|
||
const root = document.getElementById('access-matrix-content');
|
||
if (!root) return;
|
||
root.innerHTML = '<p class="loading">Carregando matriz de acessos…</p>';
|
||
try {
|
||
state.data = await api('/rbac/matrix');
|
||
if (!state.selectedRole && state.data.role_columns?.length) {
|
||
state.selectedRole = state.data.role_columns[0];
|
||
}
|
||
paint(root);
|
||
} catch (e) {
|
||
root.innerHTML = `<p class="loading">Matriz indisponível: ${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
window.renderAccessMatrix = renderAccessMatrix;
|
||
window.DeskAccessMatrix = { renderAccessMatrix };
|
||
})();
|