mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
9826ea15c5
* feat(coordinator): phase 7 — governance + skill metadata + cross-cutting invariants
Combines three stacked sub-PRs into a single coordinator phase-7
shipment against the phase-7 plan doc. The sub-PR structure (0 / A /
B) preserved on individual branches for reviewer drill-down; this
branch is the one reviewers should merge.
## Sub-PR 0 — service-auth boundary invariants
Shared helpers and contracts that lock the console ↔ node service-auth
boundary so later authz surfaces use them by construction.
- ``_effective_user_filter(request)`` in both ``turnstone.console.server``
and ``turnstone.server`` with a shared ``DENY_EMPTY_SUB`` sentinel
on ``turnstone.core.auth``. Three-way return — admin/service
bypass, scoped caller uid, or fail-closed sentinel on blank sub.
Four callsite migrations (``_coordinator_rows``,
``coordinator_children``, ``coordinator_metrics``,
``cluster_ws_live_bulk``).
- ``StorageBackend`` class docstring codifies the tenancy contract
(every list/count/aggregate method must accept ``user_id: str |
None = None`` and push ``WHERE user_id = :user_id`` into SQL) and
the ``_mapping`` row-access contract. New
``turnstone.testing.row_contract`` ships ``assert_row_like()``.
- ``_verify_collector_service_scope`` probes an upstream node at boot
with ``expected_node_id=_scope-probe_``; a 409 proves the scope
gate was passed, a 403/401 sets ``collector_scope_error`` and
causes ``cluster_snapshot`` / ``cluster_events_sse`` to return 503
with a remediation hint. Probe URL allowlist rejects non-http(s)
schemes and 169.254.0.0/16 hosts.
- 4xx log-level floor on ``_NodeDashboardCache.get``,
``_fetch_live_block``, and ``_proxy_sse`` — dotted-hierarchy
prefixes with bounded body previews. ``_bounded_body_preview`` and
``_bounded_stream_preview`` strip control chars.
## Sub-PR A — coordinator governance core
Mid-session governance surface for coordinator workstreams.
- **Trusted-session mode.** New ``coordinator.trust.send``
permission (migration 042). ``ChatSession.set_trust_send`` /
``revoke_tools`` methods with a ``_governance_lock``. ``POST
/v1/api/coordinator/{ws_id}/trust {send: bool}`` double-gated on
``admin.coordinator`` AND ``coordinator.trust.send`` with
``allow_service_bypass=False`` so service tokens can't escalate.
``_prepare_send_to_workstream`` auto-approves sends whose target is
in the coordinator's own subtree; foreign ws_ids still require
approval. ``_is_own_subtree`` checks both ``parent_ws_id`` AND
``user_id`` to defend against cross-tenant row corruption.
- **Audit-layer credential redaction.** ``record_audit`` walks
``detail`` (dicts, lists, tuples, sets, frozensets; keys too)
and routes every string through ``redact_credentials`` + a C0
control-char scrub. New kw-only ``raw_detail=True`` opt-out.
``_has_any_string`` fast-path. Audit action registry extended
with the four new governance sub-prefixes.
- **Mid-session revocation + cascading stop.** ``POST
/v1/api/coordinator/{ws_id}/restrict {revoke: [...]}`` caps 256
entries / 128 chars; ``_prepare_tool`` short-circuits with a
tool-error. ``POST /v1/api/coordinator/{ws_id}/stop_cascade``
cancels the coord's in-flight generation then dispatches
``cancel_workstream`` for every direct child in parallel via
``asyncio.gather`` bounded by ``Semaphore(16)``. Per-child
outcomes split into ``cancelled`` / ``failed`` / ``skipped``
(404 = already-gone rather than dispatch-broken). Both endpoints
apply ``allow_service_bypass=False`` on the admin gate.
- **Shared plumbing.** ``_resolve_coord_session`` helper collapses
the handler prelude three endpoints shared. ``_emit_coord_audit``
wraps ``record_audit`` in a dedicated ``ThreadPoolExecutor``
(``app.state.audit_executor``) so audit bursts don't starve cancel
dispatches. ``_require_json_object`` guards body parsing so non-
object JSON returns 400 instead of 500.
## Sub-PR B — skill metadata governance
- **Description validator (migration 043).** ``prompt_templates``
rows now require a non-empty ``description``. Existing empty rows
get backfilled with a ``"Skill: <name>"`` placeholder on upgrade.
The installer (``admin_skill_discover``) and MCP prompt sync both
synthesise a placeholder when the upstream description is blank
so non-admin write paths satisfy the invariant.
- **Skill kind classifier (migration 044).** New
``prompt_templates.kind`` column (``interactive`` / ``coordinator``
/ ``any``; defaults to ``any``). New
``turnstone.core.skill_kind.SkillKind`` StrEnum is the single
source of truth; Pydantic schemas type ``kind`` as ``SkillKind``
(OpenAPI advertises the enum) and the handler validator catches
the ValueError. ``list_skills_filtered`` gains a
``kinds: list[str] | None = None`` SQL filter.
``CoordinatorClient.list_skills`` defaults to
``kinds=["coordinator", "any"]`` so interactive-only skills are
hidden from the orchestrator.
- **``scan_status`` → ``risk_level`` rename (migration 045).**
Lossless column rename to align with ``IntentVerdict.risk_level``
terminology. Swept storage (both backends + schema + protocol),
handlers, API schemas, tool JSON, generated OpenAPI specs,
TypeScript SDK types, frontend (``governance.js``), tests, and
English prose in ``docs/judge.md`` + ``docs/tools.md``. The
user-facing on-load warning now reads ``has risk level:
{risk_tier}``. Tool JSON's ``risk_level`` enum corrected to the
scanner's actual taxonomy (``safe / low / medium / high /
critical``; was the never-shipped ``clean / flagged / unscanned /
pending``). Historical migration 021 left untouched.
## Migrations
042 (``coordinator.trust.send`` perm — PR A)
043 (description backfill — PR B)
044 (``kind`` column add — PR B)
045 (``scan_status`` → ``risk_level`` rename — PR B)
All four use position-anchored permission strings / host-side
parse-filter-rejoin on downgrade where SQL ``REPLACE`` could
corrupt prefix-overlapping values.
## Verification
- ``ruff check turnstone tests`` clean.
- ``mypy turnstone`` clean on 165 source files.
- ``pytest -m "not live"``: 4431 passed (+85 over the phase-6
baseline). Includes +32 tests in ``tests/test_service_auth_boundary.py``
and +38 in ``tests/test_coordinator_governance.py``; shared fixtures
extracted to ``tests/_coord_test_helpers.py``.
- Generated OpenAPI JSON (``sdk/typescript/openapi-{console,server}.json``)
regenerated via ``sdk/typescript/scripts/generate-types.py``; zero
``scan_status`` occurrences remaining outside the historical
migration 021 and the rename migration 045.
## Security reviews
Both reviews flagged by the phase-7 plan (items 1 + 5, plus 0a's
refuse-to-serve gate) ran through the multi-stage ``/review``
pipeline twice per sub-PR; all confirmed findings landed in-branch.
* fixup(phase-7): CI lint + PR #383 review fixups
Addresses the lint CI failure (ruff format) plus 12 findings from the
two automated PR reviewers.
Copilot:
- ``_sqlite.list_installed_skill_urls`` / ``_postgresql.list_installed_skill_urls``
used positional row indexing (``r[0]``/``r[1]``/``r[2]``) while this
same PR's ``StorageBackend`` class docstring forbids it. Switched
both to ``r._mapping["..."]`` access.
- ``list_skills.json`` previously advertised ``risk_level=""`` as a
filter for unscanned skills, but the implementation treats empty
strings as "no filter". Clarified the tool description to say
omit the filter entirely to include unscanned rows, and added an
explicit ``enum`` on the parameter restricting it to the scanner
tiers. ``_prepare_list_skills`` keeps the ``strip() or None``
normalisation — unscanned filtering now has an unambiguous contract.
- ``test_storage_skills_filtered.test_risk_level_filter`` used the
legacy ``clean`` / ``flagged`` values from the pre-rename column.
Rewritten with the scanner's actual taxonomy (``safe`` / ``high``).
github-code-quality (CodeQL):
- ``test_deny_sentinel_is_singleton`` previously asserted
``cs.DENY_EMPTY_SUB is cs.DENY_EMPTY_SUB`` — an identical-expression
comparison. Rewritten as two separate ``from ... import ... as`` aliases
(``FIRST_READ`` / ``SECOND_READ``) so the identity check is between
distinct bindings.
- ``test_restrict_empty_revoke_is_noop_but_audits`` unpacked ``state``
without using it. Renamed to ``_state``.
- Mixed import styles in ``test_service_auth_boundary.py`` — the
file previously used both ``import turnstone.console.server as cs``
and ``from turnstone.console.server import ...`` for the same
module (same story for ``turnstone.core.auth`` and
``turnstone.server``). Consolidated to the ``from X import Y`` style
used elsewhere in the file; the ``_fetch_live_block`` test now
patches via pytest's ``monkeypatch`` fixture instead of a manual
rebind through a module alias.
CI:
- ``ruff format`` reformatted one line in
``tests/test_coordinator_endpoints.py``.
Verification: ruff check + mypy clean (166 files); 4459 non-live
pytest pass.
* fix(tests): swap asyncio marker for anyio in service-auth boundary tests
PR #383 CI caught that the 13 ``@pytest.mark.asyncio`` decorators I
added in ``test_service_auth_boundary.py`` are an off-convention
choice — the rest of the repo uses ``@pytest.mark.anyio`` (148 sites
vs my 13). The CI environment pulls in ``anyio`` but not
``pytest-asyncio``, so every async test in this one file was failing
with "async def functions are not natively supported". It passed
locally by accident — my dev venv happens to have pytest-asyncio
installed ambiently.
Swapped all 13 marker sites to ``@pytest.mark.anyio``. No functional
change; the tests run under the same default asyncio backend anyio
provides.
Verification: ruff + mypy clean (166 files); 4459 non-live pytest
pass.
4114 lines
134 KiB
JavaScript
4114 lines
134 KiB
JavaScript
/* Governance tabs — roles, policies, skills, usage, audit */
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Module state
|
|
// ---------------------------------------------------------------------------
|
|
var _govRoles = [];
|
|
var _govPolicies = [];
|
|
var _govSkills = [];
|
|
var _govUsageRange = "7d";
|
|
var _govUsageGroupBy = "day";
|
|
var _govAuditEvents = [];
|
|
var _govAuditTotal = 0;
|
|
var _govAuditOffset = 0;
|
|
var _skillCurrentView = "installed";
|
|
var _skillDiscoverResults = [];
|
|
var _skillDiscoverQuery = "";
|
|
var _pendingResources = [];
|
|
var _giTrapHandler = null;
|
|
var _giTriggerEl = null;
|
|
|
|
// Trap handler refs for modals
|
|
var _crTrapHandler = null; // create role
|
|
var _erTrapHandler = null; // edit role
|
|
var _urTrapHandler = null; // user roles
|
|
var _cpTrapHandler = null; // create policy
|
|
var _epTrapHandler = null; // edit policy
|
|
var _ctmTrapHandler = null; // create template
|
|
var _etmTrapHandler = null; // edit template
|
|
|
|
// Trigger element refs for focus restoration
|
|
var _crTriggerEl = null;
|
|
var _erTriggerEl = null;
|
|
var _urTriggerEl = null;
|
|
var _cpTriggerEl = null;
|
|
var _epTriggerEl = null;
|
|
var _ctmTriggerEl = null;
|
|
var _etmTriggerEl = null;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Roles
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function loadGovRoles() {
|
|
authFetch("/v1/api/admin/roles")
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error("Failed");
|
|
return r.json();
|
|
})
|
|
.then(function (data) {
|
|
_govRoles = data.roles || [];
|
|
_renderGovRoles(_govRoles);
|
|
})
|
|
.catch(function () {
|
|
document.getElementById("admin-roles-table").innerHTML =
|
|
'<div class="dashboard-empty">Failed to load roles</div>';
|
|
});
|
|
}
|
|
|
|
function _renderGovRoles(items) {
|
|
var el = document.getElementById("admin-roles-table");
|
|
if (!items.length) {
|
|
el.innerHTML = '<div class="dashboard-empty">No roles defined</div>';
|
|
return;
|
|
}
|
|
var html = "";
|
|
for (var i = 0; i < items.length; i++) {
|
|
var r = items[i];
|
|
// Render permissions as badges
|
|
var perms = (r.permissions || "").split(",");
|
|
var badges = "";
|
|
for (var j = 0; j < perms.length; j++) {
|
|
var p = perms[j].trim();
|
|
if (!p) continue;
|
|
var cls = "scope-badge";
|
|
if (p === "approve" || p.indexOf("admin.") === 0) cls += " scope-approve";
|
|
else if (p === "write" || p.indexOf("workstreams.") === 0)
|
|
cls += " scope-write";
|
|
badges += '<span class="' + cls + '">' + escapeHtml(p) + "</span>";
|
|
}
|
|
var typeLabel = r.builtin
|
|
? '<span class="scope-badge scope-channel">builtin</span>'
|
|
: "";
|
|
var actions = r.builtin
|
|
? ""
|
|
: '<button class="admin-btn-action" data-edit-role="' +
|
|
escapeHtml(r.role_id) +
|
|
'">edit</button>' +
|
|
'<button class="admin-btn-danger" data-delete-role="' +
|
|
escapeHtml(r.role_id) +
|
|
'" data-role-name="' +
|
|
escapeHtml(r.name) +
|
|
'">delete</button>';
|
|
html +=
|
|
'<div class="admin-row" role="listitem">' +
|
|
'<span class="admin-col admin-col-rname">' +
|
|
escapeHtml(r.display_name) +
|
|
" " +
|
|
typeLabel +
|
|
"</span>" +
|
|
'<span class="admin-col admin-col-rperms">' +
|
|
badges +
|
|
"</span>" +
|
|
'<span class="admin-col admin-col-actions">' +
|
|
actions +
|
|
"</span></div>";
|
|
}
|
|
el.innerHTML = html;
|
|
// Bind edit
|
|
var editBtns = el.querySelectorAll("[data-edit-role]");
|
|
for (var k = 0; k < editBtns.length; k++) {
|
|
editBtns[k].addEventListener("click", function () {
|
|
showEditRoleModal(this.getAttribute("data-edit-role"));
|
|
});
|
|
}
|
|
// Bind delete
|
|
var delBtns = el.querySelectorAll("[data-delete-role]");
|
|
for (var k = 0; k < delBtns.length; k++) {
|
|
delBtns[k].addEventListener("click", function () {
|
|
var rid = this.getAttribute("data-delete-role");
|
|
var rname = this.getAttribute("data-role-name");
|
|
showConfirmModal(
|
|
"Delete Role",
|
|
'Delete role "' +
|
|
rname +
|
|
'"? Users with this role will lose its permissions.',
|
|
"Delete",
|
|
function () {
|
|
authFetch("/v1/api/admin/roles/" + rid, { method: "DELETE" })
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error();
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
showToast("Role deleted");
|
|
loadGovRoles();
|
|
})
|
|
.catch(function () {
|
|
showToast("Failed to delete role");
|
|
});
|
|
},
|
|
);
|
|
});
|
|
}
|
|
}
|
|
|
|
// All permission names for the checkbox UI
|
|
var _ALL_PERMISSIONS = [
|
|
"read",
|
|
"write",
|
|
"approve",
|
|
"admin.users",
|
|
"admin.roles",
|
|
"admin.orgs",
|
|
"admin.policies",
|
|
"admin.skills",
|
|
"admin.audit",
|
|
"admin.usage",
|
|
"admin.schedules",
|
|
"admin.watches",
|
|
"admin.judge",
|
|
"admin.memories",
|
|
"admin.settings",
|
|
"admin.mcp",
|
|
"tools.approve",
|
|
"workstreams.create",
|
|
"workstreams.close",
|
|
];
|
|
|
|
function _buildPermCheckboxes(prefix, selected) {
|
|
var html = '<div class="perm-grid">';
|
|
for (var i = 0; i < _ALL_PERMISSIONS.length; i++) {
|
|
var p = _ALL_PERMISSIONS[i];
|
|
var checked = selected && selected.indexOf(p) >= 0 ? " checked" : "";
|
|
html +=
|
|
'<label class="perm-checkbox"><input type="checkbox" value="' +
|
|
p +
|
|
'" name="' +
|
|
prefix +
|
|
'-perm"' +
|
|
checked +
|
|
"> " +
|
|
escapeHtml(p) +
|
|
"</label>";
|
|
}
|
|
html += "</div>";
|
|
return html;
|
|
}
|
|
|
|
function _collectPermCheckboxes(prefix) {
|
|
var boxes = document.querySelectorAll(
|
|
'input[name="' + prefix + '-perm"]:checked',
|
|
);
|
|
var perms = [];
|
|
for (var i = 0; i < boxes.length; i++) perms.push(boxes[i].value);
|
|
return perms.join(",");
|
|
}
|
|
|
|
function showCreateRoleModal() {
|
|
_crTriggerEl = document.activeElement;
|
|
var ov = document.getElementById("create-role-overlay");
|
|
ov.style.display = "flex";
|
|
document.getElementById("cr-name").value = "";
|
|
document.getElementById("cr-displayname").value = "";
|
|
document.getElementById("cr-perms-container").innerHTML =
|
|
_buildPermCheckboxes("cr", []);
|
|
document.getElementById("create-role-error").style.display = "none";
|
|
document.getElementById("cr-name").focus();
|
|
_crTrapHandler = _installTrap("create-role-overlay", "create-role-box");
|
|
}
|
|
|
|
function hideCreateRoleModal() {
|
|
document.getElementById("create-role-overlay").style.display = "none";
|
|
_crTrapHandler = _removeTrap(_crTrapHandler);
|
|
if (_crTriggerEl && _crTriggerEl.focus) {
|
|
_crTriggerEl.focus();
|
|
}
|
|
_crTriggerEl = null;
|
|
}
|
|
|
|
function submitCreateRole() {
|
|
var name = document.getElementById("cr-name").value.trim();
|
|
var dname = document.getElementById("cr-displayname").value.trim();
|
|
var perms = _collectPermCheckboxes("cr");
|
|
if (!name) {
|
|
var e = document.getElementById("create-role-error");
|
|
e.textContent = "Name is required";
|
|
e.style.display = "";
|
|
return;
|
|
}
|
|
if (!dname) dname = name;
|
|
document.getElementById("cr-submit").disabled = true;
|
|
authFetch("/v1/api/admin/roles", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
name: name,
|
|
display_name: dname,
|
|
permissions: perms,
|
|
}),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
hideCreateRoleModal();
|
|
showToast("Role created");
|
|
loadGovRoles();
|
|
})
|
|
.catch(function (e) {
|
|
var el = document.getElementById("create-role-error");
|
|
el.textContent = e.message;
|
|
el.style.display = "";
|
|
})
|
|
.finally(function () {
|
|
document.getElementById("cr-submit").disabled = false;
|
|
});
|
|
}
|
|
|
|
function showEditRoleModal(roleId) {
|
|
_erTriggerEl = document.activeElement;
|
|
var role = null;
|
|
for (var i = 0; i < _govRoles.length; i++) {
|
|
if (_govRoles[i].role_id === roleId) {
|
|
role = _govRoles[i];
|
|
break;
|
|
}
|
|
}
|
|
if (!role) return;
|
|
var ov = document.getElementById("edit-role-overlay");
|
|
ov.style.display = "flex";
|
|
document.getElementById("er-id").value = roleId;
|
|
document.getElementById("er-name").value = role.display_name;
|
|
var selected = (role.permissions || "").split(",");
|
|
document.getElementById("er-perms-container").innerHTML =
|
|
_buildPermCheckboxes("er", selected);
|
|
document.getElementById("edit-role-error").style.display = "none";
|
|
_erTrapHandler = _installTrap("edit-role-overlay", "edit-role-box");
|
|
}
|
|
|
|
function hideEditRoleModal() {
|
|
document.getElementById("edit-role-overlay").style.display = "none";
|
|
_erTrapHandler = _removeTrap(_erTrapHandler);
|
|
if (_erTriggerEl && _erTriggerEl.focus) {
|
|
_erTriggerEl.focus();
|
|
}
|
|
_erTriggerEl = null;
|
|
}
|
|
|
|
function submitEditRole() {
|
|
var roleId = document.getElementById("er-id").value;
|
|
var dname = document.getElementById("er-name").value.trim();
|
|
var perms = _collectPermCheckboxes("er");
|
|
document.getElementById("er-submit").disabled = true;
|
|
authFetch("/v1/api/admin/roles/" + roleId, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ display_name: dname, permissions: perms }),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
hideEditRoleModal();
|
|
showToast("Role updated");
|
|
loadGovRoles();
|
|
})
|
|
.catch(function (e) {
|
|
var el = document.getElementById("edit-role-error");
|
|
el.textContent = e.message;
|
|
el.style.display = "";
|
|
})
|
|
.finally(function () {
|
|
document.getElementById("er-submit").disabled = false;
|
|
});
|
|
}
|
|
|
|
// User roles modal (launched from Users tab)
|
|
function showUserRolesModal(userId) {
|
|
_urTriggerEl = document.activeElement;
|
|
var ov = document.getElementById("user-roles-overlay");
|
|
ov.style.display = "flex";
|
|
document.getElementById("ur-user-id").value = userId;
|
|
var container = document.getElementById("ur-roles-container");
|
|
container.innerHTML = '<div class="dashboard-empty">Loading...</div>';
|
|
_urTrapHandler = _installTrap("user-roles-overlay", "user-roles-box");
|
|
// Fetch all roles and user's current roles
|
|
Promise.all([
|
|
authFetch("/v1/api/admin/roles").then(function (r) {
|
|
return r.json();
|
|
}),
|
|
authFetch("/v1/api/admin/users/" + userId + "/roles").then(function (r) {
|
|
return r.json();
|
|
}),
|
|
])
|
|
.then(function (results) {
|
|
var allRoles = results[0].roles || [];
|
|
var userRoles = results[1].roles || [];
|
|
var assigned = {};
|
|
for (var i = 0; i < userRoles.length; i++)
|
|
assigned[userRoles[i].role_id] = true;
|
|
var html = "";
|
|
for (var j = 0; j < allRoles.length; j++) {
|
|
var r = allRoles[j];
|
|
var checked = assigned[r.role_id] ? " checked" : "";
|
|
html +=
|
|
'<label class="perm-checkbox"><input type="checkbox" value="' +
|
|
escapeHtml(r.role_id) +
|
|
'" name="ur-role"' +
|
|
checked +
|
|
"> " +
|
|
escapeHtml(r.display_name) +
|
|
"</label>";
|
|
}
|
|
container.innerHTML = html;
|
|
})
|
|
.catch(function () {
|
|
container.innerHTML =
|
|
'<div class="dashboard-empty">Failed to load roles</div>';
|
|
});
|
|
}
|
|
|
|
function hideUserRolesModal() {
|
|
document.getElementById("user-roles-overlay").style.display = "none";
|
|
_urTrapHandler = _removeTrap(_urTrapHandler);
|
|
if (_urTriggerEl && _urTriggerEl.focus) {
|
|
_urTriggerEl.focus();
|
|
}
|
|
_urTriggerEl = null;
|
|
}
|
|
|
|
function submitUserRoles() {
|
|
var userId = document.getElementById("ur-user-id").value;
|
|
var boxes = document.querySelectorAll('input[name="ur-role"]');
|
|
var selected = [];
|
|
for (var i = 0; i < boxes.length; i++) {
|
|
if (boxes[i].checked) selected.push(boxes[i].value);
|
|
}
|
|
// Get current user roles to diff
|
|
authFetch("/v1/api/admin/users/" + userId + "/roles")
|
|
.then(function (r) {
|
|
return r.json();
|
|
})
|
|
.then(function (data) {
|
|
var current = {};
|
|
var roles = data.roles || [];
|
|
for (var i = 0; i < roles.length; i++) current[roles[i].role_id] = true;
|
|
var promises = [];
|
|
// Assign new
|
|
for (var j = 0; j < selected.length; j++) {
|
|
if (!current[selected[j]]) {
|
|
promises.push(
|
|
authFetch("/v1/api/admin/users/" + userId + "/roles", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ role_id: selected[j] }),
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
// Unassign removed
|
|
var selMap = {};
|
|
for (var k = 0; k < selected.length; k++) selMap[selected[k]] = true;
|
|
for (var rid in current) {
|
|
if (!selMap[rid]) {
|
|
promises.push(
|
|
authFetch("/v1/api/admin/users/" + userId + "/roles/" + rid, {
|
|
method: "DELETE",
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
return Promise.all(promises);
|
|
})
|
|
.then(function () {
|
|
hideUserRolesModal();
|
|
showToast("Roles updated");
|
|
})
|
|
.catch(function () {
|
|
showToast("Failed to update roles");
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tool Policies
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function loadGovPolicies() {
|
|
authFetch("/v1/api/admin/policies")
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error("Failed");
|
|
return r.json();
|
|
})
|
|
.then(function (data) {
|
|
_govPolicies = data.policies || [];
|
|
_renderGovPolicies(_govPolicies);
|
|
})
|
|
.catch(function () {
|
|
document.getElementById("admin-policies-table").innerHTML =
|
|
'<div class="dashboard-empty">Failed to load policies</div>';
|
|
});
|
|
}
|
|
|
|
function _renderGovPolicies(items) {
|
|
var el = document.getElementById("admin-policies-table");
|
|
if (!items.length) {
|
|
el.innerHTML =
|
|
'<div class="dashboard-empty">No tool policies defined</div>';
|
|
return;
|
|
}
|
|
var html = "";
|
|
for (var i = 0; i < items.length; i++) {
|
|
var p = items[i];
|
|
var actionCls = "policy-badge policy-" + p.action;
|
|
var statusDot = p.enabled
|
|
? '<span class="watch-active" title="Enabled">\u25CF active</span>'
|
|
: '<span class="watch-completed" title="Disabled">\u25CB disabled</span>';
|
|
html +=
|
|
'<div class="admin-row" role="listitem">' +
|
|
'<span class="admin-col admin-col-pname">' +
|
|
escapeHtml(p.name) +
|
|
"</span>" +
|
|
'<span class="admin-col admin-col-ppattern"><code>' +
|
|
escapeHtml(p.tool_pattern) +
|
|
"</code></span>" +
|
|
'<span class="admin-col admin-col-paction"><span class="' +
|
|
actionCls +
|
|
'">' +
|
|
escapeHtml(p.action) +
|
|
"</span></span>" +
|
|
'<span class="admin-col admin-col-ppriority">' +
|
|
p.priority +
|
|
"</span>" +
|
|
'<span class="admin-col admin-col-pstatus">' +
|
|
statusDot +
|
|
"</span>" +
|
|
'<span class="admin-col admin-col-actions">' +
|
|
'<button class="admin-btn-action" data-edit-policy="' +
|
|
escapeHtml(p.policy_id) +
|
|
'">edit</button>' +
|
|
'<button class="admin-btn-danger" data-delete-policy="' +
|
|
escapeHtml(p.policy_id) +
|
|
'" data-policy-name="' +
|
|
escapeHtml(p.name) +
|
|
'">delete</button>' +
|
|
"</span></div>";
|
|
}
|
|
el.innerHTML = html;
|
|
el.querySelectorAll("[data-edit-policy]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
showEditPolicyModal(this.getAttribute("data-edit-policy"));
|
|
});
|
|
});
|
|
el.querySelectorAll("[data-delete-policy]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
var pid = this.getAttribute("data-delete-policy");
|
|
var pname = this.getAttribute("data-policy-name");
|
|
showConfirmModal(
|
|
"Delete Policy",
|
|
'Delete policy "' + pname + '"?',
|
|
"Delete",
|
|
function () {
|
|
authFetch("/v1/api/admin/policies/" + pid, { method: "DELETE" })
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error();
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
showToast("Policy deleted");
|
|
loadGovPolicies();
|
|
})
|
|
.catch(function () {
|
|
showToast("Failed to delete policy");
|
|
});
|
|
},
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
function showCreatePolicyModal() {
|
|
_cpTriggerEl = document.activeElement;
|
|
var ov = document.getElementById("create-policy-overlay");
|
|
ov.style.display = "flex";
|
|
document.getElementById("cp-name").value = "";
|
|
document.getElementById("cp-pattern").value = "";
|
|
document.getElementById("cp-action").value = "ask";
|
|
document.getElementById("cp-priority").value = "0";
|
|
document.getElementById("create-policy-error").style.display = "none";
|
|
document.getElementById("cp-name").focus();
|
|
_cpTrapHandler = _installTrap("create-policy-overlay", "create-policy-box");
|
|
}
|
|
|
|
function hideCreatePolicyModal() {
|
|
document.getElementById("create-policy-overlay").style.display = "none";
|
|
_cpTrapHandler = _removeTrap(_cpTrapHandler);
|
|
if (_cpTriggerEl && _cpTriggerEl.focus) {
|
|
_cpTriggerEl.focus();
|
|
}
|
|
_cpTriggerEl = null;
|
|
}
|
|
|
|
function submitCreatePolicy() {
|
|
var name = document.getElementById("cp-name").value.trim();
|
|
var pattern = document.getElementById("cp-pattern").value.trim();
|
|
var action = document.getElementById("cp-action").value;
|
|
var priority =
|
|
parseInt(document.getElementById("cp-priority").value, 10) || 0;
|
|
if (!name || !pattern) {
|
|
var e = document.getElementById("create-policy-error");
|
|
e.textContent = "Name and pattern are required";
|
|
e.style.display = "";
|
|
return;
|
|
}
|
|
document.getElementById("cp-submit").disabled = true;
|
|
authFetch("/v1/api/admin/policies", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
name: name,
|
|
tool_pattern: pattern,
|
|
action: action,
|
|
priority: priority,
|
|
}),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
hideCreatePolicyModal();
|
|
showToast("Policy created");
|
|
loadGovPolicies();
|
|
})
|
|
.catch(function (e) {
|
|
var el = document.getElementById("create-policy-error");
|
|
el.textContent = e.message;
|
|
el.style.display = "";
|
|
})
|
|
.finally(function () {
|
|
document.getElementById("cp-submit").disabled = false;
|
|
});
|
|
}
|
|
|
|
function showEditPolicyModal(policyId) {
|
|
_epTriggerEl = document.activeElement;
|
|
var policy = null;
|
|
for (var i = 0; i < _govPolicies.length; i++) {
|
|
if (_govPolicies[i].policy_id === policyId) {
|
|
policy = _govPolicies[i];
|
|
break;
|
|
}
|
|
}
|
|
if (!policy) return;
|
|
var ov = document.getElementById("edit-policy-overlay");
|
|
ov.style.display = "flex";
|
|
document.getElementById("ep-id").value = policyId;
|
|
document.getElementById("ep-name").value = policy.name;
|
|
document.getElementById("ep-pattern").value = policy.tool_pattern;
|
|
document.getElementById("ep-action").value = policy.action;
|
|
document.getElementById("ep-priority").value = policy.priority;
|
|
document.getElementById("ep-enabled").checked = policy.enabled;
|
|
document.getElementById("edit-policy-error").style.display = "none";
|
|
_epTrapHandler = _installTrap("edit-policy-overlay", "edit-policy-box");
|
|
}
|
|
|
|
function hideEditPolicyModal() {
|
|
document.getElementById("edit-policy-overlay").style.display = "none";
|
|
_epTrapHandler = _removeTrap(_epTrapHandler);
|
|
if (_epTriggerEl && _epTriggerEl.focus) {
|
|
_epTriggerEl.focus();
|
|
}
|
|
_epTriggerEl = null;
|
|
}
|
|
|
|
function submitEditPolicy() {
|
|
var id = document.getElementById("ep-id").value;
|
|
document.getElementById("ep-submit").disabled = true;
|
|
authFetch("/v1/api/admin/policies/" + id, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
name: document.getElementById("ep-name").value.trim(),
|
|
tool_pattern: document.getElementById("ep-pattern").value.trim(),
|
|
action: document.getElementById("ep-action").value,
|
|
priority: parseInt(document.getElementById("ep-priority").value, 10) || 0,
|
|
enabled: document.getElementById("ep-enabled").checked,
|
|
}),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
hideEditPolicyModal();
|
|
showToast("Policy updated");
|
|
loadGovPolicies();
|
|
})
|
|
.catch(function (e) {
|
|
var el = document.getElementById("edit-policy-error");
|
|
el.textContent = e.message;
|
|
el.style.display = "";
|
|
})
|
|
.finally(function () {
|
|
document.getElementById("ep-submit").disabled = false;
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Skills (prompt templates)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function loadGovSkills() {
|
|
authFetch("/v1/api/admin/skills")
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error("Failed");
|
|
return r.json();
|
|
})
|
|
.then(function (data) {
|
|
_govSkills = data.skills || [];
|
|
_renderGovSkills(_govSkills);
|
|
})
|
|
.catch(function () {
|
|
document.getElementById("admin-skills-table").innerHTML =
|
|
'<div class="dashboard-empty">Failed to load skills</div>';
|
|
});
|
|
}
|
|
|
|
function _renderGovSkills(items) {
|
|
var el = document.getElementById("admin-skills-table");
|
|
if (!items.length) {
|
|
el.innerHTML = '<div class="dashboard-empty">No skills configured</div>';
|
|
return;
|
|
}
|
|
var html = "";
|
|
for (var i = 0; i < items.length; i++) {
|
|
var t = items[i];
|
|
var activationBadge = "";
|
|
var activation = t.activation || "named";
|
|
if (activation === "default") {
|
|
activationBadge =
|
|
'<span class="scope-badge scope-approve">default</span>';
|
|
} else if (activation === "search") {
|
|
activationBadge = '<span class="scope-badge">search</span>';
|
|
}
|
|
var defBadge =
|
|
t.is_default && activation !== "default"
|
|
? '<span class="scope-badge scope-approve">default</span>'
|
|
: "";
|
|
var originBadge =
|
|
t.origin === "mcp"
|
|
? ' <span class="scope-badge scope-mcp">mcp:' +
|
|
escapeHtml(t.mcp_server) +
|
|
"</span>"
|
|
: "";
|
|
var catBadge =
|
|
'<span class="scope-badge">' + escapeHtml(t.category) + "</span>";
|
|
// Build risk column content with tooltip
|
|
var riskCell = "";
|
|
if (t.risk_level) {
|
|
var scanClass =
|
|
{
|
|
safe: "scope-scan-safe",
|
|
low: "scope-scan-low",
|
|
medium: "scope-scan-medium",
|
|
high: "scope-scan-high",
|
|
critical: "scope-scan-critical",
|
|
}[t.risk_level] || "";
|
|
var scanIcon =
|
|
{
|
|
safe: "\u2713 ",
|
|
low: "",
|
|
medium: "\u25B2 ",
|
|
high: "\u25C6 ",
|
|
critical: "\u26A0 ",
|
|
}[t.risk_level] || "";
|
|
var tipParts = [];
|
|
try {
|
|
var report = JSON.parse(t.scan_report || "{}");
|
|
if (report.composite != null) {
|
|
tipParts.push("Score: " + report.composite.toFixed(2));
|
|
}
|
|
var axes = ["content", "supply_chain", "vulnerability", "capability"];
|
|
for (var ai = 0; ai < axes.length; ai++) {
|
|
var d = (report.details || {})[axes[ai]] || {};
|
|
if (d.flags && d.flags.length) {
|
|
tipParts.push(
|
|
axes[ai].replace(/_/g, " ") + ": " + d.flags.join(", "),
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {}
|
|
var tipText = tipParts.length ? tipParts.join("\n") : t.risk_level;
|
|
riskCell =
|
|
'<span class="scope-badge ' +
|
|
scanClass +
|
|
'" tabindex="0" role="button" aria-label="Risk: ' +
|
|
escapeHtml(t.risk_level) +
|
|
(tipParts.length ? ". " + escapeHtml(tipParts.join(". ")) : "") +
|
|
'" title="' +
|
|
escapeHtml(tipText) +
|
|
'">' +
|
|
escapeHtml(scanIcon + t.risk_level) +
|
|
"</span>";
|
|
} else {
|
|
riskCell =
|
|
'<span class="scope-badge" style="opacity:0.4" title="Not scanned">\u2014</span>';
|
|
}
|
|
var resBadge = "";
|
|
if (t.resource_count > 0) {
|
|
resBadge =
|
|
' <span class="scope-badge" title="' +
|
|
t.resource_count +
|
|
' bundled resource(s)">' +
|
|
t.resource_count +
|
|
" res</span>";
|
|
}
|
|
var editLabel = t.readonly ? "view" : "edit";
|
|
var deleteDisabled = "";
|
|
html +=
|
|
'<div class="admin-row" role="listitem">' +
|
|
'<span class="admin-col admin-col-tmcat">' +
|
|
catBadge +
|
|
"</span>" +
|
|
'<span class="admin-col admin-col-tmname">' +
|
|
escapeHtml(t.name) +
|
|
" " +
|
|
activationBadge +
|
|
defBadge +
|
|
originBadge +
|
|
resBadge +
|
|
(t.description
|
|
? '<br><span class="admin-col-subtitle">' +
|
|
escapeHtml(t.description) +
|
|
"</span>"
|
|
: "") +
|
|
"</span>" +
|
|
'<span class="admin-col admin-col-tmrisk">' +
|
|
riskCell +
|
|
"</span>" +
|
|
'<span class="admin-col admin-col-actions">' +
|
|
'<button class="admin-btn-action" data-edit-tmpl="' +
|
|
escapeHtml(t.template_id) +
|
|
'">' +
|
|
editLabel +
|
|
"</button>" +
|
|
'<button class="admin-btn-danger" data-delete-tmpl="' +
|
|
escapeHtml(t.template_id) +
|
|
'" data-tmpl-name="' +
|
|
escapeHtml(t.name) +
|
|
'"' +
|
|
deleteDisabled +
|
|
">delete</button>" +
|
|
"</span></div>";
|
|
}
|
|
el.innerHTML = html;
|
|
el.querySelectorAll("[data-edit-tmpl]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
showEditTemplateModal(this.getAttribute("data-edit-tmpl"));
|
|
});
|
|
});
|
|
el.querySelectorAll("[data-delete-tmpl]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
var tid = this.getAttribute("data-delete-tmpl");
|
|
var tname = this.getAttribute("data-tmpl-name");
|
|
showConfirmModal(
|
|
"Delete Skill",
|
|
'Delete skill "' + tname + '"?',
|
|
"Delete",
|
|
function () {
|
|
authFetch("/v1/api/admin/skills/" + tid, { method: "DELETE" })
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error();
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
showToast("Skill deleted");
|
|
loadGovSkills();
|
|
})
|
|
.catch(function () {
|
|
showToast("Failed to delete skill");
|
|
});
|
|
},
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
function _detectTemplateVars(content) {
|
|
var matches = content.match(/\{\{(\w+)\}\}/g) || [];
|
|
var seen = {};
|
|
var result = [];
|
|
for (var i = 0; i < matches.length; i++) {
|
|
var v = matches[i].replace(/[{}]/g, "");
|
|
if (!seen[v]) {
|
|
seen[v] = true;
|
|
result.push(v);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function _updateVarsDisplay(contentId, displayId) {
|
|
var content = document.getElementById(contentId).value || "";
|
|
var vars = _detectTemplateVars(content);
|
|
document.getElementById(displayId).textContent = vars.length
|
|
? vars.join(", ")
|
|
: "(none)";
|
|
}
|
|
|
|
function showCreateTemplateModal() {
|
|
_ctmTriggerEl = document.activeElement;
|
|
var ov = document.getElementById("create-template-overlay");
|
|
ov.style.display = "flex";
|
|
document.getElementById("ctm-name").value = "";
|
|
document.getElementById("ctm-category").value = "general";
|
|
document.getElementById("skill-description").value = "";
|
|
document.getElementById("skill-tags").value = "";
|
|
document.getElementById("skill-author").value = "";
|
|
document.getElementById("skill-version").value = "";
|
|
document.getElementById("skill-license").value = "";
|
|
document.getElementById("skill-compatibility").value = "";
|
|
document.getElementById("skill-activation").value = "named";
|
|
document.getElementById("ctm-content").value = "";
|
|
document.getElementById("ctm-variables").textContent = "(none)";
|
|
document.getElementById("ctm-content").oninput = function () {
|
|
_updateVarsDisplay("ctm-content", "ctm-variables");
|
|
};
|
|
document.getElementById("ctm-default").checked = false;
|
|
// Session config fields
|
|
document.getElementById("csk-model").value = "";
|
|
document.getElementById("csk-temperature").value = "";
|
|
document.getElementById("csk-reasoning-effort").value = "";
|
|
document.getElementById("csk-max-tokens").value = "";
|
|
document.getElementById("csk-token-budget").value = "";
|
|
document.getElementById("csk-agent-max-turns").value = "";
|
|
document.getElementById("csk-auto-approve").checked = false;
|
|
document.getElementById("csk-allowed-tools").value = "";
|
|
document.getElementById("csk-allowed-tools").disabled = false;
|
|
document.getElementById("csk-notify-on-complete").value = "";
|
|
document.getElementById("csk-enabled").checked = true;
|
|
document.getElementById("csk-auto-approve").onchange = function () {
|
|
document.getElementById("csk-allowed-tools").disabled = this.checked;
|
|
};
|
|
document.getElementById("create-template-error").style.display = "none";
|
|
// Clear resource list
|
|
_pendingResources = [];
|
|
_renderPendingResources();
|
|
document.getElementById("ctm-name").focus();
|
|
_ctmTrapHandler = _installTrap(
|
|
"create-template-overlay",
|
|
"create-template-box",
|
|
);
|
|
}
|
|
|
|
function hideCreateTemplateModal() {
|
|
document.getElementById("create-template-overlay").style.display = "none";
|
|
_ctmTrapHandler = _removeTrap(_ctmTrapHandler);
|
|
if (_ctmTriggerEl && _ctmTriggerEl.focus) {
|
|
_ctmTriggerEl.focus();
|
|
}
|
|
_ctmTriggerEl = null;
|
|
}
|
|
|
|
function submitCreateTemplate() {
|
|
var name = document.getElementById("ctm-name").value.trim();
|
|
var content = document.getElementById("ctm-content").value;
|
|
if (!name || !content) {
|
|
var e = document.getElementById("create-template-error");
|
|
e.textContent = "Name and content are required";
|
|
e.style.display = "";
|
|
return;
|
|
}
|
|
var varList = _detectTemplateVars(content);
|
|
var tagsRaw = (document.getElementById("skill-tags").value || "").trim();
|
|
var tagsArray = tagsRaw
|
|
? tagsRaw
|
|
.split(",")
|
|
.map(function (t) {
|
|
return t.trim();
|
|
})
|
|
.filter(Boolean)
|
|
: [];
|
|
// Session config fields
|
|
var csTemp = document.getElementById("csk-temperature").value.trim();
|
|
var csMaxTok = document.getElementById("csk-max-tokens").value.trim();
|
|
var csBudget = document.getElementById("csk-token-budget").value.trim();
|
|
var csMaxTurns = document.getElementById("csk-agent-max-turns").value.trim();
|
|
var csAllowed = (
|
|
document.getElementById("csk-allowed-tools").value || ""
|
|
).trim();
|
|
var csAllowedArr = csAllowed
|
|
? csAllowed
|
|
.split(",")
|
|
.map(function (t) {
|
|
return t.trim();
|
|
})
|
|
.filter(Boolean)
|
|
: [];
|
|
var csNotifyRaw = (
|
|
document.getElementById("csk-notify-on-complete").value || ""
|
|
).trim();
|
|
var csNotifyVal = "[]";
|
|
if (csNotifyRaw) {
|
|
try {
|
|
var csNotifyParsed = JSON.parse(csNotifyRaw);
|
|
if (!Array.isArray(csNotifyParsed))
|
|
throw new Error("must be a JSON array");
|
|
csNotifyVal = JSON.stringify(csNotifyParsed);
|
|
} catch (ne) {
|
|
var ne2 = document.getElementById("create-template-error");
|
|
ne2.textContent = "Notify on completion: " + ne.message;
|
|
ne2.style.display = "";
|
|
return;
|
|
}
|
|
}
|
|
document.getElementById("ctm-submit").disabled = true;
|
|
var csVersion = (document.getElementById("skill-version").value || "").trim();
|
|
var createBody = {
|
|
name: name,
|
|
category: document.getElementById("ctm-category").value,
|
|
description: (
|
|
document.getElementById("skill-description").value || ""
|
|
).trim(),
|
|
tags: JSON.stringify(tagsArray),
|
|
author: (document.getElementById("skill-author").value || "").trim(),
|
|
license: (document.getElementById("skill-license").value || "").trim(),
|
|
compatibility: (
|
|
document.getElementById("skill-compatibility").value || ""
|
|
).trim(),
|
|
activation: document.getElementById("skill-activation").value,
|
|
content: content,
|
|
variables: JSON.stringify(varList),
|
|
is_default: document.getElementById("ctm-default").checked,
|
|
model: document.getElementById("csk-model").value.trim(),
|
|
auto_approve: document.getElementById("csk-auto-approve").checked,
|
|
temperature: csTemp ? parseFloat(csTemp) : null,
|
|
reasoning_effort: document.getElementById("csk-reasoning-effort").value,
|
|
max_tokens: csMaxTok ? parseInt(csMaxTok, 10) : null,
|
|
token_budget: csBudget ? parseInt(csBudget, 10) : 0,
|
|
agent_max_turns: csMaxTurns ? parseInt(csMaxTurns, 10) : null,
|
|
allowed_tools: JSON.stringify(csAllowedArr),
|
|
notify_on_complete: csNotifyVal,
|
|
enabled: document.getElementById("csk-enabled").checked,
|
|
};
|
|
if (csVersion) createBody.version = csVersion;
|
|
authFetch("/v1/api/admin/skills", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(createBody),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function (data) {
|
|
if (_pendingResources.length && data && data.template_id) {
|
|
var promises = _pendingResources.map(function (res) {
|
|
return authFetch(
|
|
"/v1/api/admin/skills/" + data.template_id + "/resources",
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(res),
|
|
},
|
|
).then(function (r) {
|
|
if (!r.ok) throw new Error("Upload failed for " + res.path);
|
|
return r.json();
|
|
});
|
|
});
|
|
Promise.all(promises)
|
|
.then(function () {
|
|
hideCreateTemplateModal();
|
|
showToast(
|
|
"Skill created with " + _pendingResources.length + " resource(s)",
|
|
);
|
|
loadGovSkills();
|
|
})
|
|
.catch(function () {
|
|
hideCreateTemplateModal();
|
|
showToast("Skill created (some resources failed)");
|
|
loadGovSkills();
|
|
});
|
|
} else {
|
|
hideCreateTemplateModal();
|
|
showToast("Skill created");
|
|
loadGovSkills();
|
|
}
|
|
})
|
|
.catch(function (e) {
|
|
var el = document.getElementById("create-template-error");
|
|
el.textContent = e.message;
|
|
el.style.display = "";
|
|
})
|
|
.finally(function () {
|
|
document.getElementById("ctm-submit").disabled = false;
|
|
});
|
|
}
|
|
|
|
function showEditTemplateModal(tmplId) {
|
|
_etmTriggerEl = document.activeElement;
|
|
var tmpl = null;
|
|
for (var i = 0; i < _govSkills.length; i++) {
|
|
if (_govSkills[i].template_id === tmplId) {
|
|
tmpl = _govSkills[i];
|
|
break;
|
|
}
|
|
}
|
|
if (!tmpl) return;
|
|
var ov = document.getElementById("edit-template-overlay");
|
|
ov.style.display = "flex";
|
|
document.getElementById("etm-id").value = tmplId;
|
|
document.getElementById("etm-name").value = tmpl.name;
|
|
document.getElementById("etm-category").value = tmpl.category;
|
|
document.getElementById("etm-description").value = tmpl.description || "";
|
|
// Parse tags from JSON array to comma-separated display
|
|
var tagsDisplay = "";
|
|
try {
|
|
var tagsList = JSON.parse(tmpl.tags || "[]");
|
|
tagsDisplay = tagsList.join(", ");
|
|
} catch (e) {
|
|
tagsDisplay = tmpl.tags || "";
|
|
}
|
|
document.getElementById("etm-tags").value = tagsDisplay;
|
|
document.getElementById("etm-author").value = tmpl.author || "";
|
|
document.getElementById("etm-version").value = tmpl.version || "";
|
|
document.getElementById("etm-license").value = tmpl.license || "";
|
|
document.getElementById("etm-compatibility").value = tmpl.compatibility || "";
|
|
document.getElementById("etm-activation").value = tmpl.activation || "named";
|
|
document.getElementById("etm-content").value = tmpl.content;
|
|
_updateVarsDisplay("etm-content", "etm-variables");
|
|
document.getElementById("etm-content").oninput = function () {
|
|
_updateVarsDisplay("etm-content", "etm-variables");
|
|
};
|
|
document.getElementById("etm-default").checked = tmpl.is_default;
|
|
// Session config fields
|
|
document.getElementById("esk-model").value = tmpl.model || "";
|
|
document.getElementById("esk-temperature").value =
|
|
tmpl.temperature != null ? tmpl.temperature : "";
|
|
document.getElementById("esk-reasoning-effort").value =
|
|
tmpl.reasoning_effort || "";
|
|
document.getElementById("esk-max-tokens").value =
|
|
tmpl.max_tokens != null ? tmpl.max_tokens : "";
|
|
document.getElementById("esk-token-budget").value = tmpl.token_budget
|
|
? tmpl.token_budget
|
|
: "";
|
|
document.getElementById("esk-agent-max-turns").value =
|
|
tmpl.agent_max_turns != null ? tmpl.agent_max_turns : "";
|
|
document.getElementById("esk-auto-approve").checked =
|
|
tmpl.auto_approve || false;
|
|
// allowed_tools: parse JSON array to comma-separated display
|
|
var allowedDisplay = "";
|
|
try {
|
|
var allowed = JSON.parse(tmpl.allowed_tools || "[]");
|
|
allowedDisplay = allowed.join(", ");
|
|
} catch (e) {
|
|
allowedDisplay = tmpl.allowed_tools || "";
|
|
}
|
|
document.getElementById("esk-allowed-tools").value = allowedDisplay;
|
|
document.getElementById("esk-allowed-tools").disabled =
|
|
tmpl.auto_approve || false;
|
|
document.getElementById("esk-enabled").checked = tmpl.enabled !== false;
|
|
var notifyVal = tmpl.notify_on_complete || "[]";
|
|
document.getElementById("esk-notify-on-complete").value =
|
|
notifyVal && notifyVal !== "[]" ? notifyVal : "";
|
|
document.getElementById("esk-auto-approve").onchange = function () {
|
|
document.getElementById("esk-allowed-tools").disabled = this.checked;
|
|
};
|
|
document.getElementById("edit-template-error").style.display = "none";
|
|
// Scan report section
|
|
var scanSection = document.getElementById("etm-scan-section");
|
|
if (scanSection) {
|
|
if (tmpl.risk_level) {
|
|
scanSection.style.display = "";
|
|
var scanClassMap = {
|
|
safe: "scope-scan-safe",
|
|
low: "scope-scan-low",
|
|
medium: "scope-scan-medium",
|
|
high: "scope-scan-high",
|
|
critical: "scope-scan-critical",
|
|
};
|
|
var report = {};
|
|
try {
|
|
report = JSON.parse(tmpl.scan_report || "{}");
|
|
} catch (e) {}
|
|
var scanHtml =
|
|
'<span class="scope-badge ' +
|
|
(scanClassMap[tmpl.risk_level] || "") +
|
|
'">' +
|
|
escapeHtml(tmpl.risk_level) +
|
|
"</span>";
|
|
if (report.composite != null) {
|
|
scanHtml +=
|
|
' <span class="scan-composite">Score: ' +
|
|
report.composite.toFixed(2) +
|
|
"</span>";
|
|
}
|
|
if (tmpl.scan_version) {
|
|
scanHtml +=
|
|
' <span class="scan-version">v' +
|
|
escapeHtml(tmpl.scan_version) +
|
|
"</span>";
|
|
}
|
|
var axes = ["content", "supply_chain", "vulnerability", "capability"];
|
|
for (var ai = 0; ai < axes.length; ai++) {
|
|
var axis = axes[ai];
|
|
var d = (report.details || {})[axis] || {};
|
|
scanHtml +=
|
|
'<div class="scan-axis"><span class="scan-axis-name">' +
|
|
escapeHtml(axis.replace(/_/g, " ")) +
|
|
'</span> <span class="scan-axis-score">' +
|
|
(d.score != null ? d.score.toFixed(1) : "0.0") +
|
|
"/4.0</span>";
|
|
if (d.flags && d.flags.length) {
|
|
scanHtml +=
|
|
' <span class="scan-axis-flags">' +
|
|
d.flags.map(escapeHtml).join(", ") +
|
|
"</span>";
|
|
}
|
|
scanHtml += "</div>";
|
|
}
|
|
document.getElementById("etm-scan-report").innerHTML = scanHtml;
|
|
} else {
|
|
scanSection.style.display = "none";
|
|
}
|
|
}
|
|
var rescanBtn = document.getElementById("etm-rescan-btn");
|
|
if (rescanBtn) {
|
|
rescanBtn.onclick = function () {
|
|
rescanBtn.disabled = true;
|
|
rescanBtn.textContent = "Scanning...";
|
|
authFetch("/v1/api/admin/skills/" + tmplId + "/rescan", {
|
|
method: "POST",
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error("Failed");
|
|
return r.json();
|
|
})
|
|
.then(function (data) {
|
|
showToast("Scan complete: " + (data.risk_level || "unknown"));
|
|
// Refresh the modal by re-loading skills and re-opening
|
|
loadGovSkills();
|
|
// Update current tmpl in memory
|
|
tmpl.risk_level = data.risk_level;
|
|
tmpl.scan_report = data.scan_report;
|
|
tmpl.scan_version = data.scan_version;
|
|
showEditTemplateModal(tmplId);
|
|
})
|
|
.catch(function () {
|
|
showToast("Re-scan failed");
|
|
})
|
|
.finally(function () {
|
|
rescanBtn.disabled = false;
|
|
rescanBtn.textContent = "Re-scan";
|
|
});
|
|
};
|
|
}
|
|
// Reset collapsible state before applying readonly rules (prevents state leak
|
|
// when switching between readonly and editable skills in the same session)
|
|
var allDetails = document.querySelectorAll(
|
|
"#edit-template-box .admin-details",
|
|
);
|
|
for (var d = 0; d < allDetails.length; d++) allDetails[d].open = false;
|
|
|
|
// --- Readonly mode for imported skills ---
|
|
var isReadonly = tmpl.readonly || false;
|
|
var editTitle = document.getElementById("edit-template-title");
|
|
if (editTitle)
|
|
editTitle.textContent = isReadonly ? "View Skill" : "Edit Skill";
|
|
// Origin badge — show provenance for installed skills
|
|
var originBadge = document.getElementById("etm-origin-badge");
|
|
if (originBadge) {
|
|
if (isReadonly && tmpl.source_url) {
|
|
originBadge.textContent = "Installed from \u00a0" + tmpl.source_url;
|
|
originBadge.style.display = "inline-flex";
|
|
} else if (isReadonly && tmpl.origin && tmpl.origin !== "manual") {
|
|
originBadge.textContent = "Installed skill";
|
|
originBadge.style.display = "inline-flex";
|
|
} else {
|
|
originBadge.style.display = "none";
|
|
}
|
|
}
|
|
var submitBtn = document.getElementById("etm-submit");
|
|
if (submitBtn) {
|
|
submitBtn.style.display = "";
|
|
submitBtn.textContent = isReadonly ? "Save Config" : "Save";
|
|
}
|
|
// Spec/content fields: locked for installed skills (preserve source fidelity)
|
|
[
|
|
"etm-name",
|
|
"etm-category",
|
|
"etm-description",
|
|
"etm-tags",
|
|
"etm-author",
|
|
"etm-version",
|
|
"etm-license",
|
|
"etm-compatibility",
|
|
"etm-activation",
|
|
"etm-content",
|
|
"etm-default",
|
|
].forEach(function (id) {
|
|
var el = document.getElementById(id);
|
|
if (el) el.disabled = isReadonly;
|
|
});
|
|
// Runtime config fields: always editable (local settings, not part of SKILL.md spec)
|
|
[
|
|
"esk-model",
|
|
"esk-temperature",
|
|
"esk-reasoning-effort",
|
|
"esk-max-tokens",
|
|
"esk-token-budget",
|
|
"esk-agent-max-turns",
|
|
"esk-auto-approve",
|
|
"esk-enabled",
|
|
].forEach(function (id) {
|
|
var el = document.getElementById(id);
|
|
if (el) el.disabled = false;
|
|
});
|
|
// esk-allowed-tools follows auto_approve state, not readonly state
|
|
var allowedToolsEl = document.getElementById("esk-allowed-tools");
|
|
if (allowedToolsEl) allowedToolsEl.disabled = tmpl.auto_approve || false;
|
|
var cancelBtn = document.querySelector("#edit-template-box .modal-cancel");
|
|
if (cancelBtn) cancelBtn.textContent = isReadonly ? "Close" : "Cancel";
|
|
// Auto-expand Runtime Config collapsible for installed skills so config is visible
|
|
if (isReadonly) {
|
|
var details = document.querySelectorAll(
|
|
"#edit-template-box .admin-details",
|
|
);
|
|
for (var d = 0; d < details.length; d++) details[d].open = true;
|
|
}
|
|
// --- Skill Resources ---
|
|
var resSection = document.getElementById("etm-resources-section");
|
|
if (resSection) {
|
|
_loadSkillResources(tmplId, isReadonly);
|
|
}
|
|
_etmTrapHandler = _installTrap("edit-template-overlay", "edit-template-box");
|
|
// Focus management
|
|
if (isReadonly) {
|
|
if (cancelBtn) cancelBtn.focus();
|
|
} else {
|
|
document.getElementById("etm-name").focus();
|
|
}
|
|
}
|
|
|
|
function hideEditTemplateModal() {
|
|
document.getElementById("edit-template-overlay").style.display = "none";
|
|
_etmTrapHandler = _removeTrap(_etmTrapHandler);
|
|
if (_etmTriggerEl && _etmTriggerEl.focus) {
|
|
_etmTriggerEl.focus();
|
|
}
|
|
_etmTriggerEl = null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Skill Resources
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function _loadSkillResources(skillId, readonly) {
|
|
var container = document.getElementById("etm-resources-list");
|
|
var addBtn = document.getElementById("etm-add-resource-btn");
|
|
var addForm = document.getElementById("etm-add-resource-form");
|
|
if (!container) return;
|
|
container.innerHTML = '<div class="dashboard-empty">Loading...</div>';
|
|
if (addBtn) addBtn.style.display = readonly ? "none" : "";
|
|
if (addForm) addForm.style.display = "none";
|
|
|
|
authFetch("/v1/api/admin/skills/" + skillId + "/resources")
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error("Failed");
|
|
return r.json();
|
|
})
|
|
.then(function (data) {
|
|
var resources = data.resources || [];
|
|
if (!resources.length) {
|
|
container.innerHTML =
|
|
'<div class="dashboard-empty">No resource files</div>';
|
|
return;
|
|
}
|
|
var html = "";
|
|
for (var i = 0; i < resources.length; i++) {
|
|
var res = resources[i];
|
|
var sizeStr =
|
|
res.size > 1024
|
|
? (res.size / 1024).toFixed(1) + " KB"
|
|
: res.size + " B";
|
|
html +=
|
|
'<div role="listitem" style="display:flex;align-items:center;padding:4px 0;gap:8px">' +
|
|
'<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><code>' +
|
|
escapeHtml(res.path) +
|
|
"</code></span>" +
|
|
'<span style="width:80px;text-align:right;opacity:0.6">' +
|
|
sizeStr +
|
|
"</span>" +
|
|
'<span style="width:60px;text-align:right">' +
|
|
(readonly
|
|
? ""
|
|
: '<button class="admin-btn-danger" data-del-res="' +
|
|
escapeHtml(res.path) +
|
|
'" style="font-size:0.85em" aria-label="Delete resource ' +
|
|
escapeHtml(res.path) +
|
|
'">delete</button>') +
|
|
"</span></div>";
|
|
}
|
|
container.innerHTML = html;
|
|
if (!readonly) {
|
|
container.querySelectorAll("[data-del-res]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
var path = this.getAttribute("data-del-res");
|
|
showConfirmModal(
|
|
"Delete Resource",
|
|
'Delete "' + path + '"?',
|
|
"Delete",
|
|
function () {
|
|
authFetch(
|
|
"/v1/api/admin/skills/" +
|
|
skillId +
|
|
"/resources/" +
|
|
path.split("/").map(encodeURIComponent).join("/"),
|
|
{ method: "DELETE" },
|
|
)
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error();
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
showToast("Resource deleted");
|
|
_loadSkillResources(skillId, readonly);
|
|
loadGovSkills();
|
|
var addBtn = document.getElementById(
|
|
"etm-add-resource-btn",
|
|
);
|
|
if (addBtn) addBtn.focus();
|
|
})
|
|
.catch(function () {
|
|
showToast("Failed to delete resource");
|
|
});
|
|
},
|
|
);
|
|
});
|
|
});
|
|
}
|
|
})
|
|
.catch(function () {
|
|
container.innerHTML =
|
|
'<div class="dashboard-empty">Failed to load resources</div>';
|
|
});
|
|
}
|
|
|
|
function _showAddResourceForm(skillId) {
|
|
var form = document.getElementById("etm-add-resource-form");
|
|
if (!form) return;
|
|
form.style.display = "";
|
|
document.getElementById("etm-res-path").value = "";
|
|
document.getElementById("etm-res-content").value = "";
|
|
document.getElementById("etm-res-content-type").value = "text/plain";
|
|
document.getElementById("etm-res-submit").onclick = function () {
|
|
var path = (document.getElementById("etm-res-path").value || "").trim();
|
|
var content = document.getElementById("etm-res-content").value || "";
|
|
var contentType = document.getElementById("etm-res-content-type").value;
|
|
if (!path || !content) {
|
|
showToast("Path and content are required");
|
|
return;
|
|
}
|
|
if (
|
|
!path.startsWith("scripts/") &&
|
|
!path.startsWith("references/") &&
|
|
!path.startsWith("assets/")
|
|
) {
|
|
showToast("Path must start with scripts/, references/, or assets/");
|
|
return;
|
|
}
|
|
this.disabled = true;
|
|
this.textContent = "Uploading\u2026";
|
|
authFetch("/v1/api/admin/skills/" + skillId + "/resources", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
path: path,
|
|
content: content,
|
|
content_type: contentType,
|
|
}),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
showToast("Resource added");
|
|
form.style.display = "none";
|
|
_loadSkillResources(skillId, false);
|
|
loadGovSkills();
|
|
})
|
|
.catch(function (e) {
|
|
showToast(e.message || "Failed to add resource");
|
|
})
|
|
.finally(function () {
|
|
var btn = document.getElementById("etm-res-submit");
|
|
if (btn) {
|
|
btn.disabled = false;
|
|
btn.textContent = "Upload";
|
|
}
|
|
});
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Pending resources (create modal)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function _renderPendingResources() {
|
|
var container = document.getElementById("ctm-resources-list");
|
|
if (!container) return;
|
|
if (!_pendingResources.length) {
|
|
container.innerHTML =
|
|
'<div class="dashboard-empty">No resource files yet</div>';
|
|
return;
|
|
}
|
|
var html = "";
|
|
for (var i = 0; i < _pendingResources.length; i++) {
|
|
var r = _pendingResources[i];
|
|
var sizeStr =
|
|
r.content.length > 1024
|
|
? (r.content.length / 1024).toFixed(1) + " KB"
|
|
: r.content.length + " B";
|
|
html +=
|
|
'<div role="listitem" style="display:flex;align-items:center;padding:4px 0;gap:8px">' +
|
|
'<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><code>' +
|
|
escapeHtml(r.path) +
|
|
"</code></span>" +
|
|
'<span style="width:80px;text-align:right;opacity:0.6">' +
|
|
sizeStr +
|
|
"</span>" +
|
|
'<span style="width:60px;text-align:right">' +
|
|
'<button class="admin-btn-danger" data-remove-res="' +
|
|
i +
|
|
'" style="font-size:0.85em" aria-label="Remove resource ' +
|
|
escapeHtml(r.path) +
|
|
'">remove</button>' +
|
|
"</span></div>";
|
|
}
|
|
container.innerHTML = html;
|
|
container.querySelectorAll("[data-remove-res]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
var idx = parseInt(this.getAttribute("data-remove-res"), 10);
|
|
_pendingResources.splice(idx, 1);
|
|
_renderPendingResources();
|
|
});
|
|
});
|
|
}
|
|
|
|
function _addPendingResource() {
|
|
var path = (document.getElementById("ctm-res-path").value || "").trim();
|
|
var content = document.getElementById("ctm-res-content").value || "";
|
|
var contentType = document.getElementById("ctm-res-content-type").value;
|
|
if (!path || !content) {
|
|
showToast("Path and content are required");
|
|
return;
|
|
}
|
|
if (
|
|
!path.startsWith("scripts/") &&
|
|
!path.startsWith("references/") &&
|
|
!path.startsWith("assets/")
|
|
) {
|
|
showToast("Path must start with scripts/, references/, or assets/");
|
|
return;
|
|
}
|
|
if (
|
|
_pendingResources.some(function (r) {
|
|
return r.path === path;
|
|
})
|
|
) {
|
|
showToast("Resource path already added");
|
|
return;
|
|
}
|
|
if (_pendingResources.length >= 10) {
|
|
showToast("Maximum 10 resources per skill");
|
|
return;
|
|
}
|
|
_pendingResources.push({
|
|
path: path,
|
|
content: content,
|
|
content_type: contentType,
|
|
});
|
|
document.getElementById("ctm-res-path").value = "";
|
|
document.getElementById("ctm-res-content").value = "";
|
|
_renderPendingResources();
|
|
document.getElementById("ctm-res-path").focus();
|
|
}
|
|
|
|
function submitEditTemplate() {
|
|
var id = document.getElementById("etm-id").value;
|
|
var content = document.getElementById("etm-content").value;
|
|
var varList = _detectTemplateVars(content);
|
|
var tagsRaw = (document.getElementById("etm-tags").value || "").trim();
|
|
var tagsArray = tagsRaw
|
|
? tagsRaw
|
|
.split(",")
|
|
.map(function (t) {
|
|
return t.trim();
|
|
})
|
|
.filter(Boolean)
|
|
: [];
|
|
// Session config fields
|
|
var esTemp = document.getElementById("esk-temperature").value.trim();
|
|
var esMaxTok = document.getElementById("esk-max-tokens").value.trim();
|
|
var esBudget = document.getElementById("esk-token-budget").value.trim();
|
|
var esMaxTurns = document.getElementById("esk-agent-max-turns").value.trim();
|
|
var esAllowed = (
|
|
document.getElementById("esk-allowed-tools").value || ""
|
|
).trim();
|
|
var esAllowedArr = esAllowed
|
|
? esAllowed
|
|
.split(",")
|
|
.map(function (t) {
|
|
return t.trim();
|
|
})
|
|
.filter(Boolean)
|
|
: [];
|
|
var esNotifyRaw = (
|
|
document.getElementById("esk-notify-on-complete").value || ""
|
|
).trim();
|
|
var esNotifyVal = "[]";
|
|
if (esNotifyRaw) {
|
|
try {
|
|
var esNotifyParsed = JSON.parse(esNotifyRaw);
|
|
if (!Array.isArray(esNotifyParsed))
|
|
throw new Error("must be a JSON array");
|
|
esNotifyVal = JSON.stringify(esNotifyParsed);
|
|
} catch (ne) {
|
|
var ne3 = document.getElementById("edit-template-error");
|
|
ne3.textContent = "Notify on completion: " + ne.message;
|
|
ne3.style.display = "";
|
|
return;
|
|
}
|
|
}
|
|
document.getElementById("etm-submit").disabled = true;
|
|
var esVersion = (document.getElementById("etm-version").value || "").trim();
|
|
var updateBody = {
|
|
name: document.getElementById("etm-name").value.trim(),
|
|
category: document.getElementById("etm-category").value,
|
|
description: (
|
|
document.getElementById("etm-description").value || ""
|
|
).trim(),
|
|
tags: JSON.stringify(tagsArray),
|
|
author: (document.getElementById("etm-author").value || "").trim(),
|
|
license: (document.getElementById("etm-license").value || "").trim(),
|
|
compatibility: (
|
|
document.getElementById("etm-compatibility").value || ""
|
|
).trim(),
|
|
activation: document.getElementById("etm-activation").value,
|
|
content: content,
|
|
variables: JSON.stringify(varList),
|
|
is_default: document.getElementById("etm-default").checked,
|
|
model: document.getElementById("esk-model").value.trim(),
|
|
auto_approve: document.getElementById("esk-auto-approve").checked,
|
|
temperature: esTemp ? parseFloat(esTemp) : null,
|
|
reasoning_effort: document.getElementById("esk-reasoning-effort").value,
|
|
max_tokens: esMaxTok ? parseInt(esMaxTok, 10) : null,
|
|
token_budget: esBudget ? parseInt(esBudget, 10) : 0,
|
|
agent_max_turns: esMaxTurns ? parseInt(esMaxTurns, 10) : null,
|
|
allowed_tools: JSON.stringify(esAllowedArr),
|
|
notify_on_complete: esNotifyVal,
|
|
enabled: document.getElementById("esk-enabled").checked,
|
|
};
|
|
if (esVersion) updateBody.version = esVersion;
|
|
authFetch("/v1/api/admin/skills/" + id, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(updateBody),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
hideEditTemplateModal();
|
|
showToast("Skill updated");
|
|
loadGovSkills();
|
|
})
|
|
.catch(function (e) {
|
|
var el = document.getElementById("edit-template-error");
|
|
el.textContent = e.message;
|
|
el.style.display = "";
|
|
})
|
|
.finally(function () {
|
|
document.getElementById("etm-submit").disabled = false;
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Usage
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function loadGovUsage() {
|
|
var now = new Date();
|
|
var since;
|
|
if (_govUsageRange === "24h") since = new Date(now - 24 * 60 * 60 * 1000);
|
|
else if (_govUsageRange === "30d")
|
|
since = new Date(now - 30 * 24 * 60 * 60 * 1000);
|
|
else since = new Date(now - 7 * 24 * 60 * 60 * 1000);
|
|
var sinceStr = since.toISOString().slice(0, 19);
|
|
|
|
// Fetch summary + breakdown in parallel
|
|
var summaryUrl = "/v1/api/admin/usage?since=" + encodeURIComponent(sinceStr);
|
|
var breakdownUrl = summaryUrl + "&group_by=" + _govUsageGroupBy;
|
|
|
|
Promise.all([
|
|
authFetch(summaryUrl).then(function (r) {
|
|
return r.json();
|
|
}),
|
|
authFetch(breakdownUrl).then(function (r) {
|
|
return r.json();
|
|
}),
|
|
])
|
|
.then(function (results) {
|
|
_renderGovUsage(results[0], results[1]);
|
|
})
|
|
.catch(function () {
|
|
document.getElementById("admin-usage-content").innerHTML =
|
|
'<div class="dashboard-empty">Failed to load usage data</div>';
|
|
});
|
|
}
|
|
|
|
function _renderGovUsage(summary, breakdown) {
|
|
var container = document.getElementById("admin-usage-content");
|
|
var s = (summary.breakdown && summary.breakdown[0]) || {};
|
|
var prompt = s.prompt_tokens || 0;
|
|
var completion = s.completion_tokens || 0;
|
|
var total = prompt + completion;
|
|
var tools = s.tool_calls_count || 0;
|
|
var cacheWrite = s.cache_creation_tokens || 0;
|
|
var cacheRead = s.cache_read_tokens || 0;
|
|
|
|
var cacheZero = cacheWrite === 0 && cacheRead === 0;
|
|
var cacheCls =
|
|
"usage-readout usage-readout-secondary" +
|
|
(cacheZero ? " usage-readout-zero" : "");
|
|
|
|
var html =
|
|
'<div class="usage-summary">' +
|
|
'<div class="usage-readout"><span class="usage-readout-value">' +
|
|
formatTokens(total) +
|
|
'</span><span class="usage-readout-label">total tokens</span></div>' +
|
|
'<div class="usage-readout"><span class="usage-readout-value">' +
|
|
formatTokens(prompt) +
|
|
'</span><span class="usage-readout-label">prompt</span></div>' +
|
|
'<div class="usage-readout"><span class="usage-readout-value">' +
|
|
formatTokens(completion) +
|
|
'</span><span class="usage-readout-label">completion</span></div>' +
|
|
'<div class="usage-readout"><span class="usage-readout-value">' +
|
|
formatCount(tools) +
|
|
'</span><span class="usage-readout-label">tool calls</span></div>' +
|
|
'<div class="usage-summary-divider"></div>' +
|
|
'<div class="' +
|
|
cacheCls +
|
|
'"><span class="usage-readout-value">' +
|
|
formatTokens(cacheWrite) +
|
|
'</span><span class="usage-readout-label">cache write</span></div>' +
|
|
'<div class="' +
|
|
cacheCls +
|
|
'"><span class="usage-readout-value">' +
|
|
formatTokens(cacheRead) +
|
|
'</span><span class="usage-readout-label">cache read</span></div>' +
|
|
"</div>";
|
|
|
|
// Bar chart breakdown
|
|
var items = breakdown.breakdown || [];
|
|
if (items.length) {
|
|
var maxVal = 0;
|
|
for (var i = 0; i < items.length; i++) {
|
|
var v = (items[i].prompt_tokens || 0) + (items[i].completion_tokens || 0);
|
|
if (v > maxVal) maxVal = v;
|
|
}
|
|
html += '<div class="usage-chart">';
|
|
for (var j = 0; j < items.length; j++) {
|
|
var item = items[j];
|
|
var val = (item.prompt_tokens || 0) + (item.completion_tokens || 0);
|
|
var pct = maxVal > 0 ? Math.round((val / maxVal) * 100) : 0;
|
|
var label = item.key || "\u2014";
|
|
html +=
|
|
'<div class="usage-bar-row">' +
|
|
'<span class="usage-bar-label">' +
|
|
escapeHtml(label) +
|
|
"</span>" +
|
|
'<div class="usage-bar-track"><div class="usage-bar-fill" style="width:' +
|
|
pct +
|
|
'%"></div></div>' +
|
|
'<span class="usage-bar-value">' +
|
|
formatTokens(val) +
|
|
"</span>" +
|
|
"</div>";
|
|
}
|
|
html += "</div>";
|
|
} else {
|
|
html += '<div class="dashboard-empty">No usage data for this period</div>';
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function setUsageRange(range) {
|
|
_govUsageRange = range;
|
|
// Update button states
|
|
var btns = document.querySelectorAll(".usage-range-btn");
|
|
for (var i = 0; i < btns.length; i++) {
|
|
btns[i].classList.toggle(
|
|
"active",
|
|
btns[i].getAttribute("data-range") === range,
|
|
);
|
|
btns[i].setAttribute(
|
|
"aria-pressed",
|
|
btns[i].classList.contains("active") ? "true" : "false",
|
|
);
|
|
}
|
|
loadGovUsage();
|
|
}
|
|
|
|
function setUsageGroupBy(groupBy) {
|
|
_govUsageGroupBy = groupBy;
|
|
var btns = document.querySelectorAll(".usage-group-btn");
|
|
for (var i = 0; i < btns.length; i++) {
|
|
btns[i].classList.toggle(
|
|
"active",
|
|
btns[i].getAttribute("data-group") === groupBy,
|
|
);
|
|
btns[i].setAttribute(
|
|
"aria-pressed",
|
|
btns[i].classList.contains("active") ? "true" : "false",
|
|
);
|
|
}
|
|
loadGovUsage();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Audit
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function loadGovAudit(append) {
|
|
if (!append) {
|
|
_govAuditOffset = 0;
|
|
_govAuditEvents = [];
|
|
}
|
|
var url = "/v1/api/admin/audit?limit=50&offset=" + _govAuditOffset;
|
|
var actionFilter = document.getElementById("audit-action-filter");
|
|
var userFilter = document.getElementById("audit-user-filter");
|
|
if (actionFilter && actionFilter.value)
|
|
url += "&action=" + encodeURIComponent(actionFilter.value);
|
|
if (userFilter && userFilter.value)
|
|
url += "&user_id=" + encodeURIComponent(userFilter.value);
|
|
|
|
authFetch(url)
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error("Failed");
|
|
return r.json();
|
|
})
|
|
.then(function (data) {
|
|
_govAuditTotal = data.total || 0;
|
|
var events = data.events || [];
|
|
_govAuditEvents = _govAuditEvents.concat(events);
|
|
_renderGovAudit(_govAuditEvents, _govAuditTotal);
|
|
})
|
|
.catch(function () {
|
|
document.getElementById("admin-audit-table").innerHTML =
|
|
'<div class="dashboard-empty">Failed to load audit events</div>';
|
|
});
|
|
}
|
|
|
|
function _relativeTime(isoStr) {
|
|
var now = Date.now();
|
|
var then = new Date(isoStr + "Z").getTime();
|
|
var diff = Math.max(0, Math.floor((now - then) / 1000));
|
|
if (diff < 60) return diff + "s ago";
|
|
if (diff < 3600) return Math.floor(diff / 60) + "m ago";
|
|
if (diff < 86400) return Math.floor(diff / 3600) + "h ago";
|
|
return Math.floor(diff / 86400) + "d ago";
|
|
}
|
|
|
|
function _renderGovAudit(events, total) {
|
|
var el = document.getElementById("admin-audit-table");
|
|
if (!events.length) {
|
|
el.innerHTML = '<div class="dashboard-empty">No audit events</div>';
|
|
return;
|
|
}
|
|
var html = "";
|
|
for (var i = 0; i < events.length; i++) {
|
|
var ev = events[i];
|
|
var detail = "";
|
|
try {
|
|
var d = JSON.parse(ev.detail || "{}");
|
|
var keys = Object.keys(d);
|
|
if (keys.length) {
|
|
var parts = [];
|
|
for (var k = 0; k < Math.min(keys.length, 3); k++) {
|
|
parts.push(keys[k] + "=" + String(d[keys[k]]).slice(0, 30));
|
|
}
|
|
detail = parts.join(", ");
|
|
}
|
|
} catch (e) {
|
|
detail = ev.detail;
|
|
}
|
|
|
|
var actionCls = "audit-badge";
|
|
if (ev.action.indexOf("delete") >= 0 || ev.action.indexOf("revoke") >= 0)
|
|
actionCls += " audit-danger";
|
|
else if (
|
|
ev.action.indexOf("create") >= 0 ||
|
|
ev.action.indexOf("assign") >= 0
|
|
)
|
|
actionCls += " audit-success";
|
|
|
|
html +=
|
|
'<div class="admin-row" role="listitem">' +
|
|
'<span class="admin-col admin-col-atime" title="' +
|
|
escapeHtml(ev.timestamp) +
|
|
'">' +
|
|
_relativeTime(ev.timestamp) +
|
|
"</span>" +
|
|
'<span class="admin-col admin-col-auser">' +
|
|
escapeHtml(
|
|
ev.username || (ev.user_id ? ev.user_id.slice(0, 8) : "\u2014"),
|
|
) +
|
|
"</span>" +
|
|
'<span class="admin-col admin-col-aaction"><span class="' +
|
|
actionCls +
|
|
'">' +
|
|
escapeHtml(ev.action) +
|
|
"</span></span>" +
|
|
'<span class="admin-col admin-col-aresource">' +
|
|
escapeHtml(
|
|
ev.resource_type
|
|
? ev.resource_type + "/" + (ev.resource_id || "").slice(0, 8)
|
|
: "\u2014",
|
|
) +
|
|
"</span>" +
|
|
'<span class="admin-col admin-col-adetail" title="' +
|
|
escapeHtml(ev.detail) +
|
|
'">' +
|
|
escapeHtml(detail || "\u2014") +
|
|
"</span>" +
|
|
"</div>";
|
|
}
|
|
// Pagination
|
|
if (events.length < total) {
|
|
html +=
|
|
'<div class="pagination"><button class="audit-load-more" onclick="loadMoreAudit()">Load more (' +
|
|
events.length +
|
|
" of " +
|
|
total +
|
|
")</button></div>";
|
|
}
|
|
el.innerHTML = html;
|
|
}
|
|
|
|
function loadMoreAudit() {
|
|
_govAuditOffset = _govAuditEvents.length;
|
|
loadGovAudit(true);
|
|
}
|
|
|
|
// Populate audit user filter from admin users list
|
|
function _populateAuditUserFilter() {
|
|
var sel = document.getElementById("audit-user-filter");
|
|
if (!sel) return;
|
|
var html = '<option value="">All users</option>';
|
|
for (var i = 0; i < _adminUsers.length; i++) {
|
|
html +=
|
|
'<option value="' +
|
|
escapeHtml(_adminUsers[i].user_id) +
|
|
'">' +
|
|
escapeHtml(_adminUsers[i].username) +
|
|
"</option>";
|
|
}
|
|
sel.innerHTML = html;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Memories tab
|
|
// ---------------------------------------------------------------------------
|
|
|
|
var _adminMemories = [];
|
|
var _memDetailTrap = null;
|
|
var _memDetailTrigger = null;
|
|
var _memSearchTimer = null;
|
|
var _memSearchBound = false;
|
|
|
|
function loadAdminMemories() {
|
|
clearTimeout(_memSearchTimer);
|
|
// Bind search debounce on first load
|
|
if (!_memSearchBound) {
|
|
var searchEl = document.getElementById("mem-search");
|
|
if (searchEl) {
|
|
searchEl.addEventListener("input", function () {
|
|
clearTimeout(_memSearchTimer);
|
|
_memSearchTimer = setTimeout(loadAdminMemories, 300);
|
|
});
|
|
}
|
|
_memSearchBound = true;
|
|
}
|
|
|
|
var memType = document.getElementById("mem-filter-type").value;
|
|
var scope = document.getElementById("mem-filter-scope").value;
|
|
var query = (document.getElementById("mem-search").value || "").trim();
|
|
|
|
var url;
|
|
if (query) {
|
|
url =
|
|
"/v1/api/admin/memories/search?q=" +
|
|
encodeURIComponent(query) +
|
|
(memType ? "&type=" + encodeURIComponent(memType) : "") +
|
|
(scope ? "&scope=" + encodeURIComponent(scope) : "");
|
|
} else {
|
|
url =
|
|
"/v1/api/admin/memories?limit=200" +
|
|
(memType ? "&type=" + encodeURIComponent(memType) : "") +
|
|
(scope ? "&scope=" + encodeURIComponent(scope) : "");
|
|
}
|
|
|
|
authFetch(url)
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error("Failed to load memories");
|
|
return r.json();
|
|
})
|
|
.then(function (data) {
|
|
_adminMemories = data.memories || [];
|
|
_renderAdminMemories(_adminMemories, data.total || _adminMemories.length);
|
|
})
|
|
.catch(function () {
|
|
document.getElementById("admin-memories-table").innerHTML =
|
|
'<div class="dashboard-empty">Failed to load memories</div>';
|
|
});
|
|
}
|
|
|
|
function _renderAdminMemories(items, total) {
|
|
var el = document.getElementById("admin-memories-table");
|
|
if (!items.length) {
|
|
el.innerHTML = '<div class="dashboard-empty">No memories found</div>';
|
|
return;
|
|
}
|
|
|
|
var html = "";
|
|
for (var i = 0; i < items.length; i++) {
|
|
var m = items[i];
|
|
|
|
// Type badge
|
|
var typeCls = "scope-badge mem-type-" + escapeHtml(m.type);
|
|
var typeBadge =
|
|
'<span class="' + typeCls + '">' + escapeHtml(m.type) + "</span>";
|
|
|
|
// Scope badge
|
|
var scopeLabel = m.scope;
|
|
if (m.scope_id) scopeLabel += ":" + m.scope_id;
|
|
var scopeCls = "scope-badge mem-scope-" + escapeHtml(m.scope);
|
|
var scopeBadge =
|
|
'<span class="' + scopeCls + '">' + escapeHtml(scopeLabel) + "</span>";
|
|
|
|
// Description (truncated)
|
|
var desc = m.description || "";
|
|
if (desc.length > 60) desc = desc.substring(0, 57) + "…";
|
|
|
|
html +=
|
|
'<div class="admin-row" role="listitem">' +
|
|
'<span class="admin-col admin-col-mname">' +
|
|
escapeHtml(m.name) +
|
|
"</span>" +
|
|
'<span class="admin-col admin-col-mtype">' +
|
|
typeBadge +
|
|
"</span>" +
|
|
'<span class="admin-col admin-col-mscope">' +
|
|
scopeBadge +
|
|
"</span>" +
|
|
'<span class="admin-col admin-col-mdesc">' +
|
|
escapeHtml(desc) +
|
|
"</span>" +
|
|
'<span class="admin-col admin-col-mupdated">' +
|
|
_relativeTime(m.updated) +
|
|
"</span>" +
|
|
'<span class="admin-col admin-col-actions">' +
|
|
'<button class="admin-btn-action" data-view-memory="' +
|
|
escapeHtml(m.memory_id) +
|
|
'">view</button>' +
|
|
'<button class="admin-btn-danger" data-delete-memory="' +
|
|
escapeHtml(m.memory_id) +
|
|
'" data-delete-name="' +
|
|
escapeHtml(m.name) +
|
|
'">delete</button>' +
|
|
"</span>" +
|
|
"</div>";
|
|
}
|
|
|
|
el.innerHTML = html;
|
|
|
|
// Bind view buttons
|
|
var viewBtns = el.querySelectorAll("[data-view-memory]");
|
|
for (var v = 0; v < viewBtns.length; v++) {
|
|
viewBtns[v].addEventListener("click", function () {
|
|
showMemoryDetailModal(this.getAttribute("data-view-memory"));
|
|
});
|
|
}
|
|
|
|
// Bind delete buttons
|
|
var delBtns = el.querySelectorAll("[data-delete-memory]");
|
|
for (var d = 0; d < delBtns.length; d++) {
|
|
delBtns[d].addEventListener("click", function () {
|
|
var mid = this.getAttribute("data-delete-memory");
|
|
var mname = this.getAttribute("data-delete-name");
|
|
deleteAdminMemory(mid, mname);
|
|
});
|
|
}
|
|
}
|
|
|
|
function showMemoryDetailModal(memoryId) {
|
|
_memDetailTrigger = document.activeElement;
|
|
var ov = document.getElementById("memory-detail-overlay");
|
|
ov.style.display = "flex";
|
|
document.getElementById("memory-detail-body").innerHTML =
|
|
'<div class="dashboard-empty">Loading…</div>';
|
|
|
|
// Disable delete button and clear stale handler while loading
|
|
var delBtn = document.getElementById("mem-detail-delete");
|
|
delBtn.disabled = true;
|
|
delBtn.onclick = null;
|
|
|
|
// Focus close button for keyboard accessibility
|
|
var closeBtn = ov.querySelector(".modal-cancel");
|
|
if (closeBtn) closeBtn.focus();
|
|
|
|
authFetch("/v1/api/admin/memories/" + encodeURIComponent(memoryId))
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error("Not found");
|
|
return r.json();
|
|
})
|
|
.then(function (m) {
|
|
var scopeLabel = m.scope;
|
|
if (m.scope_id) scopeLabel += ":" + m.scope_id;
|
|
|
|
var html =
|
|
'<div class="mem-detail-grid">' +
|
|
'<div class="mem-detail-field"><span class="mem-detail-label">Name</span>' +
|
|
escapeHtml(m.name) +
|
|
"</div>" +
|
|
'<div class="mem-detail-field"><span class="mem-detail-label">Type</span>' +
|
|
'<span class="scope-badge mem-type-' +
|
|
escapeHtml(m.type) +
|
|
'">' +
|
|
escapeHtml(m.type) +
|
|
"</span></div>" +
|
|
'<div class="mem-detail-field"><span class="mem-detail-label">Scope</span>' +
|
|
'<span class="scope-badge mem-scope-' +
|
|
escapeHtml(m.scope) +
|
|
'">' +
|
|
escapeHtml(scopeLabel) +
|
|
"</span></div>" +
|
|
'<div class="mem-detail-field"><span class="mem-detail-label">Created</span>' +
|
|
_relativeTime(m.created) +
|
|
"</div>" +
|
|
'<div class="mem-detail-field"><span class="mem-detail-label">Updated</span>' +
|
|
_relativeTime(m.updated) +
|
|
"</div>" +
|
|
'<div class="mem-detail-field"><span class="mem-detail-label">Accessed</span>' +
|
|
(m.access_count || 0) +
|
|
" times</div>" +
|
|
"</div>" +
|
|
'<div class="mem-detail-label" style="margin-top:12px">Description</div>' +
|
|
'<div class="mem-detail-desc">' +
|
|
escapeHtml(m.description || "(none)") +
|
|
"</div>" +
|
|
'<div class="mem-detail-label" style="margin-top:12px">Content</div>' +
|
|
'<pre class="memory-content-block">' +
|
|
escapeHtml(m.content) +
|
|
"</pre>";
|
|
|
|
document.getElementById("memory-detail-body").innerHTML = html;
|
|
|
|
// Wire delete button now that data is loaded
|
|
delBtn.disabled = false;
|
|
delBtn.onclick = function () {
|
|
deleteAdminMemory(m.memory_id, m.name);
|
|
};
|
|
})
|
|
.catch(function () {
|
|
document.getElementById("memory-detail-body").innerHTML =
|
|
'<div class="dashboard-empty">Failed to load memory</div>';
|
|
});
|
|
|
|
_memDetailTrap = _installTrap("memory-detail-overlay", "memory-detail-box");
|
|
}
|
|
|
|
function hideMemoryDetailModal() {
|
|
document.getElementById("memory-detail-overlay").style.display = "none";
|
|
_memDetailTrap = _removeTrap(_memDetailTrap);
|
|
if (_memDetailTrigger && _memDetailTrigger.focus) _memDetailTrigger.focus();
|
|
_memDetailTrigger = null;
|
|
}
|
|
|
|
function deleteAdminMemory(memoryId, memoryName) {
|
|
if (!confirm("Delete memory '" + memoryName + "'?")) return;
|
|
|
|
authFetch("/v1/api/admin/memories/" + encodeURIComponent(memoryId), {
|
|
method: "DELETE",
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
showToast("Memory deleted");
|
|
// Close detail modal if open
|
|
if (
|
|
document.getElementById("memory-detail-overlay").style.display !==
|
|
"none"
|
|
) {
|
|
hideMemoryDetailModal();
|
|
}
|
|
loadAdminMemories();
|
|
})
|
|
.catch(function (e) {
|
|
showToast("Error: " + e.message);
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Skill Discovery
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function switchSkillView(view) {
|
|
_skillCurrentView = view;
|
|
var btns = document.querySelectorAll("#admin-skills [data-skill-view]");
|
|
for (var i = 0; i < btns.length; i++) {
|
|
var isActive = btns[i].getAttribute("data-skill-view") === view;
|
|
btns[i].classList.toggle("active", isActive);
|
|
btns[i].setAttribute("aria-selected", isActive ? "true" : "false");
|
|
btns[i].setAttribute("tabindex", isActive ? "0" : "-1");
|
|
}
|
|
document.getElementById("skill-view-installed").style.display =
|
|
view === "installed" ? "" : "none";
|
|
document.getElementById("skill-view-discover").style.display =
|
|
view === "discover" ? "" : "none";
|
|
var toolbar = document.getElementById("skill-installed-toolbar");
|
|
if (toolbar) toolbar.style.display = view === "installed" ? "" : "none";
|
|
|
|
if (view === "installed") {
|
|
loadGovSkills();
|
|
} else {
|
|
var q = document.getElementById("skill-discover-q");
|
|
if (q) q.focus();
|
|
}
|
|
}
|
|
|
|
function searchSkillDiscover() {
|
|
var q = (document.getElementById("skill-discover-q").value || "").trim();
|
|
if (!q) {
|
|
showToast("Enter a search query");
|
|
return;
|
|
}
|
|
_skillDiscoverResults = [];
|
|
_skillDiscoverQuery = q;
|
|
|
|
var el = document.getElementById("skill-discover-results");
|
|
el.innerHTML = '<div class="dashboard-empty">Searching\u2026</div>';
|
|
|
|
var searchBtn = document.getElementById("skill-discover-search-btn");
|
|
if (searchBtn) searchBtn.disabled = true;
|
|
|
|
var url = "/v1/api/admin/skills/discover?limit=20&q=" + encodeURIComponent(q);
|
|
|
|
authFetch(url)
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Search failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function (data) {
|
|
_skillDiscoverResults = data.skills || [];
|
|
_renderSkillDiscoverResults();
|
|
})
|
|
.catch(function (e) {
|
|
el.innerHTML =
|
|
'<div class="dashboard-empty">' + escapeHtml(e.message) + "</div>";
|
|
})
|
|
.finally(function () {
|
|
if (searchBtn) searchBtn.disabled = false;
|
|
});
|
|
}
|
|
|
|
function _renderSkillDiscoverResults() {
|
|
var el = document.getElementById("skill-discover-results");
|
|
if (!_skillDiscoverResults.length) {
|
|
el.innerHTML = '<div class="dashboard-empty">No skills found</div>';
|
|
return;
|
|
}
|
|
|
|
var html = "";
|
|
for (var i = 0; i < _skillDiscoverResults.length; i++) {
|
|
var s = _skillDiscoverResults[i];
|
|
var nameLabel = escapeHtml(s.name || "");
|
|
var actionHtml;
|
|
if (s.installed) {
|
|
var scanBadgeHtml = "";
|
|
if (s.risk_level) {
|
|
var scanCls =
|
|
{
|
|
safe: "scope-scan-safe",
|
|
low: "scope-scan-low",
|
|
medium: "scope-scan-medium",
|
|
high: "scope-scan-high",
|
|
critical: "scope-scan-critical",
|
|
}[s.risk_level] || "";
|
|
scanBadgeHtml =
|
|
'<span class="scope-badge ' +
|
|
scanCls +
|
|
'" style="margin-right:4px">' +
|
|
escapeHtml(s.risk_level) +
|
|
"</span>";
|
|
}
|
|
actionHtml =
|
|
scanBadgeHtml + '<span class="mcp-installed-badge">Installed</span>';
|
|
} else {
|
|
actionHtml =
|
|
'<button class="mcp-install-btn" data-skill-install="' +
|
|
i +
|
|
'" aria-label="Install ' +
|
|
nameLabel +
|
|
'">Install</button>';
|
|
}
|
|
|
|
// Tags
|
|
var tagHtml = "";
|
|
var tags = s.tags || [];
|
|
for (var t = 0; t < tags.length && t < 4; t++) {
|
|
tagHtml += '<span class="scope-badge">' + escapeHtml(tags[t]) + "</span>";
|
|
}
|
|
|
|
// Source + install count badge
|
|
var metaHtml = "";
|
|
if (s.source) {
|
|
metaHtml +=
|
|
'<span class="scope-badge mcp-transport-http">' +
|
|
escapeHtml(s.source) +
|
|
"</span>";
|
|
}
|
|
if (s.install_count > 0) {
|
|
metaHtml +=
|
|
'<span class="mcp-reg-card-version">' +
|
|
s.install_count.toLocaleString() +
|
|
" installs</span>";
|
|
}
|
|
|
|
html +=
|
|
'<div class="mcp-reg-card" role="listitem">' +
|
|
'<div class="mcp-reg-card-info">' +
|
|
'<div class="mcp-reg-card-name">' +
|
|
nameLabel +
|
|
(s.author
|
|
? ' <span class="mcp-reg-card-version">by ' +
|
|
escapeHtml(s.author) +
|
|
"</span>"
|
|
: "") +
|
|
"</div>" +
|
|
(s.description
|
|
? '<div class="mcp-reg-card-desc">' +
|
|
escapeHtml(s.description) +
|
|
"</div>"
|
|
: "") +
|
|
'<div class="mcp-reg-card-meta">' +
|
|
tagHtml +
|
|
metaHtml +
|
|
"</div></div>" +
|
|
'<div class="mcp-reg-card-actions">' +
|
|
actionHtml +
|
|
"</div></div>";
|
|
}
|
|
|
|
el.innerHTML = html;
|
|
|
|
// Bind install handlers
|
|
el.querySelectorAll("[data-skill-install]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
var idx = parseInt(this.getAttribute("data-skill-install"), 10);
|
|
installDiscoveredSkill(_skillDiscoverResults[idx]);
|
|
});
|
|
});
|
|
}
|
|
|
|
function installDiscoveredSkill(skill) {
|
|
if (!skill) return;
|
|
|
|
// Disable the button
|
|
var btns = document.querySelectorAll("[data-skill-install]");
|
|
for (var i = 0; i < btns.length; i++) {
|
|
var idx = parseInt(btns[i].getAttribute("data-skill-install"), 10);
|
|
if (
|
|
_skillDiscoverResults[idx] &&
|
|
_skillDiscoverResults[idx].id === skill.id
|
|
) {
|
|
btns[i].disabled = true;
|
|
btns[i].textContent = "Installing\u2026";
|
|
break;
|
|
}
|
|
}
|
|
|
|
var body;
|
|
if (skill.source === "github") {
|
|
body = { source: "github", url: skill.source_url };
|
|
} else {
|
|
body = { source: "skills.sh", skill_id: skill.id };
|
|
}
|
|
|
|
authFetch("/v1/api/admin/skills/install", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Install failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function (data) {
|
|
var first = (data.installed && data.installed[0]) || {};
|
|
var tierMsg = first.risk_level ? " [" + first.risk_level + "]" : "";
|
|
showToast("Skill installed: " + (skill.name || skill.id) + tierMsg);
|
|
// Mark as installed in results with scan data
|
|
for (var j = 0; j < _skillDiscoverResults.length; j++) {
|
|
if (_skillDiscoverResults[j].id === skill.id) {
|
|
_skillDiscoverResults[j].installed = true;
|
|
_skillDiscoverResults[j].risk_level = first.risk_level || "";
|
|
break;
|
|
}
|
|
}
|
|
_renderSkillDiscoverResults();
|
|
})
|
|
.catch(function (e) {
|
|
showToast("Error: " + e.message);
|
|
_renderSkillDiscoverResults();
|
|
});
|
|
}
|
|
|
|
function showGitHubImportModal() {
|
|
_giTriggerEl = document.activeElement;
|
|
document.getElementById("github-import-overlay").style.display = "";
|
|
var urlInput = document.getElementById("gi-url");
|
|
urlInput.value = "";
|
|
var errEl = document.getElementById("github-import-error");
|
|
errEl.textContent = "";
|
|
errEl.style.display = "none";
|
|
_giTrapHandler = _installTrap("github-import-overlay", "github-import-box");
|
|
urlInput.focus();
|
|
}
|
|
|
|
function hideGitHubImportModal() {
|
|
document.getElementById("github-import-overlay").style.display = "none";
|
|
_giTrapHandler = _removeTrap(_giTrapHandler);
|
|
if (_giTriggerEl) {
|
|
_giTriggerEl.focus();
|
|
_giTriggerEl = null;
|
|
}
|
|
}
|
|
|
|
function submitGitHubImport() {
|
|
var url = (document.getElementById("gi-url").value || "").trim();
|
|
var errEl = document.getElementById("github-import-error");
|
|
if (!url) {
|
|
errEl.textContent = "URL is required";
|
|
errEl.style.display = "";
|
|
return;
|
|
}
|
|
if (!/^https?:\/\/github\.com\//i.test(url)) {
|
|
errEl.textContent = "Must be a GitHub URL";
|
|
errEl.style.display = "";
|
|
return;
|
|
}
|
|
|
|
var submitBtn = document.getElementById("gi-submit");
|
|
submitBtn.disabled = true;
|
|
submitBtn.textContent = "Installing\u2026";
|
|
errEl.style.display = "none";
|
|
|
|
authFetch("/v1/api/admin/skills/install", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ source: "github", url: url }),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Install failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function (data) {
|
|
hideGitHubImportModal();
|
|
var count = data.installed.length;
|
|
var skipCount = (data.skipped || []).length;
|
|
var msg;
|
|
if (count === 1 && !skipCount) {
|
|
var name = data.installed[0].name || "";
|
|
var tierMsg = data.installed[0].risk_level
|
|
? " [" + data.installed[0].risk_level + "]"
|
|
: "";
|
|
msg = "Skill installed: " + name + tierMsg;
|
|
} else if (count === 0 && skipCount) {
|
|
msg =
|
|
"All " +
|
|
skipCount +
|
|
" skill" +
|
|
(skipCount !== 1 ? "s" : "") +
|
|
" already installed";
|
|
} else {
|
|
msg = count + " skill" + (count !== 1 ? "s" : "") + " installed";
|
|
if (skipCount) msg += " (" + skipCount + " already installed)";
|
|
}
|
|
showToast(msg);
|
|
loadGovSkills();
|
|
})
|
|
.catch(function (e) {
|
|
errEl.textContent = e.message;
|
|
errEl.style.display = "";
|
|
})
|
|
.finally(function () {
|
|
submitBtn.disabled = false;
|
|
submitBtn.textContent = "Install";
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Prompt Policies (system message composition)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
var _promptPolicies = [];
|
|
var _cppTrapHandler = null;
|
|
var _cppTriggerEl = null;
|
|
var _eppTrapHandler = null;
|
|
var _eppTriggerEl = null;
|
|
|
|
function loadPromptPolicies() {
|
|
authFetch("/v1/api/admin/prompt-policies")
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error("Failed");
|
|
return r.json();
|
|
})
|
|
.then(function (data) {
|
|
_promptPolicies = data.policies || [];
|
|
_renderPromptPolicies(_promptPolicies);
|
|
})
|
|
.catch(function () {
|
|
var el = document.getElementById("admin-prompt-policies-table");
|
|
el.textContent = "";
|
|
var empty = document.createElement("div");
|
|
empty.className = "dashboard-empty";
|
|
empty.textContent = "Failed to load prompts";
|
|
el.appendChild(empty);
|
|
});
|
|
}
|
|
|
|
function _renderPromptPolicies(items) {
|
|
var el = document.getElementById("admin-prompt-policies-table");
|
|
el.textContent = "";
|
|
if (!items.length) {
|
|
var empty = document.createElement("div");
|
|
empty.className = "dashboard-empty";
|
|
empty.textContent = "No prompts defined";
|
|
el.appendChild(empty);
|
|
return;
|
|
}
|
|
for (var i = 0; i < items.length; i++) {
|
|
var p = items[i];
|
|
var row = document.createElement("div");
|
|
row.className = "admin-row";
|
|
row.setAttribute("role", "listitem");
|
|
|
|
var colName = document.createElement("span");
|
|
colName.className = "admin-col admin-col-pname";
|
|
colName.textContent = p.name;
|
|
row.appendChild(colName);
|
|
|
|
var colGate = document.createElement("span");
|
|
colGate.className = "admin-col admin-col-ppattern";
|
|
if (p.tool_gate) {
|
|
var code = document.createElement("code");
|
|
code.textContent = p.tool_gate;
|
|
colGate.appendChild(code);
|
|
} else {
|
|
var em = document.createElement("em");
|
|
em.textContent = "unconditional";
|
|
colGate.appendChild(em);
|
|
}
|
|
row.appendChild(colGate);
|
|
|
|
var colPri = document.createElement("span");
|
|
colPri.className = "admin-col admin-col-ppriority";
|
|
colPri.textContent = String(p.priority);
|
|
row.appendChild(colPri);
|
|
|
|
var colStatus = document.createElement("span");
|
|
colStatus.className = "admin-col admin-col-pstatus";
|
|
var dot = document.createElement("span");
|
|
dot.className = p.enabled ? "watch-active" : "watch-completed";
|
|
dot.title = p.enabled ? "Enabled" : "Disabled";
|
|
dot.textContent = p.enabled ? "\u25CF active" : "\u25CB disabled";
|
|
colStatus.appendChild(dot);
|
|
row.appendChild(colStatus);
|
|
|
|
var colActions = document.createElement("span");
|
|
colActions.className = "admin-col admin-col-actions";
|
|
var editBtn = document.createElement("button");
|
|
editBtn.className = "admin-btn-action";
|
|
editBtn.textContent = "edit";
|
|
editBtn.setAttribute("data-edit-ppolicy", p.policy_id);
|
|
editBtn.setAttribute("aria-label", "Edit prompt " + p.name);
|
|
colActions.appendChild(editBtn);
|
|
var delBtn = document.createElement("button");
|
|
delBtn.className = "admin-btn-danger";
|
|
delBtn.textContent = "delete";
|
|
delBtn.setAttribute("data-delete-ppolicy", p.policy_id);
|
|
delBtn.setAttribute("data-ppolicy-name", p.name);
|
|
delBtn.setAttribute("aria-label", "Delete prompt " + p.name);
|
|
colActions.appendChild(delBtn);
|
|
row.appendChild(colActions);
|
|
|
|
el.appendChild(row);
|
|
}
|
|
el.querySelectorAll("[data-edit-ppolicy]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
showEditPromptPolicyModal(this.getAttribute("data-edit-ppolicy"));
|
|
});
|
|
});
|
|
el.querySelectorAll("[data-delete-ppolicy]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
var pid = this.getAttribute("data-delete-ppolicy");
|
|
var pname = this.getAttribute("data-ppolicy-name");
|
|
showConfirmModal(
|
|
"Delete Prompt",
|
|
'Delete prompt "' + pname + '"?',
|
|
"Delete",
|
|
function () {
|
|
authFetch("/v1/api/admin/prompt-policies/" + pid, {
|
|
method: "DELETE",
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error();
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
showToast("Prompt deleted");
|
|
loadPromptPolicies();
|
|
})
|
|
.catch(function () {
|
|
showToast("Failed to delete prompt");
|
|
});
|
|
},
|
|
);
|
|
});
|
|
});
|
|
}
|
|
|
|
function showCreatePromptPolicyModal() {
|
|
_cppTriggerEl = document.activeElement;
|
|
var ov = document.getElementById("create-ppolicy-overlay");
|
|
ov.style.display = "flex";
|
|
document.getElementById("cpp-name").value = "";
|
|
document.getElementById("cpp-gate").value = "";
|
|
document.getElementById("cpp-content").value = "";
|
|
document.getElementById("cpp-priority").value = "0";
|
|
document.getElementById("cpp-error").style.display = "none";
|
|
document.getElementById("cpp-name").focus();
|
|
_cppTrapHandler = _installTrap(
|
|
"create-ppolicy-overlay",
|
|
"create-ppolicy-box",
|
|
);
|
|
}
|
|
|
|
function hideCreatePromptPolicyModal() {
|
|
document.getElementById("create-ppolicy-overlay").style.display = "none";
|
|
_cppTrapHandler = _removeTrap(_cppTrapHandler);
|
|
if (_cppTriggerEl && _cppTriggerEl.focus) {
|
|
_cppTriggerEl.focus();
|
|
}
|
|
_cppTriggerEl = null;
|
|
}
|
|
|
|
function submitCreatePromptPolicy() {
|
|
var errEl = document.getElementById("cpp-error");
|
|
var name = document.getElementById("cpp-name").value.trim();
|
|
var content = document.getElementById("cpp-content").value.trim();
|
|
if (!name || !content) {
|
|
errEl.textContent = "Name and content are required";
|
|
errEl.style.display = "";
|
|
return;
|
|
}
|
|
errEl.style.display = "none";
|
|
var submitBtn = document.getElementById("cpp-submit");
|
|
submitBtn.disabled = true;
|
|
authFetch("/v1/api/admin/prompt-policies", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
name: name,
|
|
content: content,
|
|
tool_gate: document.getElementById("cpp-gate").value.trim(),
|
|
priority:
|
|
parseInt(document.getElementById("cpp-priority").value, 10) || 0,
|
|
enabled: true,
|
|
}),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
hideCreatePromptPolicyModal();
|
|
showToast("Prompt created");
|
|
loadPromptPolicies();
|
|
})
|
|
.catch(function (e) {
|
|
errEl.textContent = e.message;
|
|
errEl.style.display = "";
|
|
})
|
|
.finally(function () {
|
|
submitBtn.disabled = false;
|
|
});
|
|
}
|
|
|
|
function showEditPromptPolicyModal(policyId) {
|
|
_eppTriggerEl = document.activeElement;
|
|
var p = null;
|
|
for (var i = 0; i < _promptPolicies.length; i++) {
|
|
if (_promptPolicies[i].policy_id === policyId) {
|
|
p = _promptPolicies[i];
|
|
break;
|
|
}
|
|
}
|
|
if (!p) return;
|
|
document.getElementById("epp-id").value = p.policy_id;
|
|
document.getElementById("epp-name").value = p.name;
|
|
document.getElementById("epp-gate").value = p.tool_gate || "";
|
|
document.getElementById("epp-content").value = p.content || "";
|
|
document.getElementById("epp-priority").value = p.priority || 0;
|
|
document.getElementById("epp-enabled").checked = p.enabled;
|
|
document.getElementById("epp-error").style.display = "none";
|
|
var ov = document.getElementById("edit-ppolicy-overlay");
|
|
ov.style.display = "flex";
|
|
document.getElementById("epp-name").focus();
|
|
_eppTrapHandler = _installTrap("edit-ppolicy-overlay", "edit-ppolicy-box");
|
|
}
|
|
|
|
function hideEditPromptPolicyModal() {
|
|
document.getElementById("edit-ppolicy-overlay").style.display = "none";
|
|
_eppTrapHandler = _removeTrap(_eppTrapHandler);
|
|
if (_eppTriggerEl && _eppTriggerEl.focus) {
|
|
_eppTriggerEl.focus();
|
|
}
|
|
_eppTriggerEl = null;
|
|
}
|
|
|
|
function submitEditPromptPolicy() {
|
|
var errEl = document.getElementById("epp-error");
|
|
var policyId = document.getElementById("epp-id").value;
|
|
var name = document.getElementById("epp-name").value.trim();
|
|
var content = document.getElementById("epp-content").value.trim();
|
|
if (!name || !content) {
|
|
errEl.textContent = "Name and content are required";
|
|
errEl.style.display = "";
|
|
return;
|
|
}
|
|
errEl.style.display = "none";
|
|
var submitBtn = document.getElementById("epp-submit");
|
|
submitBtn.disabled = true;
|
|
authFetch("/v1/api/admin/prompt-policies/" + policyId, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
name: name,
|
|
content: content,
|
|
tool_gate: document.getElementById("epp-gate").value.trim(),
|
|
priority:
|
|
parseInt(document.getElementById("epp-priority").value, 10) || 0,
|
|
enabled: document.getElementById("epp-enabled").checked,
|
|
}),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
hideEditPromptPolicyModal();
|
|
showToast("Prompt updated");
|
|
loadPromptPolicies();
|
|
})
|
|
.catch(function (e) {
|
|
errEl.textContent = e.message;
|
|
errEl.style.display = "";
|
|
})
|
|
.finally(function () {
|
|
submitBtn.disabled = false;
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Judge tab — settings, heuristic rules, output guard patterns
|
|
// ---------------------------------------------------------------------------
|
|
|
|
var _judgeSettings = [];
|
|
var _judgeHeuristicRules = [];
|
|
var _judgeOGPatterns = [];
|
|
var _judgeModelDefs = [];
|
|
var _chrTrapHandler = null; // create heuristic rule
|
|
var _cogpTrapHandler = null; // create output guard pattern
|
|
var _chrTriggerEl = null;
|
|
var _cogpTriggerEl = null;
|
|
var _ehrTrapHandler = null; // edit heuristic rule
|
|
var _eogpTrapHandler = null; // edit output guard pattern
|
|
var _ehrTriggerEl = null;
|
|
var _eogpTriggerEl = null;
|
|
|
|
// -- Sub-section switcher ---------------------------------------------------
|
|
|
|
function switchJudgeSection(section) {
|
|
var sections = document.querySelectorAll(".judge-section");
|
|
for (var i = 0; i < sections.length; i++) sections[i].style.display = "none";
|
|
var btns = document.querySelectorAll(".judge-section-btn");
|
|
for (var i = 0; i < btns.length; i++) {
|
|
var isActive = btns[i].getAttribute("data-section") === section;
|
|
btns[i].classList.toggle("active", isActive);
|
|
btns[i].setAttribute("aria-selected", isActive ? "true" : "false");
|
|
btns[i].setAttribute("tabindex", isActive ? "0" : "-1");
|
|
}
|
|
var target = document.getElementById(section + "-section");
|
|
if (target) target.style.display = "";
|
|
}
|
|
|
|
// Arrow key navigation for judge sub-section tabs
|
|
(function () {
|
|
var switcher = document.querySelector(".judge-section-switcher");
|
|
if (!switcher) return;
|
|
switcher.addEventListener("keydown", function (e) {
|
|
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
|
var btns = switcher.querySelectorAll(".judge-section-btn");
|
|
var secs = [];
|
|
for (var i = 0; i < btns.length; i++)
|
|
secs.push(btns[i].getAttribute("data-section"));
|
|
var current = switcher.querySelector(".judge-section-btn.active");
|
|
var idx = secs.indexOf(current ? current.getAttribute("data-section") : "");
|
|
if (e.key === "ArrowRight") idx = (idx + 1) % secs.length;
|
|
else idx = (idx - 1 + secs.length) % secs.length;
|
|
e.preventDefault();
|
|
switchJudgeSection(secs[idx]);
|
|
btns[idx].focus();
|
|
});
|
|
})();
|
|
|
|
// -- Load all judge data ----------------------------------------------------
|
|
|
|
function loadJudgeTab() {
|
|
loadJudgeHeuristicRules();
|
|
loadJudgeOGPatterns();
|
|
// Load model definitions before settings (settings render needs the model list)
|
|
authFetch("/v1/api/admin/model-definitions")
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error("Failed");
|
|
return r.json();
|
|
})
|
|
.then(function (d) {
|
|
_judgeModelDefs = d.models || [];
|
|
})
|
|
.catch(function () {
|
|
_judgeModelDefs = [];
|
|
})
|
|
.finally(function () {
|
|
loadJudgeSettings();
|
|
});
|
|
}
|
|
|
|
// -- Settings section -------------------------------------------------------
|
|
// NOTE: innerHTML usage below is safe — all dynamic values are escaped via
|
|
// escapeHtml before interpolation into the HTML string, and the
|
|
// data originates from our own admin API (authenticated, same-origin).
|
|
|
|
function loadJudgeSettings() {
|
|
authFetch("/v1/api/admin/judge/settings")
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error("Failed");
|
|
return r.json();
|
|
})
|
|
.then(function (d) {
|
|
_judgeSettings = d.settings || [];
|
|
renderJudgeSettings();
|
|
})
|
|
.catch(function () {
|
|
document.getElementById("judge-settings-container").innerHTML =
|
|
'<div class="dashboard-empty">Failed to load settings</div>';
|
|
});
|
|
}
|
|
|
|
function renderJudgeSettings() {
|
|
var c = document.getElementById("judge-settings-container");
|
|
if (!_judgeSettings.length) {
|
|
c.innerHTML = '<div class="dashboard-empty">No judge settings found</div>';
|
|
return;
|
|
}
|
|
var html = "";
|
|
for (var i = 0; i < _judgeSettings.length; i++) {
|
|
var s = _judgeSettings[i];
|
|
var shortKey = s.key.replace("judge.", "");
|
|
var inputHtml = "";
|
|
var currentVal = s.value;
|
|
var isDefault = s.source === "default";
|
|
|
|
if (s.type === "bool") {
|
|
inputHtml =
|
|
'<label class="toggle-label" style="display:flex;align-items:center;gap:8px;cursor:pointer">' +
|
|
'<input type="checkbox" data-key="' +
|
|
s.key +
|
|
'" ' +
|
|
(currentVal ? "checked" : "") +
|
|
" onchange=\"saveJudgeSetting('" +
|
|
s.key +
|
|
'\',this.checked)" style="width:16px;height:16px">' +
|
|
'<span style="font-size:12px">' +
|
|
(currentVal ? "Enabled" : "Disabled") +
|
|
"</span></label>";
|
|
} else if (s.type === "float") {
|
|
inputHtml =
|
|
'<div style="display:flex;gap:8px;align-items:center">' +
|
|
'<input type="number" step="0.01" data-key="' +
|
|
s.key +
|
|
'" value="' +
|
|
currentVal +
|
|
'"' +
|
|
(s.min_value != null ? ' min="' + s.min_value + '"' : "") +
|
|
(s.max_value != null ? ' max="' + s.max_value + '"' : "") +
|
|
' style="width:100px;padding:4px 8px;background:var(--bg);border:1px solid var(--border-strong);color:var(--fg);border-radius:3px">' +
|
|
'<button class="admin-action-btn" onclick="saveJudgeSettingFromInput(\'' +
|
|
s.key +
|
|
"')\">Save</button></div>";
|
|
} else if (s.is_secret) {
|
|
inputHtml =
|
|
'<div style="display:flex;gap:8px;align-items:center">' +
|
|
'<input type="password" data-key="' +
|
|
s.key +
|
|
'" value="' +
|
|
escapeHtml(currentVal || "") +
|
|
'" placeholder="(not set)"' +
|
|
' style="width:240px;padding:4px 8px;background:var(--bg);border:1px solid var(--border-strong);color:var(--fg);border-radius:3px">' +
|
|
'<button class="admin-action-btn" onclick="saveJudgeSettingFromInput(\'' +
|
|
s.key +
|
|
"')\">Save</button></div>";
|
|
} else if (shortKey === "model") {
|
|
// Model picker: select from model definitions
|
|
inputHtml =
|
|
'<div style="display:flex;gap:8px;align-items:center">' +
|
|
'<select data-key="' +
|
|
s.key +
|
|
'" onchange="saveJudgeSetting(\'' +
|
|
s.key +
|
|
"',this.value)\"" +
|
|
' style="width:240px;padding:4px 8px;background:var(--bg);border:1px solid var(--border-strong);color:var(--fg);border-radius:3px">' +
|
|
'<option value="">(same as session)</option>';
|
|
for (var m = 0; m < _judgeModelDefs.length; m++) {
|
|
var md = _judgeModelDefs[m];
|
|
if (!md.enabled) continue;
|
|
inputHtml +=
|
|
'<option value="' +
|
|
escapeHtml(md.alias) +
|
|
'"' +
|
|
(currentVal === md.alias ? " selected" : "") +
|
|
">" +
|
|
escapeHtml(md.alias) +
|
|
" (" +
|
|
escapeHtml(md.model) +
|
|
")</option>";
|
|
}
|
|
// Also allow the current value if it's not in model defs (manual entry)
|
|
if (
|
|
currentVal &&
|
|
!_judgeModelDefs.some(function (md) {
|
|
return md.alias === currentVal;
|
|
})
|
|
) {
|
|
inputHtml +=
|
|
'<option value="' +
|
|
escapeHtml(currentVal) +
|
|
'" selected>' +
|
|
escapeHtml(currentVal) +
|
|
" (manual)</option>";
|
|
}
|
|
inputHtml += "</select></div>";
|
|
} else {
|
|
inputHtml =
|
|
'<div style="display:flex;gap:8px;align-items:center">' +
|
|
'<input type="text" data-key="' +
|
|
s.key +
|
|
'" value="' +
|
|
escapeHtml(currentVal || "") +
|
|
'"' +
|
|
' style="width:240px;padding:4px 8px;background:var(--bg);border:1px solid var(--border-strong);color:var(--fg);border-radius:3px">' +
|
|
'<button class="admin-action-btn" onclick="saveJudgeSettingFromInput(\'' +
|
|
s.key +
|
|
"')\">Save</button></div>";
|
|
}
|
|
|
|
var resetBtn = !isDefault
|
|
? ' <button class="admin-action-btn" style="font-size:11px;padding:2px 6px" onclick="resetJudgeSetting(\'' +
|
|
s.key +
|
|
"')\">Reset</button>"
|
|
: "";
|
|
|
|
html +=
|
|
'<div style="margin-bottom:12px;padding-bottom:10px;border-bottom:1px solid var(--border-strong)">' +
|
|
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">' +
|
|
'<code style="font-family:var(--font-display);font-size:12px;font-weight:600;color:var(--fg)">' +
|
|
shortKey +
|
|
"</code>" +
|
|
(isDefault
|
|
? '<span style="font-size:11px;color:var(--fg-dim)">default</span>'
|
|
: '<span style="font-size:11px;color:var(--green)">customized</span>') +
|
|
resetBtn +
|
|
"</div>" +
|
|
'<div style="font-size:11px;color:var(--fg-dim);margin-bottom:5px">' +
|
|
escapeHtml(s.help || s.description || "") +
|
|
"</div>" +
|
|
inputHtml +
|
|
"</div>";
|
|
}
|
|
c.innerHTML = html;
|
|
}
|
|
|
|
function saveJudgeSetting(key, value) {
|
|
authFetch("/v1/api/admin/judge/settings/" + encodeURIComponent(key), {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ value: value }),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
showToast("Setting saved");
|
|
loadJudgeSettings();
|
|
})
|
|
.catch(function (e) {
|
|
showToast("Error: " + e.message);
|
|
});
|
|
}
|
|
|
|
function saveJudgeSettingFromInput(key) {
|
|
var input = document.querySelector('[data-key="' + key + '"]');
|
|
if (!input) return;
|
|
saveJudgeSetting(key, input.value);
|
|
}
|
|
|
|
function resetJudgeSetting(key) {
|
|
authFetch("/v1/api/admin/judge/settings/" + encodeURIComponent(key), {
|
|
method: "DELETE",
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
showToast("Reset to default");
|
|
loadJudgeSettings();
|
|
})
|
|
.catch(function (e) {
|
|
showToast("Error: " + e.message);
|
|
});
|
|
}
|
|
|
|
// -- Heuristic Rules section ------------------------------------------------
|
|
|
|
function loadJudgeHeuristicRules() {
|
|
authFetch("/v1/api/admin/judge/heuristic-rules")
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error("Failed");
|
|
return r.json();
|
|
})
|
|
.then(function (d) {
|
|
_judgeHeuristicRules = d.rules || [];
|
|
renderHeuristicRules();
|
|
})
|
|
.catch(function () {
|
|
document.getElementById("judge-heuristic-table-container").innerHTML =
|
|
'<div class="dashboard-empty">Failed to load rules</div>';
|
|
});
|
|
}
|
|
|
|
function renderHeuristicRules() {
|
|
var c = document.getElementById("judge-heuristic-table-container");
|
|
if (!_judgeHeuristicRules.length) {
|
|
c.innerHTML = '<div class="dashboard-empty">No rules found</div>';
|
|
return;
|
|
}
|
|
var html = "";
|
|
for (var i = 0; i < _judgeHeuristicRules.length; i++) {
|
|
var r = _judgeHeuristicRules[i];
|
|
var sourceBadge =
|
|
r.source === "builtin"
|
|
? '<span class="scope-badge">built-in</span>'
|
|
: r.source === "builtin-overridden"
|
|
? '<span class="scope-badge scope-channel">modified</span>'
|
|
: r.source === "builtin-disabled"
|
|
? '<span class="scope-badge">built-in</span>'
|
|
: '<span class="scope-badge scope-write">custom</span>';
|
|
var statusBadge = r.enabled
|
|
? '<span class="scope-badge scope-scan-safe">active</span>'
|
|
: '<span class="scope-badge scope-deny">disabled</span>';
|
|
// Note: all dynamic values are escaped via escapeHtml() — safe for innerHTML
|
|
var actions = "";
|
|
var eName = escapeHtml(r.name);
|
|
if (!r.rule_id) {
|
|
// Pure built-in: Disable + Edit
|
|
actions =
|
|
'<button class="admin-btn-action" data-disable-builtin-hr="' +
|
|
eName +
|
|
'" aria-label="Disable ' +
|
|
eName +
|
|
'">Disable</button> ' +
|
|
'<button class="admin-btn-action" data-edit-hr-builtin="' +
|
|
eName +
|
|
'" aria-label="Edit ' +
|
|
eName +
|
|
'">Edit</button>';
|
|
} else if (r.builtin) {
|
|
// Overridden or disabled built-in: Enable/Disable + Edit + Reset
|
|
actions =
|
|
'<button class="admin-btn-action" data-toggle-hr="' +
|
|
r.rule_id +
|
|
'" data-enabled="' +
|
|
!r.enabled +
|
|
'" aria-label="' +
|
|
(r.enabled ? "Disable" : "Enable") +
|
|
" " +
|
|
eName +
|
|
'">' +
|
|
(r.enabled ? "Disable" : "Enable") +
|
|
"</button> " +
|
|
'<button class="admin-btn-action" data-edit-hr="' +
|
|
r.rule_id +
|
|
'" aria-label="Edit ' +
|
|
eName +
|
|
'">Edit</button> ' +
|
|
'<button class="admin-btn-caution" data-reset-hr="' +
|
|
r.rule_id +
|
|
'" aria-label="Reset ' +
|
|
eName +
|
|
'">Reset</button>';
|
|
} else {
|
|
// Custom rule: Enable/Disable + Edit + Delete
|
|
actions =
|
|
'<button class="admin-btn-action" data-toggle-hr="' +
|
|
r.rule_id +
|
|
'" data-enabled="' +
|
|
!r.enabled +
|
|
'" aria-label="' +
|
|
(r.enabled ? "Disable" : "Enable") +
|
|
" " +
|
|
eName +
|
|
'">' +
|
|
(r.enabled ? "Disable" : "Enable") +
|
|
"</button> " +
|
|
'<button class="admin-btn-action" data-edit-hr="' +
|
|
r.rule_id +
|
|
'" aria-label="Edit ' +
|
|
eName +
|
|
'">Edit</button> ' +
|
|
'<button class="admin-btn-danger" data-delete-hr="' +
|
|
r.rule_id +
|
|
'" aria-label="Delete ' +
|
|
eName +
|
|
'">Delete</button>';
|
|
}
|
|
html +=
|
|
'<div class="admin-row" role="listitem">' +
|
|
'<span class="admin-col"><code>' +
|
|
escapeHtml(r.name) +
|
|
"</code></span>" +
|
|
'<span class="admin-col admin-col-htier">' +
|
|
escapeHtml(r.tier || r.risk_level) +
|
|
"</span>" +
|
|
'<span class="admin-col admin-col-hrisk">' +
|
|
escapeHtml(r.risk_level) +
|
|
"</span>" +
|
|
'<span class="admin-col"><code>' +
|
|
escapeHtml(r.tool_pattern) +
|
|
"</code></span>" +
|
|
'<span class="admin-col admin-col-hrec">' +
|
|
escapeHtml(r.recommendation) +
|
|
"</span>" +
|
|
'<span class="admin-col">' +
|
|
sourceBadge +
|
|
"</span>" +
|
|
'<span class="admin-col">' +
|
|
statusBadge +
|
|
"</span>" +
|
|
'<span class="admin-col">' +
|
|
actions +
|
|
"</span></div>";
|
|
}
|
|
c.innerHTML = html;
|
|
// Bind data-attribute event handlers
|
|
c.querySelectorAll("[data-disable-builtin-hr]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
disableBuiltinHeuristicRule(this.getAttribute("data-disable-builtin-hr"));
|
|
});
|
|
});
|
|
c.querySelectorAll("[data-toggle-hr]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
toggleHeuristicRule(
|
|
this.getAttribute("data-toggle-hr"),
|
|
this.getAttribute("data-enabled") === "true",
|
|
);
|
|
});
|
|
});
|
|
c.querySelectorAll("[data-edit-hr-builtin]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
showEditBuiltinHeuristicRuleModal(
|
|
this.getAttribute("data-edit-hr-builtin"),
|
|
);
|
|
});
|
|
});
|
|
c.querySelectorAll("[data-edit-hr]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
showEditHeuristicRuleModal(this.getAttribute("data-edit-hr"));
|
|
});
|
|
});
|
|
c.querySelectorAll("[data-reset-hr]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
resetHeuristicRule(this.getAttribute("data-reset-hr"));
|
|
});
|
|
});
|
|
c.querySelectorAll("[data-delete-hr]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
deleteHeuristicRule(this.getAttribute("data-delete-hr"));
|
|
});
|
|
});
|
|
}
|
|
|
|
function toggleHeuristicRule(ruleId, enabled) {
|
|
authFetch("/v1/api/admin/judge/heuristic-rules/" + ruleId, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ enabled: enabled }),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
showToast(enabled ? "Rule enabled" : "Rule disabled");
|
|
loadJudgeHeuristicRules();
|
|
})
|
|
.catch(function (e) {
|
|
showToast("Error: " + e.message);
|
|
});
|
|
}
|
|
|
|
function deleteHeuristicRule(ruleId) {
|
|
var ruleName = "";
|
|
for (var j = 0; j < _judgeHeuristicRules.length; j++) {
|
|
if (_judgeHeuristicRules[j].rule_id === ruleId) {
|
|
ruleName = _judgeHeuristicRules[j].name;
|
|
break;
|
|
}
|
|
}
|
|
showConfirmModal(
|
|
"Delete Rule",
|
|
'Delete custom rule "' + ruleName + '"? This action cannot be undone.',
|
|
"Delete",
|
|
function () {
|
|
authFetch("/v1/api/admin/judge/heuristic-rules/" + ruleId, {
|
|
method: "DELETE",
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
showToast("Rule deleted");
|
|
loadJudgeHeuristicRules();
|
|
})
|
|
.catch(function (e) {
|
|
showToast("Error: " + e.message);
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
function showCreateHeuristicRuleModal() {
|
|
_chrTriggerEl = document.activeElement;
|
|
var ov = document.getElementById("create-hr-overlay");
|
|
ov.style.display = "flex";
|
|
document.getElementById("hr-name").value = "";
|
|
document.getElementById("hr-tier").value = "medium";
|
|
document.getElementById("hr-risk").value = "medium";
|
|
document.getElementById("hr-rec").value = "review";
|
|
document.getElementById("hr-tool").value = "bash";
|
|
document.getElementById("hr-args").value = "";
|
|
document.getElementById("hr-conf").value = "0.8";
|
|
document.getElementById("hr-intent").value = "";
|
|
document.getElementById("hr-reason").value = "";
|
|
document.getElementById("create-hr-error").style.display = "none";
|
|
document.getElementById("hr-submit").disabled = false;
|
|
document.getElementById("hr-name").focus();
|
|
_chrTrapHandler = _installTrap("create-hr-overlay", "create-hr-box");
|
|
}
|
|
|
|
function hideCreateHRModal() {
|
|
document.getElementById("create-hr-overlay").style.display = "none";
|
|
_chrTrapHandler = _removeTrap(_chrTrapHandler);
|
|
if (_chrTriggerEl && _chrTriggerEl.focus) _chrTriggerEl.focus();
|
|
_chrTriggerEl = null;
|
|
}
|
|
|
|
function submitCreateHeuristicRule() {
|
|
var errEl = document.getElementById("create-hr-error");
|
|
errEl.style.display = "none";
|
|
var argsText = document.getElementById("hr-args").value.trim();
|
|
var argPatterns = argsText
|
|
? argsText.split("\n").filter(function (l) {
|
|
return l.trim();
|
|
})
|
|
: [];
|
|
var payload = {
|
|
name: document.getElementById("hr-name").value.trim(),
|
|
tier: document.getElementById("hr-tier").value,
|
|
risk_level: document.getElementById("hr-risk").value,
|
|
recommendation: document.getElementById("hr-rec").value,
|
|
tool_pattern: document.getElementById("hr-tool").value.trim(),
|
|
arg_patterns: argPatterns,
|
|
confidence: parseFloat(document.getElementById("hr-conf").value) || 0.8,
|
|
intent_template: document.getElementById("hr-intent").value.trim(),
|
|
reasoning_template: document.getElementById("hr-reason").value.trim(),
|
|
enabled: true,
|
|
};
|
|
var btn = document.getElementById("hr-submit");
|
|
btn.disabled = true;
|
|
authFetch("/v1/api/admin/judge/heuristic-rules", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
hideCreateHRModal();
|
|
showToast("Rule created");
|
|
loadJudgeHeuristicRules();
|
|
})
|
|
.catch(function (e) {
|
|
errEl.textContent = e.message;
|
|
errEl.style.display = "";
|
|
})
|
|
.finally(function () {
|
|
btn.disabled = false;
|
|
});
|
|
}
|
|
|
|
// -- Heuristic Rule: disable / edit / reset ---------------------------------
|
|
|
|
function disableBuiltinHeuristicRule(name) {
|
|
var rule = null;
|
|
for (var i = 0; i < _judgeHeuristicRules.length; i++) {
|
|
if (_judgeHeuristicRules[i].name === name) {
|
|
rule = _judgeHeuristicRules[i];
|
|
break;
|
|
}
|
|
}
|
|
if (!rule) return;
|
|
var payload = {
|
|
name: rule.name,
|
|
risk_level: rule.risk_level,
|
|
confidence: rule.confidence,
|
|
recommendation: rule.recommendation,
|
|
tool_pattern: rule.tool_pattern,
|
|
arg_patterns: rule.arg_patterns,
|
|
intent_template: rule.intent_template || "",
|
|
reasoning_template: rule.reasoning_template || "",
|
|
tier: rule.tier || rule.risk_level,
|
|
priority: rule.priority || 0,
|
|
builtin: true,
|
|
enabled: false,
|
|
};
|
|
authFetch("/v1/api/admin/judge/heuristic-rules", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
showToast("Built-in rule disabled \u2014 Reset to restore defaults");
|
|
loadJudgeHeuristicRules();
|
|
})
|
|
.catch(function (e) {
|
|
showToast("Error: " + e.message);
|
|
});
|
|
}
|
|
|
|
function resetHeuristicRule(ruleId) {
|
|
var ruleName = "";
|
|
for (var j = 0; j < _judgeHeuristicRules.length; j++) {
|
|
if (_judgeHeuristicRules[j].rule_id === ruleId) {
|
|
ruleName = _judgeHeuristicRules[j].name;
|
|
break;
|
|
}
|
|
}
|
|
showConfirmModal(
|
|
"Reset to Built-in",
|
|
'Reset "' +
|
|
ruleName +
|
|
'" to its built-in defaults? Your customizations will be removed.',
|
|
"Reset",
|
|
function () {
|
|
authFetch("/v1/api/admin/judge/heuristic-rules/" + ruleId, {
|
|
method: "DELETE",
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
showToast("Rule reset to built-in defaults");
|
|
loadJudgeHeuristicRules();
|
|
})
|
|
.catch(function (e) {
|
|
showToast("Error: " + e.message);
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
function _populateEditHRModal(rule, isBuiltin) {
|
|
document.getElementById("ehr-id").value = rule.rule_id || "";
|
|
document.getElementById("ehr-builtin").value = isBuiltin ? "true" : "false";
|
|
document.getElementById("ehr-priority").value = rule.priority || 0;
|
|
document.getElementById("ehr-name").value = rule.name;
|
|
document.getElementById("ehr-name").disabled = isBuiltin;
|
|
document.getElementById("ehr-tier").value = rule.tier || rule.risk_level;
|
|
document.getElementById("ehr-risk").value = rule.risk_level;
|
|
document.getElementById("ehr-rec").value = rule.recommendation;
|
|
document.getElementById("ehr-tool").value = rule.tool_pattern;
|
|
// arg_patterns comes as JSON string from API
|
|
var args = rule.arg_patterns || "[]";
|
|
if (typeof args === "string") {
|
|
try {
|
|
args = JSON.parse(args);
|
|
} catch (e) {
|
|
args = [];
|
|
}
|
|
}
|
|
document.getElementById("ehr-args").value = args.join("\n");
|
|
document.getElementById("ehr-conf").value = rule.confidence;
|
|
document.getElementById("ehr-intent").value = rule.intent_template || "";
|
|
document.getElementById("ehr-reason").value = rule.reasoning_template || "";
|
|
document.getElementById("edit-hr-error").style.display = "none";
|
|
document.getElementById("ehr-submit").disabled = false;
|
|
}
|
|
|
|
function showEditHeuristicRuleModal(ruleId) {
|
|
_ehrTriggerEl = document.activeElement;
|
|
var rule = null;
|
|
for (var i = 0; i < _judgeHeuristicRules.length; i++) {
|
|
if (_judgeHeuristicRules[i].rule_id === ruleId) {
|
|
rule = _judgeHeuristicRules[i];
|
|
break;
|
|
}
|
|
}
|
|
if (!rule) return;
|
|
_populateEditHRModal(rule, !!rule.builtin);
|
|
var ov = document.getElementById("edit-hr-overlay");
|
|
ov.style.display = "flex";
|
|
document.getElementById("ehr-tier").focus();
|
|
_ehrTrapHandler = _installTrap("edit-hr-overlay", "edit-hr-box");
|
|
}
|
|
|
|
function showEditBuiltinHeuristicRuleModal(name) {
|
|
_ehrTriggerEl = document.activeElement;
|
|
var rule = null;
|
|
for (var i = 0; i < _judgeHeuristicRules.length; i++) {
|
|
if (
|
|
_judgeHeuristicRules[i].name === name &&
|
|
!_judgeHeuristicRules[i].rule_id
|
|
) {
|
|
rule = _judgeHeuristicRules[i];
|
|
break;
|
|
}
|
|
}
|
|
if (!rule) return;
|
|
_populateEditHRModal(rule, true);
|
|
var ov = document.getElementById("edit-hr-overlay");
|
|
ov.style.display = "flex";
|
|
document.getElementById("ehr-tier").focus();
|
|
_ehrTrapHandler = _installTrap("edit-hr-overlay", "edit-hr-box");
|
|
}
|
|
|
|
function hideEditHRModal() {
|
|
document.getElementById("edit-hr-overlay").style.display = "none";
|
|
_ehrTrapHandler = _removeTrap(_ehrTrapHandler);
|
|
if (_ehrTriggerEl && _ehrTriggerEl.focus) _ehrTriggerEl.focus();
|
|
_ehrTriggerEl = null;
|
|
}
|
|
|
|
function submitEditHeuristicRule() {
|
|
var errEl = document.getElementById("edit-hr-error");
|
|
errEl.style.display = "none";
|
|
var argsText = document.getElementById("ehr-args").value.trim();
|
|
var argPatterns = argsText
|
|
? argsText.split("\n").filter(function (l) {
|
|
return l.trim();
|
|
})
|
|
: [];
|
|
var ruleId = document.getElementById("ehr-id").value;
|
|
var payload = {
|
|
name: document.getElementById("ehr-name").value.trim(),
|
|
tier: document.getElementById("ehr-tier").value,
|
|
risk_level: document.getElementById("ehr-risk").value,
|
|
recommendation: document.getElementById("ehr-rec").value,
|
|
tool_pattern: document.getElementById("ehr-tool").value.trim(),
|
|
arg_patterns: argPatterns,
|
|
confidence: parseFloat(document.getElementById("ehr-conf").value) || 0.8,
|
|
intent_template: document.getElementById("ehr-intent").value.trim(),
|
|
reasoning_template: document.getElementById("ehr-reason").value.trim(),
|
|
priority: parseInt(document.getElementById("ehr-priority").value, 10) || 0,
|
|
};
|
|
var btn = document.getElementById("ehr-submit");
|
|
btn.disabled = true;
|
|
|
|
var url, method;
|
|
if (ruleId) {
|
|
// Existing DB row — update in place
|
|
url = "/v1/api/admin/judge/heuristic-rules/" + ruleId;
|
|
method = "PUT";
|
|
} else {
|
|
// Pure built-in first edit — create override
|
|
url = "/v1/api/admin/judge/heuristic-rules";
|
|
method = "POST";
|
|
payload.builtin = true;
|
|
payload.enabled = true;
|
|
}
|
|
authFetch(url, {
|
|
method: method,
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
hideEditHRModal();
|
|
showToast(ruleId ? "Rule updated" : "Rule overridden");
|
|
loadJudgeHeuristicRules();
|
|
})
|
|
.catch(function (e) {
|
|
errEl.textContent = e.message;
|
|
errEl.style.display = "";
|
|
})
|
|
.finally(function () {
|
|
btn.disabled = false;
|
|
});
|
|
}
|
|
|
|
// -- Output Guard Patterns section ------------------------------------------
|
|
|
|
function loadJudgeOGPatterns() {
|
|
authFetch("/v1/api/admin/judge/output-guard-patterns")
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error("Failed");
|
|
return r.json();
|
|
})
|
|
.then(function (d) {
|
|
_judgeOGPatterns = d.patterns || [];
|
|
renderOGPatterns();
|
|
})
|
|
.catch(function () {
|
|
document.getElementById("judge-og-table-container").innerHTML =
|
|
'<div class="dashboard-empty">Failed to load patterns</div>';
|
|
});
|
|
}
|
|
|
|
function renderOGPatterns() {
|
|
var c = document.getElementById("judge-og-table-container");
|
|
if (!_judgeOGPatterns.length) {
|
|
c.innerHTML = '<div class="dashboard-empty">No patterns found</div>';
|
|
return;
|
|
}
|
|
var html = "";
|
|
for (var i = 0; i < _judgeOGPatterns.length; i++) {
|
|
var p = _judgeOGPatterns[i];
|
|
var sourceBadge =
|
|
p.source === "builtin"
|
|
? '<span class="scope-badge">built-in</span>'
|
|
: p.source === "builtin-overridden"
|
|
? '<span class="scope-badge scope-channel">modified</span>'
|
|
: p.source === "builtin-disabled"
|
|
? '<span class="scope-badge">built-in</span>'
|
|
: '<span class="scope-badge scope-write">custom</span>';
|
|
var statusBadge = p.enabled
|
|
? '<span class="scope-badge scope-scan-safe">active</span>'
|
|
: '<span class="scope-badge scope-deny">disabled</span>';
|
|
// Note: all dynamic values are escaped via escapeHtml() — safe for innerHTML
|
|
var actions = "";
|
|
var eName = escapeHtml(p.name);
|
|
if (!p.pattern_id) {
|
|
// Pure built-in: Disable + Edit
|
|
actions =
|
|
'<button class="admin-btn-action" data-disable-builtin-ogp="' +
|
|
eName +
|
|
'" aria-label="Disable ' +
|
|
eName +
|
|
'">Disable</button> ' +
|
|
'<button class="admin-btn-action" data-edit-ogp-builtin="' +
|
|
eName +
|
|
'" aria-label="Edit ' +
|
|
eName +
|
|
'">Edit</button>';
|
|
} else if (p.builtin) {
|
|
// Overridden or disabled built-in: Enable/Disable + Edit + Reset
|
|
actions =
|
|
'<button class="admin-btn-action" data-toggle-ogp="' +
|
|
p.pattern_id +
|
|
'" data-enabled="' +
|
|
!p.enabled +
|
|
'" aria-label="' +
|
|
(p.enabled ? "Disable" : "Enable") +
|
|
" " +
|
|
eName +
|
|
'">' +
|
|
(p.enabled ? "Disable" : "Enable") +
|
|
"</button> " +
|
|
'<button class="admin-btn-action" data-edit-ogp="' +
|
|
p.pattern_id +
|
|
'" aria-label="Edit ' +
|
|
eName +
|
|
'">Edit</button> ' +
|
|
'<button class="admin-btn-caution" data-reset-ogp="' +
|
|
p.pattern_id +
|
|
'" aria-label="Reset ' +
|
|
eName +
|
|
'">Reset</button>';
|
|
} else {
|
|
// Custom rule: Enable/Disable + Edit + Delete
|
|
actions =
|
|
'<button class="admin-btn-action" data-toggle-ogp="' +
|
|
p.pattern_id +
|
|
'" data-enabled="' +
|
|
!p.enabled +
|
|
'" aria-label="' +
|
|
(p.enabled ? "Disable" : "Enable") +
|
|
" " +
|
|
eName +
|
|
'">' +
|
|
(p.enabled ? "Disable" : "Enable") +
|
|
"</button> " +
|
|
'<button class="admin-btn-action" data-edit-ogp="' +
|
|
p.pattern_id +
|
|
'" aria-label="Edit ' +
|
|
eName +
|
|
'">Edit</button> ' +
|
|
'<button class="admin-btn-danger" data-delete-ogp="' +
|
|
p.pattern_id +
|
|
'" aria-label="Delete ' +
|
|
eName +
|
|
'">Delete</button>';
|
|
}
|
|
html +=
|
|
'<div class="admin-row" role="listitem">' +
|
|
'<span class="admin-col"><code>' +
|
|
escapeHtml(p.name) +
|
|
"</code></span>" +
|
|
'<span class="admin-col">' +
|
|
escapeHtml(p.category) +
|
|
"</span>" +
|
|
'<span class="admin-col admin-col-ogrisk">' +
|
|
escapeHtml(p.risk_level) +
|
|
"</span>" +
|
|
'<span class="admin-col admin-col-ogflag"><code>' +
|
|
escapeHtml(p.flag_name) +
|
|
"</code></span>" +
|
|
'<span class="admin-col">' +
|
|
sourceBadge +
|
|
"</span>" +
|
|
'<span class="admin-col">' +
|
|
statusBadge +
|
|
"</span>" +
|
|
'<span class="admin-col">' +
|
|
actions +
|
|
"</span></div>";
|
|
}
|
|
c.innerHTML = html;
|
|
// Bind data-attribute event handlers
|
|
c.querySelectorAll("[data-disable-builtin-ogp]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
disableBuiltinOGPattern(this.getAttribute("data-disable-builtin-ogp"));
|
|
});
|
|
});
|
|
c.querySelectorAll("[data-toggle-ogp]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
toggleOGPattern(
|
|
this.getAttribute("data-toggle-ogp"),
|
|
this.getAttribute("data-enabled") === "true",
|
|
);
|
|
});
|
|
});
|
|
c.querySelectorAll("[data-edit-ogp-builtin]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
showEditBuiltinOGPatternModal(this.getAttribute("data-edit-ogp-builtin"));
|
|
});
|
|
});
|
|
c.querySelectorAll("[data-edit-ogp]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
showEditOGPatternModal(this.getAttribute("data-edit-ogp"));
|
|
});
|
|
});
|
|
c.querySelectorAll("[data-reset-ogp]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
resetOGPattern(this.getAttribute("data-reset-ogp"));
|
|
});
|
|
});
|
|
c.querySelectorAll("[data-delete-ogp]").forEach(function (btn) {
|
|
btn.addEventListener("click", function () {
|
|
deleteOGPattern(this.getAttribute("data-delete-ogp"));
|
|
});
|
|
});
|
|
}
|
|
|
|
function toggleOGPattern(patternId, enabled) {
|
|
authFetch("/v1/api/admin/judge/output-guard-patterns/" + patternId, {
|
|
method: "PUT",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ enabled: enabled }),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
showToast(enabled ? "Pattern enabled" : "Pattern disabled");
|
|
loadJudgeOGPatterns();
|
|
})
|
|
.catch(function (e) {
|
|
showToast("Error: " + e.message);
|
|
});
|
|
}
|
|
|
|
function deleteOGPattern(patternId) {
|
|
var patName = "";
|
|
for (var j = 0; j < _judgeOGPatterns.length; j++) {
|
|
if (_judgeOGPatterns[j].pattern_id === patternId) {
|
|
patName = _judgeOGPatterns[j].name;
|
|
break;
|
|
}
|
|
}
|
|
showConfirmModal(
|
|
"Delete Pattern",
|
|
'Delete custom pattern "' + patName + '"? This action cannot be undone.',
|
|
"Delete",
|
|
function () {
|
|
authFetch("/v1/api/admin/judge/output-guard-patterns/" + patternId, {
|
|
method: "DELETE",
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
showToast("Pattern deleted");
|
|
loadJudgeOGPatterns();
|
|
})
|
|
.catch(function (e) {
|
|
showToast("Error: " + e.message);
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
function showCreateOutputGuardPatternModal() {
|
|
_cogpTriggerEl = document.activeElement;
|
|
var ov = document.getElementById("create-ogp-overlay");
|
|
ov.style.display = "flex";
|
|
document.getElementById("ogp-name").value = "";
|
|
document.getElementById("ogp-cat").value = "prompt_injection";
|
|
document.getElementById("ogp-risk").value = "medium";
|
|
document.getElementById("ogp-pattern").value = "";
|
|
document.getElementById("ogp-flag").value = "";
|
|
document.getElementById("ogp-ann").value = "";
|
|
document.getElementById("ogp-flags").value = "";
|
|
document.getElementById("ogp-cred").checked = false;
|
|
document.getElementById("ogp-redact").value = "";
|
|
document.getElementById("ogp-regex-result").textContent = "";
|
|
document.getElementById("create-ogp-error").style.display = "none";
|
|
document.getElementById("ogp-submit").disabled = false;
|
|
document.getElementById("ogp-name").focus();
|
|
_cogpTrapHandler = _installTrap("create-ogp-overlay", "create-ogp-box");
|
|
}
|
|
|
|
function hideCreateOGPModal() {
|
|
document.getElementById("create-ogp-overlay").style.display = "none";
|
|
_cogpTrapHandler = _removeTrap(_cogpTrapHandler);
|
|
if (_cogpTriggerEl && _cogpTriggerEl.focus) _cogpTriggerEl.focus();
|
|
_cogpTriggerEl = null;
|
|
}
|
|
|
|
function validateOGRegex() {
|
|
var pattern = document.getElementById("ogp-pattern").value;
|
|
var resultEl = document.getElementById("ogp-regex-result");
|
|
if (!pattern) {
|
|
resultEl.textContent = "";
|
|
return;
|
|
}
|
|
authFetch("/v1/api/admin/judge/validate-regex", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ pattern: pattern }),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error("Validation failed");
|
|
return r.json();
|
|
})
|
|
.then(function (d) {
|
|
if (d.valid) {
|
|
resultEl.textContent = "Valid";
|
|
resultEl.style.color = "var(--green)";
|
|
} else {
|
|
resultEl.textContent = d.error || "Invalid";
|
|
resultEl.style.color = "var(--red)";
|
|
}
|
|
})
|
|
.catch(function () {
|
|
resultEl.textContent = "Validation failed";
|
|
resultEl.style.color = "var(--red)";
|
|
});
|
|
}
|
|
|
|
function submitCreateOGPattern() {
|
|
var errEl = document.getElementById("create-ogp-error");
|
|
errEl.style.display = "none";
|
|
var payload = {
|
|
name: document.getElementById("ogp-name").value.trim(),
|
|
category: document.getElementById("ogp-cat").value,
|
|
risk_level: document.getElementById("ogp-risk").value,
|
|
pattern: document.getElementById("ogp-pattern").value,
|
|
flag_name: document.getElementById("ogp-flag").value.trim(),
|
|
annotation: document.getElementById("ogp-ann").value.trim(),
|
|
pattern_flags: document.getElementById("ogp-flags").value.trim(),
|
|
is_credential: document.getElementById("ogp-cred").checked,
|
|
redact_label: document.getElementById("ogp-redact").value.trim(),
|
|
enabled: true,
|
|
};
|
|
var btn = document.getElementById("ogp-submit");
|
|
btn.disabled = true;
|
|
authFetch("/v1/api/admin/judge/output-guard-patterns", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
hideCreateOGPModal();
|
|
showToast("Pattern created");
|
|
loadJudgeOGPatterns();
|
|
})
|
|
.catch(function (e) {
|
|
errEl.textContent = e.message;
|
|
errEl.style.display = "";
|
|
})
|
|
.finally(function () {
|
|
btn.disabled = false;
|
|
});
|
|
}
|
|
|
|
// -- Output Guard Pattern: disable / edit / reset ---------------------------
|
|
|
|
function disableBuiltinOGPattern(name) {
|
|
var pat = null;
|
|
for (var i = 0; i < _judgeOGPatterns.length; i++) {
|
|
if (_judgeOGPatterns[i].name === name) {
|
|
pat = _judgeOGPatterns[i];
|
|
break;
|
|
}
|
|
}
|
|
if (!pat) return;
|
|
var payload = {
|
|
name: pat.name,
|
|
category: pat.category,
|
|
risk_level: pat.risk_level,
|
|
pattern: pat.pattern || "",
|
|
flag_name: pat.flag_name,
|
|
annotation: pat.annotation || "",
|
|
pattern_flags: pat.pattern_flags || "",
|
|
is_credential: pat.is_credential || false,
|
|
redact_label: pat.redact_label || "",
|
|
priority: pat.priority || 0,
|
|
builtin: true,
|
|
enabled: false,
|
|
};
|
|
authFetch("/v1/api/admin/judge/output-guard-patterns", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
showToast("Built-in pattern disabled \u2014 Reset to restore defaults");
|
|
loadJudgeOGPatterns();
|
|
})
|
|
.catch(function (e) {
|
|
showToast("Error: " + e.message);
|
|
});
|
|
}
|
|
|
|
function resetOGPattern(patternId) {
|
|
var patName = "";
|
|
for (var j = 0; j < _judgeOGPatterns.length; j++) {
|
|
if (_judgeOGPatterns[j].pattern_id === patternId) {
|
|
patName = _judgeOGPatterns[j].name;
|
|
break;
|
|
}
|
|
}
|
|
showConfirmModal(
|
|
"Reset to Built-in",
|
|
'Reset "' +
|
|
patName +
|
|
'" to its built-in defaults? Your customizations will be removed.',
|
|
"Reset",
|
|
function () {
|
|
authFetch("/v1/api/admin/judge/output-guard-patterns/" + patternId, {
|
|
method: "DELETE",
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
showToast("Pattern reset to built-in defaults");
|
|
loadJudgeOGPatterns();
|
|
})
|
|
.catch(function (e) {
|
|
showToast("Error: " + e.message);
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
function _populateEditOGPModal(pat, isBuiltin) {
|
|
document.getElementById("eogp-id").value = pat.pattern_id || "";
|
|
document.getElementById("eogp-builtin").value = isBuiltin ? "true" : "false";
|
|
document.getElementById("eogp-priority").value = pat.priority || 0;
|
|
document.getElementById("eogp-name").value = pat.name;
|
|
document.getElementById("eogp-name").disabled = isBuiltin;
|
|
document.getElementById("eogp-cat").value = pat.category;
|
|
document.getElementById("eogp-risk").value = pat.risk_level;
|
|
document.getElementById("eogp-pattern").value = pat.pattern || "";
|
|
document.getElementById("eogp-flag").value = pat.flag_name || "";
|
|
document.getElementById("eogp-flag").disabled = isBuiltin;
|
|
document.getElementById("eogp-ann").value = pat.annotation || "";
|
|
document.getElementById("eogp-flags").value = pat.pattern_flags || "";
|
|
document.getElementById("eogp-cred").checked = !!pat.is_credential;
|
|
document.getElementById("eogp-redact").value = pat.redact_label || "";
|
|
document.getElementById("eogp-regex-result").textContent = "";
|
|
document.getElementById("edit-ogp-error").style.display = "none";
|
|
document.getElementById("eogp-submit").disabled = false;
|
|
}
|
|
|
|
function showEditOGPatternModal(patternId) {
|
|
_eogpTriggerEl = document.activeElement;
|
|
var pat = null;
|
|
for (var i = 0; i < _judgeOGPatterns.length; i++) {
|
|
if (_judgeOGPatterns[i].pattern_id === patternId) {
|
|
pat = _judgeOGPatterns[i];
|
|
break;
|
|
}
|
|
}
|
|
if (!pat) return;
|
|
_populateEditOGPModal(pat, !!pat.builtin);
|
|
var ov = document.getElementById("edit-ogp-overlay");
|
|
ov.style.display = "flex";
|
|
document.getElementById("eogp-cat").focus();
|
|
_eogpTrapHandler = _installTrap("edit-ogp-overlay", "edit-ogp-box");
|
|
}
|
|
|
|
function showEditBuiltinOGPatternModal(name) {
|
|
_eogpTriggerEl = document.activeElement;
|
|
var pat = null;
|
|
for (var i = 0; i < _judgeOGPatterns.length; i++) {
|
|
if (_judgeOGPatterns[i].name === name && !_judgeOGPatterns[i].pattern_id) {
|
|
pat = _judgeOGPatterns[i];
|
|
break;
|
|
}
|
|
}
|
|
if (!pat) return;
|
|
_populateEditOGPModal(pat, true);
|
|
var ov = document.getElementById("edit-ogp-overlay");
|
|
ov.style.display = "flex";
|
|
document.getElementById("eogp-cat").focus();
|
|
_eogpTrapHandler = _installTrap("edit-ogp-overlay", "edit-ogp-box");
|
|
}
|
|
|
|
function hideEditOGPModal() {
|
|
document.getElementById("edit-ogp-overlay").style.display = "none";
|
|
_eogpTrapHandler = _removeTrap(_eogpTrapHandler);
|
|
if (_eogpTriggerEl && _eogpTriggerEl.focus) _eogpTriggerEl.focus();
|
|
_eogpTriggerEl = null;
|
|
}
|
|
|
|
function validateEditOGRegex() {
|
|
var pattern = document.getElementById("eogp-pattern").value;
|
|
var resultEl = document.getElementById("eogp-regex-result");
|
|
if (!pattern) {
|
|
resultEl.textContent = "";
|
|
return;
|
|
}
|
|
authFetch("/v1/api/admin/judge/validate-regex", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ pattern: pattern }),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok) throw new Error("Validation failed");
|
|
return r.json();
|
|
})
|
|
.then(function (d) {
|
|
if (d.valid) {
|
|
resultEl.textContent = "Valid";
|
|
resultEl.style.color = "var(--green)";
|
|
} else {
|
|
resultEl.textContent = d.error || "Invalid";
|
|
resultEl.style.color = "var(--red)";
|
|
}
|
|
})
|
|
.catch(function () {
|
|
resultEl.textContent = "Validation failed";
|
|
resultEl.style.color = "var(--red)";
|
|
});
|
|
}
|
|
|
|
function submitEditOGPattern() {
|
|
var errEl = document.getElementById("edit-ogp-error");
|
|
errEl.style.display = "none";
|
|
var patternId = document.getElementById("eogp-id").value;
|
|
var payload = {
|
|
name: document.getElementById("eogp-name").value.trim(),
|
|
category: document.getElementById("eogp-cat").value,
|
|
risk_level: document.getElementById("eogp-risk").value,
|
|
pattern: document.getElementById("eogp-pattern").value,
|
|
flag_name: document.getElementById("eogp-flag").value.trim(),
|
|
annotation: document.getElementById("eogp-ann").value.trim(),
|
|
pattern_flags: document.getElementById("eogp-flags").value.trim(),
|
|
is_credential: document.getElementById("eogp-cred").checked,
|
|
redact_label: document.getElementById("eogp-redact").value.trim(),
|
|
priority: parseInt(document.getElementById("eogp-priority").value, 10) || 0,
|
|
};
|
|
var btn = document.getElementById("eogp-submit");
|
|
btn.disabled = true;
|
|
|
|
var url, method;
|
|
if (patternId) {
|
|
url = "/v1/api/admin/judge/output-guard-patterns/" + patternId;
|
|
method = "PUT";
|
|
} else {
|
|
url = "/v1/api/admin/judge/output-guard-patterns";
|
|
method = "POST";
|
|
payload.builtin = true;
|
|
payload.enabled = true;
|
|
}
|
|
authFetch(url, {
|
|
method: method,
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
})
|
|
.then(function (r) {
|
|
if (!r.ok)
|
|
return r.json().then(function (d) {
|
|
throw new Error(d.error || "Failed");
|
|
});
|
|
return r.json();
|
|
})
|
|
.then(function () {
|
|
hideEditOGPModal();
|
|
showToast(patternId ? "Pattern updated" : "Pattern overridden");
|
|
loadJudgeOGPatterns();
|
|
})
|
|
.catch(function (e) {
|
|
errEl.textContent = e.message;
|
|
errEl.style.display = "";
|
|
})
|
|
.finally(function () {
|
|
btn.disabled = false;
|
|
});
|
|
}
|