use super::features::browser_ui_feature_definitions;
use super::*;
use bucketwarden_browser_ui_e2e::browser_ui_e2e_authenticated_workflows;
use bucketwarden_browser_ui_shell::{
browser_ui_accessibility_checks, browser_ui_api_endpoints, browser_ui_responsive_breakpoints,
browser_ui_routes, browser_ui_security_policy,
};
pub fn browser_ui_manifest_json() -> String {
let mut manifest_bytes = 0;
let mut json = String::new();
for _ in 0..4 {
json = serde_json::to_string(&browser_ui_manifest_report(manifest_bytes))
.expect("browser UI manifest is serializable");
if json.len() == manifest_bytes {
break;
}
manifest_bytes = json.len();
}
json
}
fn browser_ui_manifest_report(manifest_bytes: usize) -> BrowserUiProductReport {
BrowserUiProductReport {
boundary_id: BROWSER_UI_BOUNDARY_ID.to_string(),
claim_tier: BROWSER_UI_CLAIM_TIER.to_string(),
implementation_status: "implemented".to_string(),
generated_at_epoch_seconds: 0,
features: browser_ui_feature_statuses(),
routes: browser_ui_routes(),
api_endpoints: browser_ui_api_endpoints(),
assets: browser_ui_assets_with_manifest_bytes(manifest_bytes),
security: browser_ui_security_policy(),
accessibility_checks: browser_ui_accessibility_checks(),
responsive_breakpoints: browser_ui_responsive_breakpoints(),
preference_keys: vec![
"bucketwarden.ui.density".to_string(),
"bucketwarden.ui.visibleColumns".to_string(),
"bucketwarden.ui.reportFilters".to_string(),
"bucketwarden.ui.refreshSeconds".to_string(),
],
export_actions: vec![
"download health report JSON".to_string(),
"download config report JSON".to_string(),
"download evidence export JSON".to_string(),
"download audit log JSONL".to_string(),
],
e2e_workflows: browser_ui_product_e2e_workflows(),
}
}
pub(super) fn browser_ui_product_e2e_workflows() -> Vec<String> {
let mut workflows = vec!["login -> overview -> health report -> logout".to_string()];
workflows.extend(browser_ui_e2e_authenticated_workflows());
workflows.extend([
"login failure -> auth error state -> retry".to_string(),
"responsive navigation and keyboard traversal".to_string(),
"malformed response -> visible error state".to_string(),
]);
workflows
}
pub fn browser_ui_html() -> String {
browser_ui_html_for_base("/ui")
}
pub fn browser_ui_v1_html() -> String {
browser_ui_html_for_base("/ui/v1")
}
fn browser_ui_html_for_base(ui_base: &str) -> String {
format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'self'">
<title>BucketWarden Console</title>
<link rel="icon" type="image/png" href="{ui_base}/assets/favicon.png">
<link rel="shortcut icon" type="image/png" href="{ui_base}/assets/favicon.png">
<link rel="apple-touch-icon" href="{ui_base}/assets/bucketwarden-monogram.png">
<link rel="stylesheet" href="{ui_base}/assets/app.css">
</head>
<body>
<div id="app" data-boundary="{boundary}" data-claim-tier="{tier}" data-ui-feature="feat:bucketwarden.ui.browser.format.bytes-humanized feat:bucketwarden.ui.browser.format.numbers-commas">
<aside class="shell-nav" aria-label="Primary">
<a class="brand" href="{ui_base}" aria-label="BucketWarden overview">
<img src="{ui_base}/assets/bucketwarden-monogram.png" alt="" width="42" height="42">
<span>BucketWarden</span>
</a>
<p class="principal" data-current-principal data-ui-feature="feat:bucketwarden.ui.browser.auth.current-user">Signed out</p>
<nav data-ui-feature="feat:bucketwarden.ui.browser.sidebar-order">{nav}</nav>
<button type="button" data-action="logout" data-ui-feature="feat:bucketwarden.ui.browser.auth.logout">Sign out</button>
</aside>
<main>
<header class="topbar" data-ui-feature="feat:bucketwarden.ui.browser.topbar-context-order">
<div>
<p class="route-kicker">BucketWarden Console</p>
<h1 data-route-title>Overview</h1>
<p class="route-summary" data-route-summary>Runtime status, reports, buckets, governance, audit, evidence, and settings.</p>
<div class="context-strip" aria-label="Current context" data-ui-feature="feat:bucketwarden.ui.browser.context-chips.ordered-conditional">
<span class="context-chip" data-context-session data-ui-feature="feat:bucketwarden.ui.browser.auth.current-user">Session: signed out</span>
<span class="context-chip" data-context-tenant>Tenant: default</span>
<span class="context-chip" data-context-bucket hidden>Bucket: none</span>
<span class="context-chip" data-context-object hidden>Object: none</span>
</div>
</div>
<div class="refresh-controls" data-ui-feature="feat:bucketwarden.ui.browser.refresh-realtime">
<button type="button" data-action="refresh">Refresh</button>
<label for="refresh-interval">Auto refresh</label>
<select id="refresh-interval" data-refresh-interval aria-label="Auto refresh interval">
<option value="0">Off</option>
<option value="15">15s</option>
<option value="30" selected>30s</option>
<option value="60">60s</option>
</select>
</div>
</header>
<form class="action-filter-bar" data-form="global-filter" data-ui-feature="feat:bucketwarden.ui.browser.action-filter-bar feat:bucketwarden.ui.browser.action-filter-bar.enhanced feat:bucketwarden.ui.browser.action-filter-bar.scoped-state feat:bucketwarden.ui.browser.action-filter-bar.clear-reset feat:bucketwarden.ui.browser.search.route-scoped-visibility" aria-label="Search, filter, and sort current view">
<label class="field-control" for="global-q"><span>Search</span><input id="global-q" name="q" placeholder="Search current view"></label>
<label class="field-control" for="global-prefix"><span>Prefix</span><input id="global-prefix" name="prefix" placeholder="Folder prefix"></label>
<label class="field-control" for="global-status"><span>Status</span><select id="global-status" name="status">
<option value="all">All</option>
<option value="allowed">Allowed</option>
<option value="denied">Denied</option>
<option value="failed">Failed</option>
</select></label>
<label class="field-control" for="global-sort"><span>Sort</span><select id="global-sort" name="sort">
<option value="-sequence">Newest first</option>
<option value="sequence">Oldest first</option>
<option value="name">Name</option>
<option value="key">Object key</option>
</select></label>
<span class="filter-scope" data-filter-scope>Scope: current view</span>
<button type="submit">Apply</button>
<button type="button" class="secondary-action" data-action="clear-filters">Clear</button>
</form>
<div class="content-inspector" data-ui-feature="feat:bucketwarden.ui.browser.visual-layout-ia feat:bucketwarden.ui.browser.shell.route-chrome-rules feat:bucketwarden.ui.browser.route-active-content-isolation">
<div>
<section id="login" class="panel" data-route-panel="login" data-ui-feature="feat:bucketwarden.ui.browser.auth.login" aria-labelledby="login-title">
<img class="login-mark" src="{ui_base}/assets/bucketwarden-monogram.png" alt="" width="88" height="88">
<h2 id="login-title">Operator Login</h2>
<div class="session-card" data-view="login-authenticated" data-ui-feature="feat:bucketwarden.ui.browser.login.authenticated-session-state" hidden>
<p data-view="login-session-summary">Signed in.</p>
<button type="button" data-action="login-continue">Continue to overview</button>
<button type="button" class="secondary-action" data-action="login-signout">Sign out</button>
</div>
<form data-form="login" data-ui-feature="feat:bucketwarden.ui.browser.login-submit feat:bucketwarden.ui.browser.login.error-state-polish">
<label for="principal">Principal</label>
<input id="principal" name="principal" autocomplete="username" required>
<label for="identity-provider" data-ui-feature="feat:bucketwarden.ui.browser.identity-provider-selection">Identity Provider</label>
<select id="identity-provider" name="identity_provider" data-identity-provider>
<option value="custom-shared-secret">Shared secret</option>
</select>
<label for="secret">Shared Secret</label>
<input id="secret" name="secret" type="password" autocomplete="current-password" required>
<button type="submit">Sign in</button>
<p class="login-status" role="status" aria-live="polite" data-auth-state>Unauthenticated</p>
</form>
</section>
<section id="overview" class="dashboard overview-kpi-dashboard" data-route-panel="overview" aria-label="Operational overview">
<article class="panel overview-summary" data-ui-feature="feat:bucketwarden.ui.browser.overview-dashboard feat:bucketwarden.ui.browser.overview.kpi-summary-only"><h2>Runtime KPIs</h2><dl class="kpi-grid" data-view="overview"></dl></article>
</section>
<section class="panel reports-workspace" data-route-panel="reports" data-ui-feature="feat:bucketwarden.ui.browser.reports-dashboard feat:bucketwarden.ui.browser.reports-diagnostics.boundary feat:bucketwarden.ui.browser.reports.category-workspace feat:bucketwarden.ui.browser.reports.freshness-actions feat:bucketwarden.ui.browser.reports.actionable-index feat:bucketwarden.ui.browser.reports.index-detail-separation"><h2>Reports</h2><div class="report-index-region" data-view="report-index"></div><div class="report-detail-grid" data-view="report-detail-region"><section class="report-detail-panel"><h3>Health</h3><dl data-view="route-report" data-ui-feature="feat:bucketwarden.ui.browser.health-report-view"></dl></section><section class="report-detail-panel"><h3>Configuration</h3><dl data-view="route-config" data-ui-feature="feat:bucketwarden.ui.browser.configuration-report-view feat:bucketwarden.ui.browser.reports.config"></dl></section><section class="report-detail-panel"><h3>Storage</h3><dl data-view="route-storage" data-ui-feature="feat:bucketwarden.ui.browser.reports.storage"></dl></section><section class="report-detail-panel"><h3>Governance</h3><dl data-view="route-report-governance" data-ui-feature="feat:bucketwarden.ui.browser.reports.governance"></dl></section><section class="report-detail-panel"><h3>Incident</h3><dl data-view="route-incident" data-ui-feature="feat:bucketwarden.ui.browser.incident-report-view feat:bucketwarden.ui.browser.reports.incident feat:bucketwarden.ui.browser.reports.detail-region feat:bucketwarden.ui.browser.reports.staleness-indicator"></dl></section></div></section>
<section class="panel bucket-workspace" data-route-panel="buckets" data-has-selection="false" data-ui-feature="feat:bucketwarden.ui.browser.bucket-object-explorer feat:bucketwarden.ui.browser.bucket-list-view feat:bucketwarden.ui.browser.buckets.tabs feat:bucketwarden.ui.browser.bucket-list.full-page feat:bucketwarden.ui.browser.bucket-detail.full-page feat:bucketwarden.ui.browser.bucket-detail.route-state-separation feat:bucketwarden.ui.browser.object-detail.route-state-separation feat:bucketwarden.ui.browser.bucket-explorer.stable-file-browser-layout feat:bucketwarden.ui.browser.file-explorer.professional-affordances feat:bucketwarden.ui.browser.selection.route-reset feat:bucketwarden.ui.browser.empty-state.resource-panels feat:bucketwarden.ui.browser.empty-state.bucket-object-version"><h2>Buckets</h2><div class="explorer-shell"><section class="explorer-region bucket-collection-region" data-view="bucket-collection" aria-label="Bucket list"><h3>Bucket list</h3><div data-view="route-buckets"></div></section><section class="explorer-region bucket-detail-workspace" data-view="bucket-detail-workspace" aria-label="Bucket workspace"><div class="bucket-tabs" role="tablist" aria-label="Bucket detail tabs" data-ui-feature="feat:bucketwarden.ui.browser.bucket-tabs.semantic-tablist"><button id="bucket-tab-files" type="button" role="tab" data-bucket-tab="files" aria-controls="bucket-panel-files" data-ui-feature="feat:bucketwarden.ui.browser.bucket-detail.file-explorer-tab">File explorer</button><button id="bucket-tab-governance" type="button" role="tab" data-bucket-tab="governance" aria-controls="bucket-panel-governance" data-ui-feature="feat:bucketwarden.ui.browser.bucket-detail.governance-policy-tab">Governance and policy</button></div><section id="bucket-panel-files" data-bucket-panel="files" role="tabpanel" aria-labelledby="bucket-tab-files"><h3>Bucket detail</h3><dl data-view="bucket-detail" data-ui-feature="feat:bucketwarden.ui.browser.bucket-detail-view feat:bucketwarden.ui.browser.buckets.detail feat:bucketwarden.ui.browser.bucket-detail.route-state-separation"></dl><h3>File explorer</h3><nav class="breadcrumbs" data-view="file-breadcrumbs" data-ui-feature="feat:bucketwarden.ui.browser.file-explorer.breadcrumbs feat:bucketwarden.ui.browser.file-explorer.path-breadcrumb feat:bucketwarden.ui.browser.file-explorer.professional-affordances" aria-label="Object prefix breadcrumbs"></nav><div class="file-explorer-grid"><div data-view="file-tree" data-ui-feature="feat:bucketwarden.ui.browser.file-explorer.tree feat:bucketwarden.ui.browser.file-explorer.collapsible-tree feat:bucketwarden.ui.browser.file-explorer.icons feat:bucketwarden.ui.browser.file-explorer.professional-affordances"></div><div data-view="object-list" data-ui-feature="feat:bucketwarden.ui.browser.object-list-view feat:bucketwarden.ui.browser.objects.table feat:bucketwarden.ui.browser.objects.prefix-tree feat:bucketwarden.ui.browser.objects.breadcrumbs feat:bucketwarden.ui.browser.objects.detail-drawer feat:bucketwarden.ui.browser.objects.metadata feat:bucketwarden.ui.browser.file-explorer.object-table feat:bucketwarden.ui.browser.file-explorer.icons feat:bucketwarden.ui.browser.file-explorer.professional-affordances"></div></div><h3>Versions</h3><div data-view="version-history" data-ui-feature="feat:bucketwarden.ui.browser.object-version-history-view feat:bucketwarden.ui.browser.versions.table feat:bucketwarden.ui.browser.versions.latest-marker feat:bucketwarden.ui.browser.versions.delete-markers feat:bucketwarden.ui.browser.object-detail.route-state-separation"></div><div data-view="version-actions" data-ui-feature="feat:bucketwarden.ui.browser.versions.download feat:bucketwarden.ui.browser.versions.restore-copy feat:bucketwarden.ui.browser.object-detail.download feat:bucketwarden.ui.browser.object-detail.crud-actions feat:bucketwarden.ui.browser.governance.object-crud-gates"></div><h3>Object detail</h3><dl data-view="object-detail" data-ui-feature="feat:bucketwarden.ui.browser.object-detail.full-page feat:bucketwarden.ui.browser.object-detail.metadata feat:bucketwarden.ui.browser.object-detail.governance feat:bucketwarden.ui.browser.object-detail.route-state-separation feat:bucketwarden.ui.browser.governance.object-crud-gates"></dl><h3>Preview</h3><div data-view="object-preview" data-ui-feature="feat:bucketwarden.ui.browser.object-detail.preview"></div></section><section id="bucket-panel-governance" data-bucket-panel="governance" role="tabpanel" aria-labelledby="bucket-tab-governance"><h3>Bucket governance and policy</h3><dl data-view="bucket-governance" data-ui-feature="feat:bucketwarden.ui.browser.governance.bucket-versioning feat:bucketwarden.ui.browser.governance.bucket-object-lock feat:bucketwarden.ui.browser.governance.bucket-retention-defaults feat:bucketwarden.ui.browser.governance.lifecycle-rules feat:bucketwarden.ui.browser.governance.policy-acl-summary feat:bucketwarden.ui.browser.bucket-detail.governance-policy-tab feat:bucketwarden.ui.browser.governance.bucket-crud-gates"></dl><div class="governance-gates" data-view="bucket-governance-gates" data-ui-feature="feat:bucketwarden.ui.browser.governance.bucket-crud-gates feat:bucketwarden.ui.browser.governance.gated-action-visual-state"></div></section></section></div></section>
<section class="panel governance-workspace" data-route-panel="governance" data-ui-feature="feat:bucketwarden.ui.browser.governance.scope-separated-route feat:bucketwarden.ui.browser.global-governance.dashboard feat:bucketwarden.ui.browser.global-governance.findings feat:bucketwarden.ui.browser.governance.structured-findings-gates feat:bucketwarden.ui.browser.governance.global-crud-gates feat:bucketwarden.ui.browser.governance.global-policy-crud-gates feat:bucketwarden.ui.browser.governance.global-retention-posture feat:bucketwarden.ui.browser.governance.global-legal-hold-posture feat:bucketwarden.ui.browser.governance.bucket-scope-exclusion feat:bucketwarden.ui.browser.governance.object-scope-exclusion feat:bucketwarden.ui.browser.governance.gated-action-visual-state feat:bucketwarden.ui.browser.tenant-governance.views feat:bucketwarden.ui.browser.empty-state.resource-panels feat:bucketwarden.ui.browser.empty-state.governance-records"><h2>Global Governance</h2><p class="route-note">Global and tenant-level governance only. Bucket and object governance remain on their detail pages.</p><p class="route-note" data-view="bucket-governance-remediation" data-ui-feature="feat:bucketwarden.ui.browser.governance.bucket-scope-exclusion">Bucket governance is bucket-scoped. Open a bucket detail governance and policy tab for bucket policy, lifecycle, ACL, object-lock, and retention-default actions.</p><p class="route-note" data-view="object-governance-remediation" data-ui-feature="feat:bucketwarden.ui.browser.governance.object-scope-exclusion">Object governance is object-scoped. Open an object detail workflow for version retention, legal hold, delete-marker, and object policy actions.</p><dl data-view="global-governance-dashboard" data-ui-feature="feat:bucketwarden.ui.browser.global-governance.dashboard"></dl><h3>Tenant policy controls</h3><p class="route-note" data-view="global-policy-mode" data-ui-feature="feat:bucketwarden.ui.browser.governance.global-policy-crud-gates">Read-only report mode: global policy mutation requires a runtime tenant governance API.</p><div class="governance-gates" data-view="global-governance-gates" data-ui-feature="feat:bucketwarden.ui.browser.governance.global-crud-gates feat:bucketwarden.ui.browser.governance.global-policy-crud-gates feat:bucketwarden.ui.browser.governance.gated-action-visual-state"></div><p role="status" data-view="global-governance-action"></p><h3>Tenant retention posture</h3><p class="route-note" data-view="global-retention-remediation" data-ui-feature="feat:bucketwarden.ui.browser.governance.global-retention-posture">Retention remediation is bucket-scoped. Open a bucket detail governance tab to change defaults where supported.</p><dl data-view="global-retention-summary" data-ui-feature="feat:bucketwarden.ui.browser.tenant-governance.views feat:bucketwarden.ui.browser.governance.global-retention-posture"></dl><h3>Tenant legal hold posture</h3><p class="route-note" data-view="global-legal-hold-remediation" data-ui-feature="feat:bucketwarden.ui.browser.governance.global-legal-hold-posture">Legal-hold remediation is object-scoped. Open an object detail governance workflow to apply or lift holds.</p><dl data-view="global-legal-hold-summary" data-ui-feature="feat:bucketwarden.ui.browser.tenant-governance.views feat:bucketwarden.ui.browser.governance.global-legal-hold-posture"></dl><h3>Findings</h3><div data-view="global-governance-findings" data-ui-feature="feat:bucketwarden.ui.browser.global-governance.findings"></div></section>
<section class="panel" data-route-panel="audit" data-ui-feature="feat:bucketwarden.ui.browser.audit-log-view feat:bucketwarden.ui.browser.audit-search-filter-sort feat:bucketwarden.ui.browser.audit.single-filter-source"><h2>Audit</h2><p class="route-note">Use the route filter bar above to search, filter by outcome, and sort audit events.</p><div data-view="route-audit" data-ui-feature="feat:bucketwarden.ui.browser.audit.table-layout"></div></section>
<section class="panel evidence-workspace" data-route-panel="evidence" data-ui-feature="feat:bucketwarden.ui.browser.evidence-view feat:bucketwarden.ui.browser.evidence.list feat:bucketwarden.ui.browser.evidence.detail-preview feat:bucketwarden.ui.browser.evidence.selection-detail-readability"><h2>Evidence</h2><div class="page-actions"><button type="button" data-action="download-evidence" data-ui-feature="feat:bucketwarden.ui.browser.evidence-export-download feat:bucketwarden.ui.browser.evidence.export">Download export</button><p role="status" data-view="evidence-download"></p></div><div class="detail-grid evidence-detail-grid"><div data-view="route-evidence" data-ui-feature="feat:bucketwarden.ui.browser.evidence.table-layout feat:bucketwarden.ui.browser.evidence.selection-detail-readability"></div><aside class="detail-panel" data-view="evidence-detail" aria-label="Evidence detail" data-ui-feature="feat:bucketwarden.ui.browser.evidence.selection-detail-readability"></aside></div></section>
<section class="panel settings-workspace" data-route-panel="settings" data-ui-feature="feat:bucketwarden.ui.browser.preferences-view feat:bucketwarden.ui.browser.preferences.user-settings feat:bucketwarden.ui.browser.settings.admin-settings feat:bucketwarden.ui.browser.settings.sectioned-management feat:bucketwarden.ui.browser.settings.ia-section-separation feat:bucketwarden.ui.browser.empty-state.resource-panels feat:bucketwarden.ui.browser.empty-state.admin-tenants-users-roles"><h2>Settings</h2><div class="settings-grid"><section class="management-section preferences-section" data-settings-section="preferences"><h3>Preferences</h3><form class="toolbar form-grid" data-form="preferences" data-ui-feature="feat:bucketwarden.ui.browser.preferences-persistence feat:bucketwarden.ui.browser.preferences.user-settings"><label for="pref-density">Density</label><input id="pref-density" name="bucketwarden.ui.density"><label for="pref-refresh">Refresh seconds</label><input id="pref-refresh" name="bucketwarden.ui.refreshSeconds" inputmode="numeric"><label for="pref-columns">Visible columns</label><input id="pref-columns" name="bucketwarden.ui.visibleColumns"><label for="pref-filters">Report filters</label><input id="pref-filters" name="bucketwarden.ui.reportFilters"><button type="submit">Save</button></form><p role="status" data-view="preferences-save"></p><dl class="preference-summary" data-view="route-preferences"></dl></section><section class="management-section tenant-section" data-settings-section="tenant"><h3 data-ui-feature="feat:bucketwarden.ui.browser.tenants.selector feat:bucketwarden.ui.browser.tenant-management.views">Tenant scope</h3><dl data-view="tenant-scope" data-ui-feature="feat:bucketwarden.ui.browser.tenants.scoped-requests feat:bucketwarden.ui.browser.tenant-governance.views"></dl></section><section class="management-section identity-section" data-settings-section="identity"><h3>Users</h3><div data-view="admin-users" data-ui-feature="feat:bucketwarden.ui.browser.admin.users-list feat:bucketwarden.ui.browser.user-management.humans"></div><h3>User detail</h3><dl data-view="admin-user-detail" data-ui-feature="feat:bucketwarden.ui.browser.admin.user-detail feat:bucketwarden.ui.browser.user-management.humans"></dl></section><section class="management-section access-section" data-settings-section="access"><h3>Roles and permissions</h3><div data-view="admin-roles" data-ui-feature="feat:bucketwarden.ui.browser.admin.roles-list feat:bucketwarden.ui.browser.role-management.views"></div><h3>Role assignments</h3><div data-view="admin-role-assignments" data-ui-feature="feat:bucketwarden.ui.browser.admin.role-assignments feat:bucketwarden.ui.browser.role-management.views"></div><h3>Effective permissions</h3><div data-view="admin-effective-permissions" data-ui-feature="feat:bucketwarden.ui.browser.admin.effective-permissions feat:bucketwarden.ui.browser.permission-management.views"></div><h3>Groups</h3><div data-view="admin-groups" data-ui-feature="feat:bucketwarden.ui.browser.group-management.views"></div></section></div></section>
<section class="state-panel" aria-live="polite" role="status" data-ui-feature="feat:bucketwarden.ui.browser.auth.route-guards feat:bucketwarden.ui.browser.status.blank-strip-suppression">
<span data-loading hidden>Loading</span>
<span data-empty hidden>No records</span>
<span data-denied data-ui-feature="feat:bucketwarden.ui.browser.auth.permission-denied" hidden>Permission denied</span>
<span data-error data-ui-feature="feat:bucketwarden.ui.browser.auth.session-expiry" hidden></span>
<span data-disabled-action-reason data-ui-feature="feat:bucketwarden.ui.browser.auth.disabled-action-reasons" hidden></span>
</section>
</div>
<aside class="secondary-inspector" aria-label="Selection inspector" data-inspector-state="open" data-ui-feature="feat:bucketwarden.ui.browser.secondary-inspector-drawer feat:bucketwarden.ui.browser.secondary-inspector.visibility-rules feat:bucketwarden.ui.browser.secondary-inspector.collapse-expand feat:bucketwarden.ui.browser.secondary-inspector.row-toggle feat:bucketwarden.ui.browser.secondary-inspector.readable-layout">
<div class="inspector-head"><h2>Inspector</h2><button type="button" class="secondary-action" data-action="toggle-inspector" aria-expanded="true">Collapse</button></div>
<dl class="inspector-list" data-view="selection-inspector"></dl>
</aside>
</div>
</main>
</div>
<script src="{ui_base}/assets/app.js"></script>
</body>
</html>"#,
boundary = BROWSER_UI_BOUNDARY_ID,
tier = BROWSER_UI_CLAIM_TIER,
ui_base = ui_base,
nav = browser_ui_routes()
.into_iter()
.filter(|route| !route.route.starts_with("/ui/v1"))
.map(|route| format!(
r#"<a href="{href}" data-ui-feature="{feature}">{label}</a>"#,
href = escape_html(&route.route.replacen("/ui", ui_base, 1)),
feature = escape_html(&route.feature_id),
label = escape_html(&route.label)
))
.collect::<Vec<_>>()
.join("")
)
}
pub fn browser_ui_css() -> &'static str {
r#":root{color-scheme:light;--color-bg-app:#f4f7f9;--color-bg-surface:#fff;--color-bg-sidebar:#13232d;--color-text-primary:#17212b;--color-text-muted:#5c6670;--color-border-subtle:#d8dde3;--color-action-primary:#176b87;--color-status-success:#247a4b;--color-status-danger:#a53333;--space-2:8px;--space-3:12px;--space-4:16px;--radius-1:4px;--radius-2:8px;--font-size-sm:14px;--table-row-height:40px;--focus-ring:3px solid #88c8dc;font-family:Inter,Segoe UI,Arial,sans-serif}*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0;color:var(--color-text-primary);background:var(--color-bg-app)}#app{min-height:100vh;display:grid;grid-template-columns:256px 1fr}.shell-nav{background:var(--color-bg-sidebar);color:#fff;padding:20px}.brand{display:flex;align-items:center;gap:10px;color:#fff;text-decoration:none;font-size:20px;font-weight:800;line-height:1;margin:0 0 10px}.brand img{width:42px;height:42px;object-fit:contain}.login-mark{width:74px;height:74px;object-fit:contain;display:block;margin:0 0 14px}.principal{color:#c3dbe4;margin:0 0 18px}.shell-nav nav{display:grid;gap:6px}.shell-nav a{color:#dcecf2;text-decoration:none;padding:9px 10px;border-radius:var(--radius-2)}.shell-nav a.is-active,.shell-nav a:focus,.shell-nav a:hover{outline:2px solid #9bd6e8;background:#203744}main{padding:24px;display:grid;gap:18px;align-content:start}.topbar{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:14px;align-items:start}.route-kicker{margin:0;color:var(--color-text-muted);font-size:var(--font-size-sm);font-weight:700}.topbar h1{font-size:28px;line-height:1.15;margin:2px 0 4px}.route-summary,.muted,.route-note{margin:0;color:var(--color-text-muted)}.context-strip{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px}.context-chip{border:1px solid var(--color-border-subtle);border-radius:var(--radius-2);background:#fff;padding:6px 8px;font-size:var(--font-size-sm);font-weight:700}.refresh-controls{display:grid;grid-template-columns:auto minmax(96px,130px);gap:8px;align-items:end}.action-filter-bar{display:grid;grid-template-columns:minmax(180px,1.3fr) minmax(140px,1fr) minmax(120px,.8fr) minmax(140px,.9fr) minmax(120px,.7fr) auto auto;gap:10px;align-items:end;background:#fff;border:1px solid var(--color-border-subtle);border-radius:var(--radius-2);padding:12px}.field-control{display:grid;gap:5px;margin:0}.field-control span{font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:var(--color-text-muted)}.dashboard{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.panel,.state-panel{background:var(--color-bg-surface);border:1px solid var(--color-border-subtle);border-radius:var(--radius-2);padding:var(--space-4)}.panel[data-route-panel=login]{max-width:560px;width:100%;margin:28px auto 0;padding:28px}.panel h2{font-size:18px;margin:0 0 10px}.toolbar{display:grid;grid-template-columns:repeat(3,minmax(140px,1fr)) auto;gap:10px;align-items:end;margin-bottom:12px}.form-grid{grid-template-columns:repeat(2,minmax(180px,1fr)) auto}.explorer-shell{display:grid;grid-template-columns:minmax(260px,320px) minmax(520px,1fr);gap:16px;align-items:start;overflow-x:auto}.file-explorer-grid{display:grid;grid-template-columns:minmax(240px,300px) minmax(440px,1fr);gap:12px;overflow-x:auto}.bucket-tabs{display:flex;gap:0;margin:8px 0 12px;border-bottom:1px solid var(--color-border-subtle)}.bucket-tabs [role=tab]{appearance:none;min-height:40px;margin:0 0 -1px;background:#f8fafb;color:var(--color-text-muted);border:1px solid transparent;border-bottom:0;border-radius:6px 6px 0 0;padding:9px 14px}.bucket-tabs [role=tab]:hover{background:#eef4f7}.bucket-tabs [aria-selected=true]{background:#fff;border-color:var(--color-border-subtle);box-shadow:inset 0 3px 0 var(--color-action-primary);color:var(--color-text-primary);font-weight:800}.breadcrumbs{display:flex;align-items:center;gap:4px;flex-wrap:wrap;margin:8px 0 12px;border:1px solid var(--color-border-subtle);border-radius:var(--radius-2);background:#fbfcfd;padding:7px 10px}.breadcrumb-item{margin:0;border:0;background:transparent;color:var(--color-action-primary);padding:4px 2px;font-weight:700}.breadcrumb-item[aria-current=page]{color:var(--color-text-primary);cursor:default}.breadcrumb-separator{color:var(--color-text-muted)}.content-inspector{display:grid;grid-template-columns:minmax(0,1fr) minmax(320px,380px);gap:18px}body[data-inspector-visible=false] .content-inspector{grid-template-columns:1fr}body[data-inspector-visible=false] .secondary-inspector{display:none!important}.secondary-inspector{position:sticky;top:18px;align-self:start;background:#fbfcfd;border:1px solid var(--color-border-subtle);border-radius:var(--radius-2);padding:var(--space-4);min-width:0}.secondary-inspector[hidden]{display:none!important}.secondary-inspector.is-collapsed .inspector-list{display:none}.secondary-inspector.is-collapsed{max-height:72px;overflow:hidden}.inspector-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.inspector-list{grid-template-columns:minmax(92px,120px) minmax(0,1fr);align-items:start}.inspector-list dd{font-weight:700;line-height:1.35}.explorer{border:1px solid var(--color-border-subtle);border-radius:var(--radius-2);background:#fbfcfd;min-height:220px;padding:8px;overflow:auto}.explorer ul{list-style:none;margin:0;padding:0}.explorer li{margin:2px 0}.explorer button{width:100%;margin:0;background:transparent;color:var(--color-text-primary);text-align:left;border:1px solid transparent;border-radius:var(--radius-1);font-weight:600;cursor:pointer}.explorer button:hover,.explorer button:focus{background:#eaf3f6;border-color:#9bd6e8}.explorer button.is-selected,.explorer button[aria-pressed=true]{background:#dcecf2;border-color:var(--color-action-primary);box-shadow:inset 3px 0 0 var(--color-action-primary)}.entity-button{display:grid;grid-template-columns:22px minmax(0,1fr) auto;gap:6px;align-items:center}.entity-icon{font-size:16px;text-align:center}.entity-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}.entity-meta{grid-column:3;color:var(--color-text-muted);font-size:var(--font-size-sm);font-weight:600;white-space:nowrap}.bucket-list .entity-icon{color:#176b87}.file-tree .entity-icon{color:#a35a00}.object-table .entity-icon{color:#247a4b}.governance-gates{display:flex;gap:8px;flex-wrap:wrap;margin:10px 0 14px}.gate-control{display:grid;gap:4px}.gate-note{font-size:12px;color:var(--color-text-muted)}.secondary-action.is-gated{background:#f3f5f6;color:var(--color-text-muted);border-style:dashed}.gate-control[data-gate-state=read-only]{opacity:.95}.bucket-workspace[data-has-selection=false] .bucket-detail-workspace{display:none}.bucket-workspace[data-has-selection=true] .bucket-detail-workspace{display:block}.report-detail-grid{display:grid;grid-template-columns:repeat(2,minmax(260px,1fr));gap:12px}.report-detail-panel{border:1px solid var(--color-border-subtle);border-radius:var(--radius-2);background:#fbfcfd;padding:12px}.login-status{border-left:3px solid var(--color-border-subtle);padding-left:8px}.evidence-detail-grid table tr{cursor:pointer}.evidence-detail-grid table tr[aria-selected=true]{background:#eaf3f6}.empty-state{border:1px dashed var(--color-border-subtle);border-radius:var(--radius-2);color:var(--color-text-muted);padding:14px;background:#fbfcfd}.session-card,.detail-panel,.management-section,.report-card{border:1px solid var(--color-border-subtle);border-radius:var(--radius-2);background:#fbfcfd;padding:12px}.report-index-grid{display:grid;grid-template-columns:repeat(2,minmax(260px,1fr));gap:12px;margin-bottom:14px}.report-card h3{margin:0 0 4px}.report-card code{display:block;margin:8px 0;color:var(--color-text-muted);overflow:hidden;text-overflow:ellipsis}.page-actions{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:12px}.detail-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,360px);gap:14px;align-items:start}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(280px,1fr));gap:14px;align-items:start}.management-section h3{margin-top:0}.preference-summary{grid-template-columns:minmax(260px,1fr) minmax(120px,220px)}.preview-panel{border:1px solid var(--color-border-subtle);border-radius:var(--radius-2);background:#fbfcfd;padding:12px;color:var(--color-text-muted);white-space:pre-wrap}.state-panel{display:none}.state-panel:has([data-loading]:not([hidden])),.state-panel:has([data-empty]:not([hidden])),.state-panel:has([data-denied]:not([hidden])),.state-panel:has([data-error]:not(:empty)),.state-panel:has([data-disabled-action-reason]:not([hidden])){display:block}dl{display:grid;grid-template-columns:minmax(120px,180px) 1fr;gap:6px 12px}dt{color:var(--color-text-muted)}dd{margin:0;font-weight:700;overflow-wrap:anywhere}table{width:100%;border-collapse:collapse;table-layout:fixed}th,td{border-bottom:1px solid var(--color-border-subtle);min-height:var(--table-row-height);padding:8px;text-align:left;vertical-align:top;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}label{display:block;font-weight:600;margin-top:10px}input,select{width:100%;min-height:38px;border:1px solid var(--color-border-subtle);border-radius:6px;padding:8px;background:#fff}button{margin-top:12px;min-height:38px;border:0;border-radius:6px;background:var(--color-action-primary);color:#fff;padding:8px 14px;font-weight:700}.secondary-action{border:1px solid var(--color-border-subtle);background:#fff;color:var(--color-text-primary)}button:disabled{opacity:.64;cursor:not-allowed}button:focus,input:focus,select:focus,.brand:focus{outline:var(--focus-ring);outline-offset:2px}[role=status]{color:var(--color-text-muted)}[role=status]:empty{display:none}[data-denied]{color:var(--color-status-danger);font-weight:700}.is-error{color:var(--color-status-danger)}.is-ok{color:var(--color-status-success)}body[data-route-chrome=login] #app{grid-template-columns:1fr}body[data-route-chrome=login] .shell-nav{display:none}body[data-route-chrome=login] .topbar{display:none}body[data-route-chrome=login] main{min-height:100vh;place-content:center;background:linear-gradient(180deg,#eef4f7 0,#f8fafb 100%)}.overview-kpi-dashboard{grid-template-columns:1fr}.overview-summary{max-width:980px}.kpi-grid{grid-template-columns:repeat(4,minmax(130px,1fr));gap:10px}.kpi-grid dt{font-size:12px;text-transform:uppercase;letter-spacing:.04em}.kpi-grid dd{font-size:24px;line-height:1.1}.explorer-region{min-width:0}.explorer-region>h3{margin:0 0 8px}@media(max-width:1180px){.content-inspector,.explorer-shell,.file-explorer-grid,.detail-grid,.settings-grid{grid-template-columns:1fr}.secondary-inspector{position:relative;top:auto}.action-filter-bar{grid-template-columns:repeat(2,minmax(0,1fr))}.filter-scope{grid-column:1/-1}}@media(max-width:760px){#app{grid-template-columns:1fr}.shell-nav{position:relative}.dashboard,.toolbar,.explorer-shell,.topbar,.action-filter-bar,.content-inspector,.refresh-controls,.file-explorer-grid,.report-index-grid{grid-template-columns:1fr}main{padding:14px}dl{grid-template-columns:1fr}.shell-nav nav{grid-template-columns:repeat(2,minmax(0,1fr))}.secondary-inspector{position:relative;top:auto;order:2}.topbar h1{font-size:24px}.panel[data-route-panel=login]{margin-top:10px;padding:18px}}"#
}
pub fn browser_ui_js() -> &'static str {
r#"'use strict';
const uiBase=location.pathname==='/ui/v1'||location.pathname.startsWith('/ui/v1/')?'/ui/v1':'/ui';
const apiBase=uiBase==='/ui/v1'?'/api/v1':'/ui/api';
function routePath(suffix=''){return `${uiBase}${suffix}`}
function apiPath(suffix=''){return `${apiBase}${suffix}`}
const routeMap=new Map([[routePath(),'overview'],[routePath('/login'),'login'],[routePath('/logout'),'logout'],[routePath('/reports'),'reports'],[routePath('/buckets'),'buckets'],[routePath('/governance'),'governance'],[routePath('/audit'),'audit'],[routePath('/evidence'),'evidence'],[routePath('/settings'),'settings']]);
function bucketDetailPath(bucket){return `${routePath('/buckets')}/${enc(bucket)}`}
function objectDetailPath(bucket,key){return `${bucketDetailPath(bucket)}/objects/${enc(key)}`}
function routeForPath(path){if(routeMap.has(path))return routeMap.get(path);if(path.startsWith(`${routePath('/buckets')}/`))return 'buckets';return 'overview'}
const state={principal:null,session:null,route:routeForPath(location.pathname),returnTo:null,selectedBucket:null,selectedObject:null,activeBucketTab:'files',inspectorCollapsed:false,collapsedPrefixes:new Set(),filters:{query:'',prefix:'',status:'all',sort:'-sequence'},preferences:{density:'comfortable',refreshSeconds:30}};
const routeMeta={login:['Operator Login','Authenticate with a configured principal and shared secret.'],logout:['Sign Out','Clear the browser session and return to login.'],overview:['Overview','Runtime status, reports, buckets, governance, audit, evidence, and settings.'],reports:['Reports','Health, configuration, storage, incident, and governance report summaries.'],buckets:['Buckets','Browse buckets, folders, objects, metadata, and object-specific versions.'],governance:['Governance','Global and tenant-level policy, retention, legal-hold posture, and governance findings.'],audit:['Audit','Search, filter, sort, and inspect operator and runtime audit events.'],evidence:['Evidence','Review evidence bundles and export runtime proof records.'],settings:['Settings','Manage operator display, refresh, columns, and report preferences.']};
const routeChromeRules={login:{filters:false,refresh:false,inspector:false,selection:false},logout:{filters:false,refresh:false,inspector:false,selection:false},overview:{filters:false,refresh:true,inspector:false,selection:false},reports:{filters:false,refresh:true,inspector:false,selection:false},buckets:{filters:true,refresh:true,inspector:true,selection:true},governance:{filters:false,refresh:true,inspector:false,selection:false},audit:{filters:true,refresh:true,inspector:false,selection:false},evidence:{filters:false,refresh:true,inspector:false,selection:false},settings:{filters:false,refresh:true,inspector:false,selection:false}};
const governanceGateCatalog={
global:[{action:'create-global-policy',label:'Create global policy',requires:'tenant governance API',confirmation:'not runtime-enabled',available:false,unavailable:'Global policy CRUD is tracked but this runtime exposes read-only tenant governance reports.'},{action:'update-global-policy',label:'Update global policy',requires:'tenant governance API',confirmation:'not runtime-enabled',available:false,unavailable:'Global policy updates are tracked but not runtime-enabled in this browser UI.'},{action:'retire-global-policy',label:'Retire global policy',requires:'tenant governance API',confirmation:'not runtime-enabled',available:false,unavailable:'Global policy retirement is tracked but not runtime-enabled in this browser UI.'}],
bucket:[{action:'update-bucket-policy',label:'Update bucket policy',requires:'bucket governance permission',confirmation:'required'},{action:'update-lifecycle-rule',label:'Update lifecycle rule',requires:'bucket lifecycle permission',confirmation:'required'},{action:'update-retention-defaults',label:'Update retention defaults',requires:'bucket object lock permission',confirmation:'required'}],
object:[{action:'apply-legal-hold',label:'Apply legal hold',requires:'object governance permission',confirmation:'reason required'},{action:'lift-legal-hold',label:'Lift legal hold',requires:'object governance permission',confirmation:'reason required'},{action:'update-retention',label:'Update retention',requires:'object retention permission',confirmation:'required'}]
};
let refreshTimer=null;
const statusNode=document.querySelector('[data-auth-state]');
const principalNode=document.querySelector('[data-current-principal]');
const providerSelect=document.querySelector('[data-identity-provider]');
const routeTitleNode=document.querySelector('[data-route-title]');
const routeSummaryNode=document.querySelector('[data-route-summary]');
const tenantContextNode=document.querySelector('[data-context-tenant]');
const bucketContextNode=document.querySelector('[data-context-bucket]');
const objectContextNode=document.querySelector('[data-context-object]');
const sessionContextNode=document.querySelector('[data-context-session]');
const refreshIntervalNode=document.querySelector('[data-refresh-interval]');
const loadingNode=document.querySelector('[data-loading]');
const emptyNode=document.querySelector('[data-empty]');
const deniedNode=document.querySelector('[data-denied]');
const errorNode=document.querySelector('[data-error]');
const disabledActionReasonNode=document.querySelector('[data-disabled-action-reason]');
const loginForm=document.querySelector('[data-form=login]');
const loginButton=loginForm?.querySelector('button[type=submit]');
const loginAuthenticatedPanel=document.querySelector('[data-view=login-authenticated]');
function token(){return state.session?.access_key_id||sessionStorage.getItem('bwui_access_key')||''}
function setDisabledActionReason(message=''){if(!disabledActionReasonNode)return;disabledActionReasonNode.hidden=!message;text(disabledActionReasonNode,message)}
function clearSession(message='Unauthenticated'){state.session=null;state.principal=null;sessionStorage.removeItem('bwui_access_key');principalNode.textContent='Signed out';statusNode.textContent=message;statusNode.className='';setDisabledActionReason('missing authenticated session')}
function setStatus(kind,message){loadingNode.hidden=kind!=='loading';emptyNode.hidden=kind!=='empty';deniedNode.hidden=kind!=='denied';errorNode.hidden=kind!=='error';deniedNode.textContent=kind==='denied'?(message||'Permission denied'):'';errorNode.textContent=kind==='error'?message:'';if(kind!=='denied'&&token())setDisabledActionReason('')}
function isAuthExpired(error){return error.status===401||error.code==='SessionExpired'||error.code==='SessionInvalid'||error.code==='AuthenticationFailed'}
function handleApiFailure(error){if(error.status===403||error.code==='PermissionDenied'){setDisabledActionReason(error.message||'missing operator permission');setStatus('denied',error.message||'Permission denied for this action');renderInspector('permission denied');return}if(isAuthExpired(error)){const expired=error.code==='SessionExpired';clearSession(expired?'Session expired. Sign in again.':'Unauthenticated');state.returnTo=location.pathname===routePath('/login')?state.returnTo:location.pathname;navigate(routePath('/login'),false);return}setStatus('error',error.message||error.code||'Request failed')}
async function api(path,options={}){setStatus('loading','');const headers=Object.assign({'accept':'application/json'},options.headers||{});if(token())headers['x-bucketwarden-ui-access-key']=token();if(options.body&&!headers['content-type'])headers['content-type']='application/json';const response=await fetch(path,Object.assign({},options,{headers}));const payload=await response.json().catch(()=>({code:'InvalidJson',message:'Response was not JSON'}));if(!response.ok){const error=Object.assign({status:response.status},payload);handleApiFailure(error);throw error}setStatus('', '');return payload}
function text(node,value){node.textContent=value==null?'':String(value)}
function enc(value){return encodeURIComponent(String(value||''))}
function renderEmpty(selector,message,resource='record'){const host=document.querySelector(selector);host.replaceChildren();const node=document.createElement('div');node.className='empty-state';node.dataset.emptyResource=resource;node.setAttribute('role','status');node.setAttribute('aria-label',`${resource} empty state`);text(node,message);host.append(node);setStatus('empty','')}
function renderExplorerEmpty(selector,message,resource='record'){const host=document.querySelector(selector);host.replaceChildren();const explorer=document.createElement('div');explorer.className='explorer';const node=document.createElement('div');node.className='empty-state';node.dataset.emptyResource=resource;node.setAttribute('role','status');node.setAttribute('aria-label',`${resource} empty state`);text(node,message);explorer.append(node);host.append(explorer);setStatus('empty','')}
function formatNumber(value){const numeric=Number(value);return Number.isFinite(numeric)?new Intl.NumberFormat('en-US').format(numeric):String(value??'')}
function formatBytes(value){const bytes=Number(value);if(!Number.isFinite(bytes))return String(value??'');const units=['B','KB','MB','GB','TB'];let size=bytes;let unit=0;while(size>=1024&&unit<units.length-1){size=size/1024;unit+=1}return `${unit===0?formatNumber(size):size.toFixed(size>=10?1:2)} ${units[unit]}`}
function formatValue(label,value){if(value===null||value===undefined||value==='')return 'none';const name=String(label||'').toLowerCase();if(name.includes('bytes')||name.includes('size')||name.includes('content_length'))return formatBytes(value);if(typeof value==='number')return formatNumber(value);return String(value)}
function renderPairs(selector,pairs,emptyMessage='No records found',resource='record'){const node=document.querySelector(selector);node.replaceChildren();if(!pairs.length){renderEmpty(selector,emptyMessage,resource);return}for(const [key,value]of pairs){const dt=document.createElement('dt');const dd=document.createElement('dd');text(dt,key);text(dd,formatValue(key,value));node.append(dt,dd)}}
function routeChrome(route,key){return Boolean(routeChromeRules[route]?.[key])}
function inspectorAllowed(){return routeChrome(state.route,'inspector')&&(state.route==='buckets'?Boolean(state.selectedBucket):state.route==='governance'?Boolean(state.selectedObject):false)}
function setInspectorCollapsed(collapsed){state.inspectorCollapsed=collapsed;const inspector=document.querySelector('.secondary-inspector');const button=document.querySelector('[data-action=toggle-inspector]');if(inspector){inspector.hidden=!inspectorAllowed();inspector.dataset.inspectorState=collapsed?'collapsed':'open';inspector.classList.toggle('is-collapsed',collapsed)}if(button){button.setAttribute('aria-expanded',collapsed?'false':'true');text(button,collapsed?'Expand':'Collapse')}}
function renderInspector(reason='Ready'){setInspectorCollapsed(state.inspectorCollapsed);if(!inspectorAllowed())return;renderPairs('[data-view=selection-inspector]',[
['Route',routeMeta[state.route]?.[0]||state.route],
['Tenant','default'],
['Principal',state.session?.principal_id||'signed out'],
['Bucket',state.selectedBucket||'none'],
['Object',state.selectedObject||'none'],
['Filter',state.filters.query||state.filters.prefix||state.filters.status!=='all'?`${state.filters.query||'*'} / ${state.filters.prefix||'*'} / ${state.filters.status}`:'none'],
['State',reason]
])}
function renderTable(selector,rows,columns,emptyMessage='No records found',resource='record'){const host=document.querySelector(selector);host.replaceChildren();if(!rows.length){renderEmpty(selector,emptyMessage,resource);return}const table=document.createElement('table');const thead=document.createElement('thead');const headRow=document.createElement('tr');for(const col of columns){const th=document.createElement('th');text(th,col.label);headRow.append(th)}thead.append(headRow);table.append(thead);const body=document.createElement('tbody');for(const row of rows){const tr=document.createElement('tr');tr.tabIndex=0;for(const col of columns){const td=document.createElement('td');text(td,formatValue(col.key,row[col.key]));tr.append(td)}body.append(tr)}table.append(body);host.append(table)}
function renderReportIndex(rows){const host=document.querySelector('[data-view=report-index]');host.replaceChildren();if(!rows.length){renderEmpty('[data-view=report-index]','No reports available','report');return}const grid=document.createElement('div');grid.className='report-index-grid';for(const row of rows){const card=document.createElement('article');card.className='report-card';const title=document.createElement('h3');text(title,row.title||row.id);const meta=document.createElement('p');meta.className='muted';text(meta,`${row.category||'runtime'} / ${row.status||'unknown'} / ${row.freshness||'runtime'}`);const endpoint=document.createElement('code');text(endpoint,row.endpoint||'none');const action=document.createElement('button');action.type='button';action.className='secondary-action';action.dataset.reportAction=row.id||row.title||'report';text(action,`Open ${row.title||row.id}`);action.addEventListener('click',()=>{const target=document.querySelector('[data-view=route-report]');target?.scrollIntoView({block:'start',behavior:'smooth'});});card.append(title,meta,endpoint,action);grid.append(card)}host.append(grid)}
function renderActionTable(selector,rows,columns,actionLabel,onAction,emptyMessage='No records found',resource='record'){const host=document.querySelector(selector);host.replaceChildren();if(!rows.length){renderEmpty(selector,emptyMessage,resource);return}const table=document.createElement('table');const thead=document.createElement('thead');const headRow=document.createElement('tr');for(const col of columns){const th=document.createElement('th');text(th,col.label);headRow.append(th)}const actionHead=document.createElement('th');text(actionHead,'Action');headRow.append(actionHead);thead.append(headRow);table.append(thead);const body=document.createElement('tbody');for(const row of rows){const tr=document.createElement('tr');for(const col of columns){const td=document.createElement('td');text(td,row[col.key]);tr.append(td)}const action=document.createElement('td');const button=document.createElement('button');button.type='button';text(button,actionLabel);button.addEventListener('click',()=>onAction(row));action.append(button);tr.append(action);body.append(tr)}table.append(body);host.append(table)}
function appendEntityLabel(button,icon,label,meta){button.classList.add('entity-button');const mark=document.createElement('span');mark.className='entity-icon';mark.setAttribute('aria-hidden','true');text(mark,icon);const name=document.createElement('span');name.className='entity-name';text(name,label);button.append(mark,name);if(meta){const detail=document.createElement('span');detail.className='entity-meta';text(detail,meta);button.append(detail)}}
function renderBucketExplorer(rows){const host=document.querySelector('[data-view=route-buckets]');host.replaceChildren();if(!rows.length){renderExplorerEmpty('[data-view=route-buckets]','No buckets exist in this runtime','bucket');return}const explorer=document.createElement('div');explorer.className='explorer bucket-list';const list=document.createElement('ul');for(const row of rows){const selected=row.name===state.selectedBucket;const item=document.createElement('li');const button=document.createElement('button');button.type='button';button.className=selected?'is-selected':'';button.dataset.bucket=row.name;button.dataset.entityRow='bucket';button.setAttribute('aria-pressed',selected?'true':'false');button.setAttribute('aria-label',`Open bucket ${row.name}`);button.setAttribute('title',`Open bucket ${row.name}`);appendEntityLabel(button,'▣',row.name,`${formatNumber(row.object_count)} objects`);button.addEventListener('click',()=>{if(row.name===state.selectedBucket){setInspectorCollapsed(!state.inspectorCollapsed)}else{setInspectorCollapsed(false)}selectBucket(row.name)});item.append(button);list.append(item)}explorer.append(list);host.append(explorer)}
function prefixVisible(prefix){for(const collapsed of state.collapsedPrefixes){if(prefix!==collapsed&&prefix.startsWith(collapsed))return false}return true}
function renderFileTree(rows,bucket){const host=document.querySelector('[data-view=file-tree]');if(!host)return;host.replaceChildren();const explorer=document.createElement('div');explorer.className='explorer file-tree';explorer.setAttribute('role','tree');const list=document.createElement('ul');const prefixes=new Set();for(const row of rows){const parts=String(row.key).split('/');for(let i=1;i<parts.length;i++)prefixes.add(parts.slice(0,i).join('/')+'/')}if(!prefixes.size){const item=document.createElement('li');item.setAttribute('role','treeitem');item.setAttribute('aria-level','1');text(item,'/');list.append(item)}for(const prefix of [...prefixes].sort().filter(prefixVisible)){const item=document.createElement('li');const depth=String(prefix).split('/').filter(Boolean).length;const collapsed=state.collapsedPrefixes.has(prefix);item.setAttribute('role','treeitem');item.setAttribute('aria-level',String(depth));item.setAttribute('aria-expanded',collapsed?'false':'true');const button=document.createElement('button');button.type='button';button.dataset.prefix=prefix;button.dataset.treePath=prefix;button.setAttribute('aria-expanded',collapsed?'false':'true');button.setAttribute('aria-label',`${collapsed?'Expand':'Collapse'} prefix ${prefix}`);appendEntityLabel(button,collapsed?'▸':'▾',prefix,'prefix');button.addEventListener('click',()=>{if(state.collapsedPrefixes.has(prefix)){state.collapsedPrefixes.delete(prefix)}else{state.collapsedPrefixes.add(prefix)}state.filters.prefix=prefix;document.querySelector('[name=prefix]').value=prefix;selectBucket(bucket)});item.append(button);list.append(item)}explorer.append(list);host.append(explorer)}
function renderBreadcrumbs(bucket){const host=document.querySelector('[data-view=file-breadcrumbs]');if(!host)return;host.replaceChildren();const parts=String(state.filters.prefix||'').split('/').filter(Boolean);const root=document.createElement('button');root.type='button';root.className='breadcrumb-item';root.setAttribute('aria-label',`Open bucket root ${bucket||''}`.trim());if(!parts.length)root.setAttribute('aria-current','page');text(root,bucket||'bucket');root.addEventListener('click',()=>{state.filters.prefix='';document.querySelector('[name=prefix]').value='';if(bucket)selectBucket(bucket)});host.append(root);let path='';parts.forEach((part,index)=>{path+=`${part}/`;const separator=document.createElement('span');separator.className='breadcrumb-separator';separator.setAttribute('aria-hidden','true');text(separator,'/');const crumb=document.createElement('button');crumb.type='button';crumb.className='breadcrumb-item';crumb.dataset.prefix=path;if(index===parts.length-1)crumb.setAttribute('aria-current','page');text(crumb,part);crumb.addEventListener('click',()=>{state.filters.prefix=path;document.querySelector('[name=prefix]').value=path;if(bucket)selectBucket(bucket)});host.append(separator,crumb)})}
function renderObjectExplorer(rows,bucket){renderBreadcrumbs(bucket);renderFileTree(rows,bucket);const query=String(state.filters.query||'').toLowerCase();const filtered=query?rows.filter((row)=>String(row.key).toLowerCase().includes(query)):rows;if(!filtered.length){renderExplorerEmpty('[data-view=object-list]',query?`No objects matching ${state.filters.query}`:`No objects in ${bucket}`,'object');return}const host=document.querySelector('[data-view=object-list]');host.replaceChildren();const explorer=document.createElement('div');explorer.className='explorer object-table';const list=document.createElement('ul');for(const row of filtered){const selected=row.key===state.selectedObject;const item=document.createElement('li');item.className='object-node';const button=document.createElement('button');button.type='button';button.className=selected?'is-selected':'';button.dataset.bucket=bucket;button.dataset.objectKey=row.key;button.dataset.entityRow='object';button.setAttribute('aria-pressed',selected?'true':'false');button.setAttribute('aria-label',`Open object ${row.key}`);button.setAttribute('title',`Open object ${row.key}`);appendEntityLabel(button,'□',row.key,`${formatNumber(row.version_count)} versions, ${formatBytes(row.total_bytes||0)}`);button.addEventListener('click',()=>{if(row.key===state.selectedObject){setInspectorCollapsed(!state.inspectorCollapsed)}else{setInspectorCollapsed(false)}selectObject(bucket,row.key)});item.append(button);list.append(item)}explorer.append(list);host.append(explorer)}
function renderVersionActions(rows){const host=document.querySelector('[data-view=version-actions]');if(!host)return;host.replaceChildren();if(!rows.length)return;const latest=rows.find((row)=>row.is_latest)||rows[0];for(const label of ['Preview latest','Download latest','Restore as copy','Delete object']){const button=document.createElement('button');button.type='button';button.dataset.action=`object-${label.toLowerCase().split(' ')[0]}`;button.disabled=label!=='Preview latest';button.title=`${label} uses selected version ${latest.version_id}`;text(button,label);host.append(button)}}
function renderObjectDetail(bucket,key,versions){const latest=versions.find((row)=>row.is_latest)||versions[0];if(!latest){renderEmpty('[data-view=object-detail]','No selected object');renderEmpty('[data-view=object-preview]','No preview available');return}renderPairs('[data-view=object-detail]',[['Bucket',bucket],['Object key',key],['Latest version',latest.version_id],['Version label',latest.version_label],['Bytes',latest.content_length],['ETag',latest.etag],['Owner',latest.owner],['Legal hold',latest.legal_hold],['Retention',latest.retention_mode||'none'],['Replication',latest.replication_status||'none']]);const preview=document.querySelector('[data-view=object-preview]');preview.replaceChildren();const card=document.createElement('div');card.className='preview-panel';text(card,`Preview: ${key} (${formatBytes(latest.content_length)}) is available through the authenticated S3 GetObject/download surface. Binary-safe inline previews stay metadata-only until a content preview API is enabled.`);preview.append(card)}
function renderBucketSelectionEmpty(){renderEmpty('[data-view=bucket-detail]','Select a bucket to open its full detail page','bucket');renderExplorerEmpty('[data-view=file-tree]','Select a bucket to show its folder tree','bucket');renderEmpty('[data-view=file-breadcrumbs]','No bucket path selected','bucket');renderExplorerEmpty('[data-view=object-list]','Select a bucket to browse objects','object');renderEmpty('[data-view=version-history]','No object selected','version');renderEmpty('[data-view=version-actions]','No object actions available','object-action');renderEmpty('[data-view=object-detail]','No object selected','object');renderEmpty('[data-view=object-preview]','No object preview available','object');renderEmpty('[data-view=bucket-governance]','Select a bucket to show governance and policy records','bucket-governance');renderEmpty('[data-view=bucket-governance-gates]','No bucket actions available','bucket-action')}
async function loadBucketExplorer(){const buckets=await api(`${apiPath('/buckets')}?sort=name`);renderBucketExplorer(buckets.buckets);if(!buckets.buckets.length){state.selectedBucket=null;state.selectedObject=null;renderBucketSelectionEmpty();syncShell();return}if(!state.selectedBucket){state.selectedObject=null;renderBucketSelectionEmpty();syncShell();return}await selectBucket(state.selectedBucket,false)}
function renderGovernanceGates(selector,scope,allowed){const host=document.querySelector(selector);if(!host)return;host.replaceChildren();for(const gate of governanceGateCatalog[scope]||[]){const runtimeSupported=gate.available!==false;const enabled=allowed&&runtimeSupported;const reason=enabled?`Requires ${gate.requires}; ${gate.confirmation}`:gate.unavailable||`Blocked: requires ${gate.requires}; ${gate.confirmation}`;const button=document.createElement('button');button.type='button';button.className=enabled?'secondary-action':'secondary-action is-gated';button.disabled=!enabled;button.setAttribute('aria-disabled',enabled?'false':'true');button.dataset.governanceGate=scope;button.dataset.governanceAction=gate.action;button.dataset.requires=gate.requires;button.dataset.confirmation=gate.confirmation;button.dataset.runtimeSupport=runtimeSupported?'supported':'unsupported';button.dataset.policyScope=scope==='global'?'tenant-global':'scoped-resource';button.title=enabled?`${gate.label}; requires ${gate.requires}; ${gate.confirmation}`:reason;text(button,gate.label);const note=document.createElement('span');note.className='gate-note';note.dataset.disabledReason=enabled?'none':reason;text(note,reason);const wrap=document.createElement('span');wrap.className='gate-control';wrap.dataset.gateState=enabled?'enabled':'read-only';wrap.append(button,note);host.append(wrap)}}
function governanceFindingRecord(finding){const raw=String(finding||'');const parts=raw.split(':').map((part)=>part.trim()).filter(Boolean);return {scope:parts[0]||'global',check:parts[1]||'governance',status:raw.toLowerCase().includes('missing')?'Action needed':'Review',message:raw}}
function renderEvidenceDetail(item){const host=document.querySelector('[data-view=evidence-detail]');if(!host)return;host.replaceChildren();if(!item){renderEmpty('[data-view=evidence-detail]','No evidence selected','evidence');return}const title=document.createElement('h3');text(title,item.name);const dl=document.createElement('dl');for(const pair of [['Type',item.content_type],['Records',item.record_count],['Bytes',item.bytes]]){const dt=document.createElement('dt');const dd=document.createElement('dd');text(dt,pair[0]);text(dd,formatValue(pair[0],pair[1]));dl.append(dt,dd)}const preview=document.createElement('pre');preview.className='preview-panel';text(preview,`Evidence ${item.name} contains ${formatNumber(item.record_count)} records and ${formatBytes(item.bytes)}.`);host.append(title,dl,preview)}
function selectEvidenceRow(row,tr){for(const current of document.querySelectorAll('[data-evidence-row]'))current.setAttribute('aria-selected','false');if(tr)tr.setAttribute('aria-selected','true');renderEvidenceDetail(row)}
function renderEvidenceList(rows){const host=document.querySelector('[data-view=route-evidence]');host.replaceChildren();if(!rows.length){renderEmpty('[data-view=route-evidence]','No evidence records available','evidence');renderEvidenceDetail(null);return}const table=document.createElement('table');const thead=document.createElement('thead');const head=document.createElement('tr');for(const label of ['Evidence','Type','Records','Bytes']){const th=document.createElement('th');text(th,label);head.append(th)}thead.append(head);table.append(thead);const body=document.createElement('tbody');rows.forEach((row,index)=>{const tr=document.createElement('tr');tr.tabIndex=0;tr.dataset.evidenceRow=row.name;tr.setAttribute('aria-selected','false');tr.addEventListener('click',()=>selectEvidenceRow(row,tr));tr.addEventListener('keydown',(event)=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();selectEvidenceRow(row,tr)}});for(const [key,label]of [['name','Evidence'],['content_type','Type'],['record_count','Records'],['bytes','Bytes']]){const td=document.createElement('td');text(td,formatValue(label,row[key]));tr.append(td)}body.append(tr);if(index===0)queueMicrotask(()=>selectEvidenceRow(row,tr))});table.append(body);host.append(table)}
async function selectBucket(bucket,push=true){state.selectedBucket=bucket;state.selectedObject=null;const detail=await api(`${apiPath('/buckets')}/${enc(bucket)}`);renderPairs('[data-view=bucket-detail]',[['Name',detail.bucket.name],['Owner',detail.bucket.owner],['Region',detail.bucket.region],['Versioning',detail.bucket.versioning_status],['Object lock',detail.bucket.has_object_lock],['Policy statements',detail.policies.length],['Findings',detail.findings.length],['Total bytes',detail.bucket.total_bytes]]);renderPairs('[data-view=bucket-governance]',[['Versioning',detail.bucket.versioning_status],['Object lock',detail.bucket.has_object_lock],['Lifecycle',detail.bucket.has_lifecycle],['Policy statements',detail.policies.length],['Findings',detail.findings.length],['Encrypted',detail.bucket.has_encryption],['Replication',detail.bucket.has_replication]]);renderGovernanceGates('[data-view=bucket-governance-gates]','bucket',Boolean(state.session));const objects=await api(`${apiPath('/buckets')}/${enc(bucket)}/objects?sort=key&prefix=${enc(state.filters.prefix)}`);renderObjectExplorer(objects.objects,bucket);const query=String(state.filters.query||'').toLowerCase();const selectable=query?objects.objects.filter((row)=>String(row.key).toLowerCase().includes(query)):objects.objects;if(selectable.length){const params=new URLSearchParams(location.search);await selectObject(bucket,params.get('key')||state.selectedObject||selectable[0].key,false)}else{state.selectedObject=null;renderEmpty('[data-view=version-history]',`No versions in ${bucket}`);renderEmpty('[data-view=object-detail]','No object selected');renderEmpty('[data-view=object-preview]','No preview available');renderVersionActions([])}renderInspector('bucket selected');if(push)history.pushState(null,'',bucketDetailPath(bucket));syncShell()}
async function selectObject(bucket,key,push=true){state.selectedBucket=bucket;state.selectedObject=key;const versions=await api(`${apiPath('/object-versions')}?bucket=${enc(bucket)}&key=${enc(key)}`);renderTable('[data-view=version-history]',versions.versions,[{key:'version_label',label:'Version'},{key:'version_id',label:'Version ID'},{key:'is_latest',label:'Latest'},{key:'delete_marker',label:'Delete marker'},{key:'last_modified_epoch_seconds',label:'Modified'},{key:'content_length',label:'Bytes'},{key:'retention_mode',label:'Retention'},{key:'legal_hold',label:'Legal hold'}]);renderVersionActions(versions.versions);renderObjectDetail(bucket,key,versions.versions);renderInspector('object selected');if(push)history.pushState(null,'',objectDetailPath(bucket,key));syncShell()}
async function ensureSelectedObject(){if(state.selectedBucket&&state.selectedObject)return;const buckets=await api(`${apiPath('/buckets')}?sort=name&limit=1`);if(!buckets.buckets.length)return;state.selectedBucket=buckets.buckets[0].name;const objects=await api(`${apiPath('/buckets')}/${enc(state.selectedBucket)}/objects?sort=key&limit=1`);if(objects.objects.length)state.selectedObject=objects.objects[0].key}
async function loadGovernanceView(){resetSelectionsForRoute('governance');const global=await api(apiPath('/reports/governance'));renderPairs('[data-view=global-governance-dashboard]',[['Scope','Global tenant governance'],['Retention buckets',global.retention_bucket_count],['Retained versions',global.retained_version_count],['Lifecycle buckets',global.lifecycle_bucket_count],['Lifecycle rules',global.lifecycle_rule_count],['Active legal holds',global.legal_hold_object_count||0],['Findings',global.security_governance_findings.length]]);renderGovernanceGates('[data-view=global-governance-gates]','global',Boolean(state.session));renderPairs('[data-view=global-retention-summary]',[['Scope',global.retention_scope||'tenant'],['Total buckets',global.total_bucket_count||0],['Retention buckets',global.retention_bucket_count],['Retention coverage',`${global.retention_coverage_percent||0}%`],['Retention exceptions',global.retention_exception_bucket_count||0],['Retained versions',global.retained_version_count],['Lifecycle buckets',global.lifecycle_bucket_count],['Lifecycle rules',global.lifecycle_rule_count],['Remediation surface',global.retention_remediation_surface||routePath('/buckets')]]);renderPairs('[data-view=global-legal-hold-summary]',[['Scope',global.legal_hold_scope||'tenant'],['Total objects',global.total_object_count||0],['Active legal holds',global.legal_hold_object_count||0],['Legal-hold coverage',`${global.legal_hold_coverage_percent||0}%`],['Legal-hold exceptions',global.legal_hold_exception_object_count||0],['Mutation surface',global.legal_hold_mutation_surface||'object-detail'],['Remediation surface',global.legal_hold_remediation_surface||routePath('/buckets')]]);renderTable('[data-view=global-governance-findings]',(global.security_governance_findings||[]).map(governanceFindingRecord),[{key:'scope',label:'Scope'},{key:'check',label:'Check'},{key:'status',label:'Status'},{key:'message',label:'Message'}],'No governance findings','finding');syncShell()}
async function submitLegalHold(event){event.preventDefault();if(!state.selectedBucket||!state.selectedObject){setStatus('empty','No selected object');return}const submitter=event.submitter;const data=new FormData(event.currentTarget);const enabled=String(submitter?.value||'true')==='true';const reason=String(data.get('reason')||'').trim();const status=document.querySelector('[data-view=legal-hold-action]');if(!reason){text(status,'Reason is required');return}const qs=`bucket=${enc(state.selectedBucket)}&key=${enc(state.selectedObject)}`;const updated=await api(`${apiPath('/legal-hold')}?${qs}`,{method:'POST',body:JSON.stringify({enabled,reason})});text(status,`${enabled?'Applied':'Lifted'} legal hold for ${updated.version_id}`);await loadGovernanceView()}
async function loadAuditView(){const params=new URLSearchParams();params.set('limit','25');if(state.filters.query)params.set('q',state.filters.query);if(state.filters.status&&state.filters.status!=='all')params.set('outcome',state.filters.status);params.set('sort',state.filters.sort||'-sequence');const audit=await api(`${apiPath('/audit')}?${params.toString()}`);renderTable('[data-view=route-audit]',audit.events,[{key:'sequence',label:'Seq'},{key:'subject',label:'Subject'},{key:'action',label:'Action'},{key:'resource',label:'Resource'},{key:'outcome',label:'Outcome'},{key:'detail',label:'Detail'}])}
async function loadEvidenceView(){const evidence=await api(`${apiPath('/evidence')}?sort=name`);renderEvidenceList(evidence.evidence)}
async function downloadEvidenceExport(){const button=document.querySelector('[data-action=download-evidence]');const status=document.querySelector('[data-view=evidence-download]');if(button)button.disabled=true;try{const exportPayload=await api(apiPath('/evidence-export'));const blob=new Blob([JSON.stringify(exportPayload.report,null,2)],{type:exportPayload.content_type});const link=document.createElement('a');link.href=URL.createObjectURL(blob);link.download=exportPayload.filename;link.click();URL.revokeObjectURL(link.href);text(status,`Prepared ${exportPayload.filename}`)}finally{if(button)button.disabled=false}}
const preferenceKeys=['bucketwarden.ui.density','bucketwarden.ui.refreshSeconds','bucketwarden.ui.visibleColumns','bucketwarden.ui.reportFilters'];
function applyPreferenceControls(values){for(const key of preferenceKeys){const input=document.querySelector(`[name="${key}"]`);if(input)input.value=values[key]||''}state.preferences=Object.assign({},state.preferences,values)}
function preferenceFormValues(){const values={};for(const key of preferenceKeys){const input=document.querySelector(`[name="${key}"]`);const value=String(input?.value||'').trim();if(value)values[key]=value}return values}
async function loadPreferencesView(){const prefs=await api(apiPath('/preferences'));applyPreferenceControls(prefs.values||{});renderPairs('[data-view=route-preferences]',preferenceKeys.map((key)=>[key,(prefs.values||{})[key]||'default']));await loadAdminView()}
async function loadAdminView(){const admin=await api(apiPath('/admin'));if(admin.tenant_scope.tenant_ids.length){renderPairs('[data-view=tenant-scope]',[['Selected tenant',admin.tenant_scope.selected_tenant_id],['Tenant count',admin.tenant_scope.tenant_ids.length],['Scoped request header',admin.tenant_scope.scoped_request_header]])}else{renderEmpty('[data-view=tenant-scope]','No tenants configured','tenant')}renderTable('[data-view=admin-users]',admin.users,[{key:'principal_id',label:'Principal'},{key:'tenant_id',label:'Tenant'},{key:'kind',label:'Kind'},{key:'enabled',label:'Enabled'},{key:'operator_role_count',label:'Roles'}],'No users configured','user');renderTable('[data-view=admin-roles]',admin.roles,[{key:'role',label:'Role'},{key:'assignment_count',label:'Assignments'}],'No roles configured','role');renderTable('[data-view=admin-role-assignments]',admin.assignments,[{key:'principal_id',label:'Principal'},{key:'role',label:'Role'},{key:'scope',label:'Scope'}],'No role assignments configured','role-assignment');renderTable('[data-view=admin-effective-permissions]',admin.effective_permissions.map((permission)=>({permission})),[{key:'permission',label:'Permission'}],'No permissions configured','permission');renderTable('[data-view=admin-groups]',(admin.groups||[]).map((group)=>({group})),[{key:'group',label:'Group'}],'No groups configured','group');if(admin.users.length){const detail=await api(`${apiPath('/admin/users')}/${enc(admin.users[0].principal_id)}`);renderPairs('[data-view=admin-user-detail]',[['Principal',detail.principal_id],['Tenant',detail.tenant_id],['Kind',detail.kind],['Enabled',detail.enabled],['Assignments',detail.assignments.length],['Effective permissions',detail.effective_permissions.length]])}else{renderEmpty('[data-view=admin-user-detail]','No user selected','user')}}
async function savePreferencesView(){const values=preferenceFormValues();const saved=await api(apiPath('/preferences'),{method:'PUT',body:JSON.stringify({values})});applyPreferenceControls(saved.values||{});renderPairs('[data-view=route-preferences]',preferenceKeys.map((key)=>[key,(saved.values||{})[key]||'default']));text(document.querySelector('[data-view=preferences-save]'),'Preferences saved')}
async function loadIdentityProviders(){if(!providerSelect)return;const response=await api(apiPath('/identity-providers'));providerSelect.replaceChildren();for(const provider of response.providers||[]){const option=document.createElement('option');option.value=provider.provider_id;option.disabled=!provider.enabled;text(option,provider.enabled?provider.label:`${provider.label} (disabled)`);providerSelect.append(option)}providerSelect.value=response.default_provider_id||'custom-shared-secret'}
function routeUsesGlobalFilter(route){return routeChrome(route,'filters')}
function syncShell(){document.body.dataset.routeChrome=state.route;document.body.dataset.inspectorVisible=inspectorAllowed()?'true':'false';const bucketPanel=document.querySelector('[data-route-panel=buckets]');if(bucketPanel)bucketPanel.dataset.hasSelection=state.selectedBucket?'true':'false';principalNode.textContent=state.session?`Signed in as ${state.session.principal_id}`:'Signed out';const meta=routeMeta[state.route]||['Overview','Runtime status'];text(routeTitleNode,meta[0]);text(routeSummaryNode,meta[1]);text(sessionContextNode,state.session?`Session: ${state.session.principal_id}`:'Session: signed out');text(tenantContextNode,'Tenant: default');text(bucketContextNode,`Bucket: ${state.selectedBucket||'none'}`);text(objectContextNode,`Object: ${state.selectedObject||'none'}`);bucketContextNode.hidden=!state.selectedBucket;objectContextNode.hidden=!state.selectedObject;for(const link of document.querySelectorAll('.shell-nav a')){const linkRoute=routeForPath(new URL(link.href).pathname);link.classList.toggle('is-active',linkRoute===state.route);link.hidden=linkRoute==='login'&&Boolean(state.session)}for(const panel of document.querySelectorAll('[data-route-panel]'))panel.hidden=panel.getAttribute('data-route-panel')!==state.route;const filter=document.querySelector('[data-form=global-filter]');if(filter){filter.hidden=!routeUsesGlobalFilter(state.route);filter.dataset.searchScope=state.route}if(loginForm&&loginAuthenticatedPanel){const signedIn=Boolean(state.session);loginForm.hidden=signedIn;loginAuthenticatedPanel.hidden=!signedIn;text(document.querySelector('[data-view=login-session-summary]'),signedIn?`Signed in as ${state.session.principal_id}.`:'Signed out.')}document.querySelector('.refresh-controls').hidden=!routeChrome(state.route,'refresh');syncFilterControls();renderInspector()}
async function logoutCurrent(push=true){const hadToken=Boolean(token());if(hadToken)await api(apiPath('/logout'),{method:'POST'}).catch(()=>{});clearSession('Unauthenticated');state.route='login';state.returnTo=null;if(push)history.pushState(null,'',routePath('/login'));syncShell()}
function resetSelectionsForRoute(route){if(!routeChrome(route,'selection')){state.selectedBucket=null;state.selectedObject=null;state.filters.prefix=''}}
async function navigate(path,push=true){const next=routeForPath(path);if(next==='logout'){state.route='logout';if(push)history.pushState(null,'',path);syncShell();return logoutCurrent(true)}if(next!=='login'&&!token()){state.route='login';state.returnTo=path;syncShell();return}state.route=next;hydrateRouteState(path);if(push)history.pushState(null,'',path);syncShell();await refresh()}
async function refresh(){if(!token()&&state.route!=='login')return navigate(routePath('/login'),false);if(state.route==='login')return;try{if(state.route==='overview'){const overview=await api(apiPath('/overview'));state.session=overview.session;state.principal=overview.session.principal_id;syncShell();renderPairs('[data-view=overview]',[['Buckets',overview.metrics.bucket_count],['Objects',overview.metrics.object_count],['Versions',overview.metrics.version_count],['Total bytes',overview.metrics.total_bytes],['Retention buckets',overview.retention_bucket_count],['Retained versions',overview.retained_version_count],['Lifecycle buckets',overview.lifecycle_bucket_count],['Status',overview.health.status]])}else if(state.route==='reports'){const reports=await api(apiPath('/reports'));const reportRows=(reports.reports||[]).map((row)=>Object.assign({freshness:'runtime'},row));renderReportIndex(reportRows);const health=await api(apiPath('/reports/health'));renderPairs('[data-view=route-report]',[['Status',health.status],['Ready',health.ready],['Generated',health.generated_at_epoch_seconds],['Issues',health.issues.length],['Active storage',health.active_storage_backend]]);const config=await api(apiPath('/reports/config'));renderPairs('[data-view=route-config]',[['Default region',config.default_bucket_region],['KMS key',config.key_id],['Storage backend',config.active_storage_backend],['Replication strategy',config.active_replication_strategy],['Metadata persistence',config.metadata_persistence_model],['Buckets with object lock',config.buckets_with_object_lock.length]]);const storage=await api(apiPath('/reports/storage'));renderPairs('[data-view=route-storage]',[['Active backend',storage.active_backend],['Supported',storage.supported_backends.length],['Unsupported',storage.unsupported_backends.length],['Caveats',storage.caveats.length]]);const governance=await api(apiPath('/reports/governance'));renderPairs('[data-view=route-report-governance]',[['Retention buckets',governance.retention_bucket_count],['Retained versions',governance.retained_version_count],['Lifecycle buckets',governance.lifecycle_bucket_count],['Findings',governance.security_governance_findings.length]]);const incident=await api(apiPath('/reports/incident'));renderPairs('[data-view=route-incident]',[['Type',incident.incident_type],['Status',incident.status],['Summary',incident.summary],['Evidence',incident.evidence.length]])}else if(state.route==='buckets'){await loadBucketExplorer()}else if(state.route==='governance'){await loadGovernanceView()}else if(state.route==='audit'){await loadAuditView()}else if(state.route==='evidence'){await loadEvidenceView()}else if(state.route==='settings'){await loadPreferencesView()}}catch(error){handleApiFailure(error)}}
async function bootstrapSession(){if(state.route==='logout')return logoutCurrent(true);if(!token()){if(state.route!=='login')state.returnTo=location.pathname;return navigate(routePath('/login'),false)}try{const session=await api(apiPath('/current-user'));state.session=session;state.principal=session.principal_id;statusNode.textContent=`Authenticated as ${state.principal}`;statusNode.className='is-ok';syncShell();await refresh()}catch(error){handleApiFailure(error)}}
function syncFilterControls(){const form=document.querySelector('[data-form=global-filter]');if(!form)return;form.elements.q.value=state.filters.query||'';form.elements.prefix.value=state.filters.prefix||'';form.elements.status.value=state.filters.status||'all';form.elements.sort.value=state.filters.sort||'-sequence';const scope=document.querySelector('[data-filter-scope]');if(scope)text(scope,`Scope: ${routeMeta[state.route]?.[0]||state.route}`)}
function setBucketTab(tab){state.activeBucketTab=tab;for(const panel of document.querySelectorAll('[data-bucket-panel]'))panel.hidden=panel.getAttribute('data-bucket-panel')!==tab;for(const button of document.querySelectorAll('[data-bucket-tab]')){const selected=button.dataset.bucketTab===tab;button.setAttribute('aria-selected',selected?'true':'false');button.tabIndex=selected?0:-1}}
function hydrateRouteState(path=location.pathname){const params=new URLSearchParams(location.search);const bucketPrefix=`${routePath('/buckets')}/`;if(path.startsWith(bucketPrefix)){const parts=path.slice(bucketPrefix.length).split('/');state.selectedBucket=decodeURIComponent(parts[0]||'')||null;state.selectedObject=parts[1]==='objects'?decodeURIComponent(parts.slice(2).join('/')||''):null}else{if(params.get('bucket'))state.selectedBucket=params.get('bucket');if(params.get('key'))state.selectedObject=params.get('key')}if(params.get('prefix'))state.filters.prefix=params.get('prefix');if(params.get('q'))state.filters.query=params.get('q');resetSelectionsForRoute(state.route);syncFilterControls();setBucketTab(state.activeBucketTab)}
function scheduleRefresh(){if(refreshTimer)clearInterval(refreshTimer);const seconds=Number(refreshIntervalNode?.value||0);if(seconds>0)refreshTimer=setInterval(()=>refresh().catch(handleApiFailure),seconds*1000)}
hydrateRouteState();
loadIdentityProviders().catch(handleApiFailure);
loginForm?.addEventListener('submit',(event)=>{
event.preventDefault();
const form=event.currentTarget;
const data=new FormData(event.currentTarget);
if(loginButton)loginButton.disabled=true;
api(apiPath('/login'),{method:'POST',body:JSON.stringify({principal_id:String(data.get('principal')||''),shared_secret:String(data.get('secret')||''),identity_provider:String(data.get('identity_provider')||'custom-shared-secret')})}).then((session)=>{state.session=session;state.principal=session.principal_id;sessionStorage.setItem('bwui_access_key',session.access_key_id);statusNode.textContent=`Authenticated as ${state.principal}`;statusNode.className='is-ok';const secret=form.querySelector('[name=secret]');if(secret)secret.value='';navigate(state.returnTo&&state.returnTo!==routePath('/login')?state.returnTo:routePath(),true)}).catch((error)=>{clearSession('Unauthenticated');statusNode.textContent=error.message||'Authentication failed';statusNode.className='is-error'}).finally(()=>{if(loginButton)loginButton.disabled=false});
});
document.querySelector('[data-action=logout]')?.addEventListener('click',()=>logoutCurrent(true));
document.querySelector('[data-action=login-continue]')?.addEventListener('click',()=>navigate(routePath(),true));
document.querySelector('[data-action=login-signout]')?.addEventListener('click',()=>logoutCurrent(true));
document.querySelector('[data-action=toggle-inspector]')?.addEventListener('click',()=>setInspectorCollapsed(!state.inspectorCollapsed));
document.querySelector('[data-action=clear-filters]')?.addEventListener('click',()=>{state.filters={query:'',prefix:'',status:'all',sort:'-sequence'};syncFilterControls();refresh().catch(handleApiFailure)});
document.querySelectorAll('[data-bucket-tab]').forEach((button)=>button.addEventListener('click',()=>setBucketTab(button.dataset.bucketTab)));
document.querySelector('[data-form=global-filter]')?.addEventListener('submit',(event)=>{event.preventDefault();const data=new FormData(event.currentTarget);state.filters.query=String(data.get('q')||'');state.filters.prefix=String(data.get('prefix')||'');state.filters.status=String(data.get('status')||'all')||'all';state.filters.sort=String(data.get('sort')||'-sequence');syncFilterControls();refresh().catch(handleApiFailure)});
document.querySelector('[data-form=legal-hold]')?.addEventListener('submit',(event)=>submitLegalHold(event).catch(handleApiFailure));
document.querySelector('[data-action=refresh]')?.addEventListener('click',()=>refresh().catch(handleApiFailure));
refreshIntervalNode?.addEventListener('change',scheduleRefresh);
document.querySelector('[data-action=download-evidence]')?.addEventListener('click',()=>downloadEvidenceExport().catch(handleApiFailure));
document.querySelector('[data-form=preferences]')?.addEventListener('submit',(event)=>{event.preventDefault();savePreferencesView().catch(handleApiFailure)});
document.querySelectorAll('.shell-nav a').forEach((link)=>link.addEventListener('click',(event)=>{event.preventDefault();navigate(new URL(link.href).pathname,true)}));
addEventListener('popstate',()=>navigate(location.pathname,false));
syncShell();bootstrapSession();
scheduleRefresh();
window.BucketWardenUI={state,api,navigate,refresh,logout(){return logoutCurrent(true)},features:Array.from(document.querySelectorAll('[data-ui-feature]')).map((node)=>node.getAttribute('data-ui-feature'))};
"#
}
pub(super) fn browser_ui_feature_statuses() -> Vec<BrowserUiFeatureStatus> {
browser_ui_feature_definitions()
.into_iter()
.map(
|(feature_id, title, category, runtime_surface)| BrowserUiFeatureStatus {
feature_id: feature_id.to_string(),
title: title.to_string(),
category: category.to_string(),
runtime_surface: runtime_surface.to_string(),
implementation_status: "implemented".to_string(),
claim_tier: BROWSER_UI_CLAIM_TIER.to_string(),
},
)
.collect()
}
pub(super) fn browser_ui_assets() -> Vec<BrowserUiAsset> {
let manifest_bytes = browser_ui_manifest_json().len();
browser_ui_assets_with_manifest_bytes(manifest_bytes)
}
fn browser_ui_assets_with_manifest_bytes(manifest_bytes: usize) -> Vec<BrowserUiAsset> {
[
("/ui", "text/html; charset=utf-8", browser_ui_html().len()),
(
"/ui/assets/app.css",
"text/css; charset=utf-8",
browser_ui_css().len(),
),
(
"/ui/assets/app.js",
"application/javascript; charset=utf-8",
browser_ui_js().len(),
),
(
"/ui/assets/bucketwarden-monogram.png",
"image/png",
include_bytes!("../../assets/bucketwarden-monogram.png")
.len(),
),
(
"/ui/assets/favicon.png",
"image/png",
include_bytes!("../../assets/bucketwarden-monogram.png")
.len(),
),
(
"/ui/manifest.json",
"application/json; charset=utf-8",
manifest_bytes,
),
]
.into_iter()
.map(|(path, content_type, bytes)| BrowserUiAsset {
path: path.to_string(),
content_type: content_type.to_string(),
bytes,
})
.collect()
}